Skip to content

Commit a749989

Browse files
committed
feat(clients): auto-reconnect on drop + live session timer
- Auto-reconnect: if the tunnel drops while the user still wants it (they didn't disconnect) and it had reached 'connected', bring it back up ~3s later. Only a genuine drop reconnects — initial connect failures fall through to the error + watchdog, so there's no retry loop. Desktop also treats a polled 'down'/'error' status as a drop. - Session timer: stamp the connect time (connectedSince) and show a live "Connected · <elapsed>" — under the orb on desktop, in the connected subtitle on mobile — ticking each second. tsc / eslint / prettier / tests green on both clients.
1 parent 721f9d9 commit a749989

6 files changed

Lines changed: 138 additions & 5 deletions

File tree

clients/desktop/src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { CountryPicker } from './components/CountryPicker.js';
66
import { MultihopPanel } from './components/MultihopPanel.js';
77
import { TierBadge } from './components/TierBadge.js';
88
import { StatBar } from './components/StatBar.js';
9+
import { SessionTimer } from './components/SessionTimer.js';
910
import { Settings } from './components/Settings.js';
1011
import { UPGRADE_URL } from './lib/directory.js';
1112

@@ -65,6 +66,8 @@ export function App(): JSX.Element {
6566
onToggle={connected || busy ? conn.disconnect : conn.connect}
6667
/>
6768

69+
{connected && conn.connectedSince && <SessionTimer since={conn.connectedSince} />}
70+
6871
<button
6972
className="loc-btn"
7073
onClick={() => setPicker('entry')}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect, useState } from 'react';
2+
import type { JSX } from 'react';
3+
4+
/** Compact elapsed duration: "45s", "12m 03s", "2h 09m". */
5+
function fmt(ms: number): string {
6+
const s = Math.max(0, Math.floor(ms / 1000));
7+
const h = Math.floor(s / 3600);
8+
const m = Math.floor((s % 3600) / 60);
9+
const sec = s % 60;
10+
if (h > 0) {
11+
return `${h}h ${String(m).padStart(2, '0')}m`;
12+
}
13+
if (m > 0) {
14+
return `${m}m ${String(sec).padStart(2, '0')}s`;
15+
}
16+
return `${sec}s`;
17+
}
18+
19+
/** Live "Connected · <elapsed>" line, ticking once a second. */
20+
export function SessionTimer({ since }: { readonly since: number }): JSX.Element {
21+
const [now, setNow] = useState(() => Date.now());
22+
useEffect(() => {
23+
const id = setInterval(() => setNow(Date.now()), 1000);
24+
return () => clearInterval(id);
25+
}, []);
26+
return <div className="session-timer">Connected · {fmt(now - since)}</div>;
27+
}

clients/desktop/src/hooks/useConnection.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export interface ConnectionModel {
6868
readonly autoConnect: boolean;
6969
/** Toggle auto-connect on launch. */
7070
readonly setAutoConnect: (on: boolean) => void;
71+
/** Unix-ms when the active session connected, or null when not connected. */
72+
readonly connectedSince: number | null;
7173
}
7274

7375
const DOWN: TunnelStatus = {
@@ -113,6 +115,10 @@ export function useConnection(): ConnectionModel {
113115
() => localStorage.getItem('cvpn.autoConnect') === '1',
114116
);
115117
const autoConnectedRef = useRef(false);
118+
// Unix-ms when the current session connected (for the session timer), and
119+
// whether we ever reached 'connected' this session (for auto-reconnect).
120+
const [connectedSince, setConnectedSince] = useState<number | null>(null);
121+
const wasConnectedRef = useRef(false);
116122

117123
// Bootstrap: discover the fleet and restore the last-selected country.
118124
useEffect(() => {
@@ -230,6 +236,7 @@ export function useConnection(): ConnectionModel {
230236
}, [selected, exit, multihop, routeStyle, phase, keypair, killSwitch, refreshEntitlement]);
231237

232238
const disconnect = useCallback(() => {
239+
wasConnectedRef.current = false;
233240
void (async () => {
234241
try {
235242
const s = await teardown();
@@ -242,6 +249,30 @@ export function useConnection(): ConnectionModel {
242249
})();
243250
}, []);
244251

252+
// Session timer + drop detection: stamp the connect time, remember we reached
253+
// 'connected', and clear on idle.
254+
useEffect(() => {
255+
if (phase === 'connected') {
256+
wasConnectedRef.current = true;
257+
setConnectedSince((prev) => prev ?? Date.now());
258+
} else if (phase === 'idle') {
259+
setConnectedSince(null);
260+
}
261+
}, [phase]);
262+
263+
// Auto-reconnect an unexpected drop: if the tunnel errors out after having been
264+
// connected (not a user disconnect, not an initial connect failure), bring it
265+
// back up shortly.
266+
useEffect(() => {
267+
if (phase === 'error' && wasConnectedRef.current) {
268+
wasConnectedRef.current = false;
269+
setConnectedSince(null);
270+
const id = setTimeout(() => connect(), 3000);
271+
return () => clearTimeout(id);
272+
}
273+
return undefined;
274+
}, [phase, connect]);
275+
245276
// Auto-connect on launch (opt-in): once discovery settles and a location is
246277
// selected, bring the tunnel up automatically.
247278
useEffect(() => {
@@ -261,8 +292,10 @@ export function useConnection(): ConnectionModel {
261292
try {
262293
const s = await nativeStatus();
263294
setTunnel(s);
264-
if (s.state === 'error') {
265-
setError(s.error ?? 'tunnel error');
295+
// A drop shows up as an error or a 'down' state while we think we're
296+
// connected — surface it as an error so auto-reconnect can kick in.
297+
if (s.state === 'error' || s.state === 'down') {
298+
setError(s.error ?? 'connection lost');
266299
setPhase('error');
267300
}
268301
} catch {
@@ -293,5 +326,6 @@ export function useConnection(): ConnectionModel {
293326
setKillSwitch,
294327
autoConnect,
295328
setAutoConnect,
329+
connectedSince,
296330
};
297331
}

clients/desktop/src/styles.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,14 @@ button {
371371
font-size: 18px;
372372
}
373373

374+
.session-timer {
375+
text-align: center;
376+
font-family: var(--mono);
377+
font-size: 12px;
378+
color: var(--cyan);
379+
margin-top: -4px;
380+
}
381+
374382
.ping {
375383
width: 8px;
376384
height: 8px;

clients/mobile/src/screens/ConnectScreen.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Orb + tier pill + a country selector row + the big connect/disconnect button,
55
* plus live down/up/ping stats when connected. Everything is driven by `useVpn`.
66
*/
7+
import { useEffect, useState } from 'react';
78
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native';
89
import type { RouteStyle } from '@cumulusvpn/core';
910
import type { Country } from '../lib/gateways';
@@ -55,6 +56,17 @@ export function ConnectScreen({
5556
const target: Country | null = vpn.selected ?? vpn.countries[0] ?? null;
5657
const busy = vpn.state === 'connecting' || vpn.state === 'disconnecting';
5758

59+
// Tick every second while connected so the session timer stays live.
60+
const [now, setNow] = useState(() => Date.now());
61+
useEffect(() => {
62+
if (!connected) {
63+
return undefined;
64+
}
65+
const id = setInterval(() => setNow(Date.now()), 1000);
66+
return () => clearInterval(id);
67+
}, [connected]);
68+
const elapsed = vpn.connectedSince ? formatDuration(now - vpn.connectedSince) : null;
69+
5870
return (
5971
<View style={styles.root}>
6072
<View style={styles.top}>
@@ -77,9 +89,7 @@ export function ConnectScreen({
7789
<View style={styles.loc}>
7890
<Text style={styles.flag}>{target.flag}</Text>
7991
<Text style={styles.country}>{target.name}</Text>
80-
<Text style={styles.ip}>
81-
{target.city ? `Protected · ${target.city}` : 'Protected'}
82-
</Text>
92+
<Text style={styles.ip}>{elapsed ? `Protected · ${elapsed}` : 'Protected'}</Text>
8393
</View>
8494
) : (
8595
<View style={styles.loc}>
@@ -400,6 +410,21 @@ function sinceSec(unixSec: number): number {
400410
return Math.max(0, Math.round(Date.now() / 1000 - unixSec));
401411
}
402412

413+
/** Compact elapsed duration: "45s", "12m 03s", "2h 09m". */
414+
function formatDuration(ms: number): string {
415+
const s = Math.max(0, Math.floor(ms / 1000));
416+
const h = Math.floor(s / 3600);
417+
const m = Math.floor((s % 3600) / 60);
418+
const sec = s % 60;
419+
if (h > 0) {
420+
return `${h}h ${String(m).padStart(2, '0')}m`;
421+
}
422+
if (m > 0) {
423+
return `${m}m ${String(sec).padStart(2, '0')}s`;
424+
}
425+
return `${sec}s`;
426+
}
427+
403428
const styles = StyleSheet.create({
404429
root: { flex: 1, paddingHorizontal: space.xl, paddingBottom: space.xxl, paddingTop: space.sm },
405430
top: {

clients/mobile/src/state/useVpn.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export interface VpnModel {
7979
readonly killSwitch: boolean;
8080
/** Auto-connect on app launch once discovery completes (persisted). */
8181
readonly autoConnect: boolean;
82+
/** Unix-ms when the active session connected, or null when not connected. */
83+
readonly connectedSince: number | null;
8284
}
8385

8486
/** Chain-payment identity derived from the device key + last enrollment. */
@@ -131,6 +133,13 @@ export function useVpn(): VpnModel & VpnActions {
131133
const [killSwitch, setKillSwitchState] = useState(false);
132134
const [autoConnect, setAutoConnectState] = useState(false);
133135
const autoConnectedRef = useRef(false);
136+
// Unix-ms when the current session connected, for the session timer.
137+
const [connectedSince, setConnectedSince] = useState<number | null>(null);
138+
// Whether the user currently wants a tunnel (true after connect, false after
139+
// an explicit disconnect) + whether we reached 'connected' — together these
140+
// distinguish an unexpected drop from a user disconnect, for auto-reconnect.
141+
const wantConnectedRef = useRef(false);
142+
const wasConnectedRef = useRef(false);
134143

135144
// Latest enrolled gateway IP, kept in a ref for the status poller.
136145
const gatewayIpRef = useRef<string | null>(null);
@@ -222,6 +231,11 @@ export function useVpn(): VpnModel & VpnActions {
222231
const sub = onTunnelStatus((s) => {
223232
setStatus(s);
224233
setState(s.state);
234+
if (s.state === 'connected') {
235+
setConnectedSince((prev) => prev ?? Date.now());
236+
} else if (s.state === 'disconnected' || s.state === 'error') {
237+
setConnectedSince(null);
238+
}
225239
});
226240
return () => sub.remove();
227241
}, []);
@@ -276,6 +290,7 @@ export function useVpn(): VpnModel & VpnActions {
276290
if (!keypair) {
277291
return;
278292
}
293+
wantConnectedRef.current = true;
279294
setError(null);
280295
setState('connecting');
281296
try {
@@ -333,6 +348,8 @@ export function useVpn(): VpnModel & VpnActions {
333348
}, [keypair, countries, selectedCode, routeStyle, entryCode, exitCode, killSwitch]);
334349

335350
const disconnect = useCallback(async (): Promise<void> => {
351+
wantConnectedRef.current = false;
352+
wasConnectedRef.current = false;
336353
setState('disconnecting');
337354
try {
338355
await CumulusTunnel.stopTunnel();
@@ -341,6 +358,24 @@ export function useVpn(): VpnModel & VpnActions {
341358
}
342359
}, []);
343360

361+
// ---- auto-reconnect on an unexpected drop ------------------------------
362+
// If the tunnel drops while the user still wants it (they didn't tap
363+
// Disconnect) and we had reached 'connected', bring it back up shortly. Only
364+
// reconnects a genuine drop — initial connect failures are handled by the
365+
// watchdog + the error message, not a retry loop.
366+
useEffect(() => {
367+
if (state === 'connected') {
368+
wasConnectedRef.current = true;
369+
return;
370+
}
371+
if (state === 'disconnected' && wantConnectedRef.current && wasConnectedRef.current) {
372+
wasConnectedRef.current = false;
373+
const id = setTimeout(() => void connect(), 3000);
374+
return () => clearTimeout(id);
375+
}
376+
return undefined;
377+
}, [state, connect]);
378+
344379
// ---- auto-connect on launch (opt-in) -----------------------------------
345380
// Once, after the first successful discovery, if the user enabled auto-connect
346381
// and nothing is up yet, bring the tunnel up automatically.
@@ -408,6 +443,7 @@ export function useVpn(): VpnModel & VpnActions {
408443
exit,
409444
killSwitch,
410445
autoConnect,
446+
connectedSince,
411447
connect,
412448
disconnect,
413449
selectCountry,

0 commit comments

Comments
 (0)