Skip to content

Commit 721f9d9

Browse files
committed
feat(desktop): settings sheet + auto-connect on launch (mobile parity)
- A settings sheet (gear icon in the titlebar): Auto-connect on launch and Kill switch toggles + an About section (version, pinned directory trust key, a link to cumulusvpn.com). - Auto-connect: persisted in localStorage; once discovery settles and a location is selected on launch, the tunnel comes up automatically (fires once). Verified via a headless render of the settings sheet. tsc / eslint / prettier / tests green.
1 parent 262dabc commit 721f9d9

4 files changed

Lines changed: 202 additions & 0 deletions

File tree

clients/desktop/src/App.tsx

Lines changed: 15 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 { Settings } from './components/Settings.js';
910
import { UPGRADE_URL } from './lib/directory.js';
1011

1112
/** Which hop the country picker is currently editing, or closed. */
@@ -40,6 +41,7 @@ function pingClass(load: number): string {
4041
export function App(): JSX.Element {
4142
const conn = useConnection();
4243
const [picker, setPicker] = useState<PickerTarget>(null);
44+
const [showSettings, setShowSettings] = useState(false);
4345

4446
const connected = conn.phase === 'connected';
4547
const busy = conn.phase === 'connecting';
@@ -53,6 +55,9 @@ export function App(): JSX.Element {
5355
<span className="dot" />
5456
CumulusVPN
5557
</div>
58+
<button className="gear" onClick={() => setShowSettings(true)} aria-label="Settings">
59+
60+
</button>
5661
</div>
5762

5863
<ConnectOrb
@@ -145,6 +150,16 @@ export function App(): JSX.Element {
145150
onClose={() => setPicker(null)}
146151
/>
147152
)}
153+
154+
{showSettings && (
155+
<Settings
156+
autoConnect={conn.autoConnect}
157+
killSwitch={conn.killSwitch}
158+
onAutoConnect={conn.setAutoConnect}
159+
onKillSwitch={conn.setKillSwitch}
160+
onClose={() => setShowSettings(false)}
161+
/>
162+
)}
148163
</div>
149164
);
150165
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { JSX } from 'react';
2+
import { CVPN_DIRECTORY_PUBKEY } from '../lib/directory.js';
3+
4+
/** App version — matches the release tag. */
5+
const APP_VERSION = '0.1.0';
6+
7+
interface Props {
8+
readonly autoConnect: boolean;
9+
readonly killSwitch: boolean;
10+
readonly onAutoConnect: (v: boolean) => void;
11+
readonly onKillSwitch: (v: boolean) => void;
12+
readonly onClose: () => void;
13+
}
14+
15+
/** Full-window settings sheet: connection preferences + about. */
16+
export function Settings({
17+
autoConnect,
18+
killSwitch,
19+
onAutoConnect,
20+
onKillSwitch,
21+
onClose,
22+
}: Props): JSX.Element {
23+
return (
24+
<div className="sheet">
25+
<h2>Settings</h2>
26+
27+
<div className="setsec">Connection</div>
28+
<SettingToggle
29+
label="Auto-connect on launch"
30+
sub="Connect automatically when the app opens"
31+
value={autoConnect}
32+
onChange={onAutoConnect}
33+
/>
34+
<SettingToggle
35+
label="Kill switch"
36+
sub="Block all traffic if the VPN drops"
37+
value={killSwitch}
38+
onChange={onKillSwitch}
39+
/>
40+
41+
<div className="setsec">About</div>
42+
<div className="setinfo">
43+
<span>Version</span>
44+
<span className="lat">CumulusVPN {APP_VERSION}</span>
45+
</div>
46+
<div className="setinfo">
47+
<span>Directory trust key</span>
48+
<span className="lat">{CVPN_DIRECTORY_PUBKEY.slice(0, 16)}</span>
49+
</div>
50+
<a className="setinfo setlink" href="https://cumulusvpn.com" target="_blank" rel="noreferrer">
51+
<span>cumulusvpn.com</span>
52+
<span className="chev"></span>
53+
</a>
54+
55+
<button className="close" onClick={onClose}>
56+
Close
57+
</button>
58+
</div>
59+
);
60+
}
61+
62+
function SettingToggle({
63+
label,
64+
sub,
65+
value,
66+
onChange,
67+
}: {
68+
readonly label: string;
69+
readonly sub: string;
70+
readonly value: boolean;
71+
readonly onChange: (v: boolean) => void;
72+
}): JSX.Element {
73+
return (
74+
<button
75+
type="button"
76+
className="setrow"
77+
role="switch"
78+
aria-checked={value}
79+
onClick={() => onChange(!value)}
80+
>
81+
<span className="setmeta">
82+
<span className="setlabel">{label}</span>
83+
<span className="setsub">{sub}</span>
84+
</span>
85+
<span className={`ks-track ${value ? 'on' : ''}`}>
86+
<span className="ks-thumb" />
87+
</span>
88+
</button>
89+
);
90+
}

clients/desktop/src/hooks/useConnection.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export interface ConnectionModel {
6464
readonly killSwitch: boolean;
6565
/** Toggle the kill switch; applies on the next connect. */
6666
readonly setKillSwitch: (on: boolean) => void;
67+
/** Auto-connect on launch once discovery settles (persisted; default off). */
68+
readonly autoConnect: boolean;
69+
/** Toggle auto-connect on launch. */
70+
readonly setAutoConnect: (on: boolean) => void;
6771
}
6872

6973
const DOWN: TunnelStatus = {
@@ -105,6 +109,10 @@ export function useConnection(): ConnectionModel {
105109
const [killSwitch, setKillSwitchState] = useState(
106110
() => localStorage.getItem('cvpn.killSwitch') !== '0',
107111
);
112+
const [autoConnect, setAutoConnectState] = useState(
113+
() => localStorage.getItem('cvpn.autoConnect') === '1',
114+
);
115+
const autoConnectedRef = useRef(false);
108116

109117
// Bootstrap: discover the fleet and restore the last-selected country.
110118
useEffect(() => {
@@ -165,6 +173,11 @@ export function useConnection(): ConnectionModel {
165173
localStorage.setItem('cvpn.killSwitch', on ? '1' : '0');
166174
}, []);
167175

176+
const setAutoConnect = useCallback((on: boolean) => {
177+
setAutoConnectState(on);
178+
localStorage.setItem('cvpn.autoConnect', on ? '1' : '0');
179+
}, []);
180+
168181
/** Poll chain entitlement from the metering gateway; never drops the tunnel. */
169182
const refreshEntitlement = useCallback(
170183
async (gatewayIp: string, signPubKey: string) => {
@@ -229,6 +242,15 @@ export function useConnection(): ConnectionModel {
229242
})();
230243
}, []);
231244

245+
// Auto-connect on launch (opt-in): once discovery settles and a location is
246+
// selected, bring the tunnel up automatically.
247+
useEffect(() => {
248+
if (autoConnect && !autoConnectedRef.current && phase === 'idle' && selected) {
249+
autoConnectedRef.current = true;
250+
connect();
251+
}
252+
}, [autoConnect, phase, selected, connect]);
253+
232254
// Poll native tunnel status (byte counters, handshake) while connected.
233255
useEffect(() => {
234256
if (phase !== 'connected') {
@@ -269,5 +291,7 @@ export function useConnection(): ConnectionModel {
269291
selectExit,
270292
killSwitch,
271293
setKillSwitch,
294+
autoConnect,
295+
setAutoConnect,
272296
};
273297
}

clients/desktop/src/styles.css

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,79 @@ button {
298298
transform: translateX(17px);
299299
}
300300

301+
/* Settings — gear button + sheet rows */
302+
.titlebar .gear {
303+
font-size: 15px;
304+
color: var(--ink-3);
305+
padding: 3px 7px;
306+
border-radius: 8px;
307+
line-height: 1;
308+
}
309+
.titlebar .gear:hover {
310+
color: var(--ink);
311+
background: var(--surface-2);
312+
}
313+
.setsec {
314+
font-size: 10.5px;
315+
text-transform: uppercase;
316+
letter-spacing: 0.7px;
317+
color: var(--ink-3);
318+
margin: 16px 4px 8px;
319+
}
320+
.setrow {
321+
display: flex;
322+
align-items: center;
323+
justify-content: space-between;
324+
gap: 12px;
325+
width: 100%;
326+
text-align: left;
327+
background: var(--surface);
328+
border: 1px solid var(--line);
329+
border-radius: 12px;
330+
padding: 12px 13px;
331+
margin-bottom: 8px;
332+
}
333+
.setmeta {
334+
display: flex;
335+
flex-direction: column;
336+
}
337+
.setlabel {
338+
color: var(--ink);
339+
font-weight: 600;
340+
font-size: 13.5px;
341+
}
342+
.setsub {
343+
color: var(--ink-3);
344+
font-size: 11.5px;
345+
margin-top: 2px;
346+
}
347+
.setinfo {
348+
display: flex;
349+
align-items: center;
350+
justify-content: space-between;
351+
padding: 11px 13px;
352+
background: var(--surface);
353+
border: 1px solid var(--line);
354+
border-radius: 12px;
355+
margin-bottom: 8px;
356+
font-size: 13px;
357+
color: var(--ink-2);
358+
text-decoration: none;
359+
}
360+
.setinfo .lat {
361+
font-family: var(--mono);
362+
font-size: 11px;
363+
color: var(--ink-3);
364+
}
365+
.setlink {
366+
color: var(--cyan);
367+
font-weight: 600;
368+
}
369+
.setlink .chev {
370+
color: var(--ink-3);
371+
font-size: 18px;
372+
}
373+
301374
.ping {
302375
width: 8px;
303376
height: 8px;

0 commit comments

Comments
 (0)