@bakapiano/ccsm 0.22.2 → 0.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +233 -231
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +592 -592
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +187 -15
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/XtermTerminal.js +148 -14
  44. package/public/js/components/useDragSort.js +67 -67
  45. package/public/js/dialog.js +67 -67
  46. package/public/js/icons.js +212 -212
  47. package/public/js/main.js +296 -296
  48. package/public/js/pages/AboutPage.js +90 -90
  49. package/public/js/pages/ConfigurePage.js +713 -713
  50. package/public/js/pages/LaunchPage.js +421 -421
  51. package/public/js/pages/RemotePage.js +743 -743
  52. package/public/js/pages/SessionsPage.js +100 -100
  53. package/public/js/state.js +335 -335
  54. package/public/manifest.webmanifest +25 -0
  55. package/public/setup/index.html +567 -0
  56. package/scripts/dev.js +149 -149
  57. package/scripts/install.js +153 -153
  58. package/scripts/restart-helper.js +96 -96
  59. package/scripts/upgrade-helper.js +687 -687
  60. package/server.js +1807 -1807
@@ -1,743 +1,743 @@
1
- // Remote · expose this backend over a public tunnel URL so the same
2
- // frontend can be loaded from a phone / another laptop / wherever.
3
- // All API + WS calls are gated by a token the user sets here; the
4
- // share URL embeds it (?token=…) so the remote browser captures it
5
- // on first arrival and stashes it in localStorage.
6
- //
7
- // Layout mirrors ConfigurePage: .settings-scroll wrapper → Section →
8
- // .config-grid → .field rows with label + content. No bespoke cards.
9
-
10
- import { html } from '../html.js';
11
- import { useState, useEffect, useRef } from 'preact/hooks';
12
- import { api } from '../api.js';
13
- import { PageTitleBar } from '../components/PageTitleBar.js';
14
- import { setToast } from '../toast.js';
15
- import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
16
- import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose, IconCloudflareColor, IconMicrosoftColor } from '../icons.js';
17
- import { fmtAgo } from '../util.js';
18
- import { clockTick } from '../state.js';
19
-
20
- function genToken() {
21
- const a = new Uint8Array(18);
22
- crypto.getRandomValues(a);
23
- let s = '';
24
- for (const b of a) s += String.fromCharCode(b);
25
- return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
26
- }
27
-
28
- async function copy(text) {
29
- try {
30
- await navigator.clipboard.writeText(text);
31
- setToast('Copied', 'ok');
32
- } catch {
33
- setToast('Copy failed · select + Ctrl+C', 'error');
34
- }
35
- }
36
-
37
- function shareUrl(tunnelUrl, token) {
38
- if (!tunnelUrl || !token) return '';
39
- try {
40
- const u = new URL(tunnelUrl);
41
- u.searchParams.set('token', token);
42
- return u.toString();
43
- } catch { return ''; }
44
- }
45
-
46
- function Section({ title, meta, children }) {
47
- return html`
48
- <section class="settings-section">
49
- <header class="settings-section-head">
50
- <h2 class="settings-section-title">${title}</h2>
51
- ${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
52
- </header>
53
- <div class="settings-section-body">${children}</div>
54
- </section>`;
55
- }
56
-
57
- function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete }) {
58
- const lastSeen = d.lastSeen ? fmtAgo(d.lastSeen) : '—';
59
- const ipShort = d.ip ? d.ip.split(',')[0].trim() : null;
60
- return html`
61
- <div class=${`remote-device is-${kind}`}>
62
- <div class="remote-device-main">
63
- <div class="remote-device-label">
64
- ${d.code ? html`<code class="remote-device-code" title="Match this with the code shown on the requesting device">${d.code}</code>` : null}
65
- <span class="remote-device-name">${d.label || 'Unknown device'}</span>
66
- ${kind === 'approved' ? html`
67
- <button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
68
- ` : null}
69
- </div>
70
- <div class="remote-device-meta">
71
- ${ipShort ? html`<span class="mono">${ipShort}</span> · ` : null}
72
- <span>seen ${lastSeen}</span>
73
- ${d.userAgent ? html` · <span class="remote-device-ua" title=${d.userAgent}>${d.userAgent.slice(0, 60)}${d.userAgent.length > 60 ? '…' : ''}</span>` : null}
74
- </div>
75
- </div>
76
- <div class="remote-device-actions">
77
- ${kind === 'pending' ? html`
78
- <button class="action primary small" onClick=${onApprove}>Approve</button>
79
- <button class="action subtle small" onClick=${onReject}>Reject</button>
80
- ` : null}
81
- ${kind === 'approved' ? html`
82
- <button class="action subtle danger small" onClick=${onRevoke}><${IconClose} /> Revoke</button>
83
- ` : null}
84
- ${kind === 'rejected' ? html`
85
- <button class="action subtle small" onClick=${onApprove}>Re-approve</button>
86
- <button class="action subtle danger small" onClick=${onDelete}><${IconClose} /> Delete</button>
87
- ` : null}
88
- </div>
89
- </div>`;
90
- }
91
-
92
- function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
93
- return html`
94
- <button type="button"
95
- class=${`provider-tile${selected ? ' is-selected' : ''}${disabled ? ' is-disabled' : ''}`}
96
- aria-pressed=${selected ? 'true' : 'false'}
97
- disabled=${disabled}
98
- onClick=${() => !disabled && onSelect(id)}>
99
- <span class="provider-tile-icon">${icon}</span>
100
- <span class="provider-tile-body">
101
- <span class="provider-tile-label">${label}</span>
102
- ${hint ? html`<span class="provider-tile-hint">${hint}</span>` : null}
103
- </span>
104
- </button>`;
105
- }
106
-
107
- // Tiny inline row shown under the signed-in Microsoft Dev Tunnel
108
- // status. Displays the persisted (named) tunnel id ccsm reuses across
109
- // restarts so the public URL stays stable — and lets the user rotate
110
- // it on demand. Reset requires the tunnel to be stopped first; the
111
- // server-side route also enforces this.
112
- function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
113
- if (!tunnelId) {
114
- return html`
115
- <div class="tunnel-id-row is-empty">
116
- <span class="tunnel-id-label">Tunnel id</span>
117
- <span class="tunnel-id-value-empty">none yet · minted on next Start</span>
118
- </div>`;
119
- }
120
- return html`
121
- <div class="tunnel-id-row">
122
- <span class="tunnel-id-label">Tunnel id</span>
123
- <code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
124
- <button type="button" class="action subtle small tunnel-id-reset"
125
- disabled=${running}
126
- title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
127
- onClick=${onReset}>
128
- <${IconRecycle} /> Reset
129
- </button>
130
- </div>`;
131
- }
132
-
133
- function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
134
- if (!info) return html`<span class="provider-status-muted">probing…</span>`;
135
- if (!info.installed) {
136
- return html`
137
- <div class="provider-status">
138
- <span class="provider-status-state is-warn">
139
- <span class="provider-status-dot is-warn"></span> Not installed
140
- </span>
141
- <button type="button" class="action small" onClick=${onInstall}>
142
- Install via winget
143
- </button>
144
- </div>`;
145
- }
146
- if (id !== 'devtunnel') {
147
- // Cloudflare quick tunnel · no account state, just version.
148
- return html`
149
- <div class="provider-status">
150
- <span class="provider-status-state is-ok">
151
- <span class="provider-status-dot is-ok"></span> Ready · anonymous
152
- </span>
153
- ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
154
- </div>`;
155
- }
156
- // devtunnel · signed-in / signed-out states each get their own row.
157
- if (!info.loggedIn) {
158
- // While a sign-in flow is in flight the signin-card below this
159
- // row carries its own header + spinner + cancel button. Showing
160
- // a second "Signing in…" CTA here is just noise — collapse the
161
- // whole signed-out block down to a thin status line until the
162
- // card resolves one way or the other.
163
- if (loggingIn) {
164
- return html`
165
- <div class="provider-status">
166
- <span class="provider-status-state">
167
- <span class="provider-status-dot"></span> Signing in…
168
- </span>
169
- ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
170
- </div>`;
171
- }
172
- return html`
173
- <div class="provider-status">
174
- <span class="provider-status-state is-warn">
175
- <span class="provider-status-dot is-warn"></span> Not signed in
176
- </span>
177
- ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
178
- <button type="button" class="btn-signin-microsoft provider-status-signin" onClick=${onLogin}>
179
- <${IconMicrosoftColor} size=${18} />
180
- <span>Sign in with Microsoft</span>
181
- </button>
182
- </div>`;
183
- }
184
- return html`
185
- <div class="provider-status">
186
- <span class="provider-status-state is-ok">
187
- <span class="provider-status-dot is-ok"></span> Signed in
188
- </span>
189
- <span class="provider-status-user">${info.user}</span>
190
- ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
191
- <button type="button" class="action subtle small provider-status-switch" onClick=${onLogin}>
192
- Switch
193
- </button>
194
- </div>`;
195
- }
196
-
197
- // Device-code login panel. Shown when a `devtunnel user login -d` flow
198
- // is in flight or just finished. The user clicks Open, signs in on
199
- // microsoft.com, and we flip to "Signed in" automatically when the
200
- // child exits 0 (the probe cache gets invalidated on exit).
201
- function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
202
- if (!login) return null;
203
- const { status, url, code, error, user, lines } = login;
204
- const running = status === 'running';
205
- const done = status === 'done';
206
- const failed = status === 'error';
207
- const canceled = status === 'canceled';
208
- const host = (() => { try { return new URL(url).host; } catch { return url || ''; } })();
209
- return html`
210
- <div class=${`signin-card is-${status}`}>
211
- ${running ? html`
212
- <div class="signin-card-header">
213
- <span class="signin-card-spinner" aria-hidden="true"></span>
214
- <span class="signin-card-eyebrow">Signing in to Microsoft</span>
215
- <button type="button" class="signin-card-cancel" onClick=${onCancel} title="Cancel sign-in">
216
- <${IconClose} /> Cancel
217
- </button>
218
- </div>
219
- <div class="signin-card-code-block">
220
- <span class="signin-card-code-label">Device code</span>
221
- <div class="signin-card-code-row">
222
- ${code ? html`
223
- <code class="signin-card-code">${code}</code>
224
- <button type="button" class="action subtle small signin-card-code-copy"
225
- title="Copy code" onClick=${() => copy(code)}>
226
- <${IconCopy} />
227
- </button>
228
- ` : html`<span class="signin-card-code-pending">generating…</span>`}
229
- </div>
230
- </div>
231
- <ol class="signin-card-steps">
232
- <li>
233
- ${url ? html`
234
- <a class="signin-card-open" href=${url} target="_blank" rel="noreferrer noopener">
235
- <${IconExternal} /> Open <span class="signin-card-host">${host}</span>
236
- </a>
237
- ` : html`<span class="signin-card-step-muted">Waiting for sign-in URL…</span>`}
238
- </li>
239
- <li>Paste the device code shown above.</li>
240
- <li>Pick an account and approve — this page flips automatically.</li>
241
- </ol>
242
- ` : null}
243
- ${done ? html`
244
- <div class="signin-card-result is-ok">
245
- <span class="signin-card-result-icon" aria-hidden="true">✓</span>
246
- <div class="signin-card-result-body">
247
- <div class="signin-card-result-title">Signed in</div>
248
- <div class="signin-card-result-meta">
249
- ${user ? html`as <code>${user}</code> · ` : ''}you can start the tunnel now.
250
- </div>
251
- </div>
252
- <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
253
- </div>
254
- ` : null}
255
- ${failed ? html`
256
- <div class="signin-card-result is-error">
257
- <span class="signin-card-result-icon" aria-hidden="true">!</span>
258
- <div class="signin-card-result-body">
259
- <div class="signin-card-result-title">Sign-in failed</div>
260
- <div class="signin-card-result-meta">${error || 'devtunnel exited with an error.'}</div>
261
- </div>
262
- <div class="signin-card-result-actions">
263
- <button type="button" class="action small" onClick=${onRetry}>Try again</button>
264
- <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
265
- </div>
266
- </div>
267
- ` : null}
268
- ${canceled ? html`
269
- <div class="signin-card-result is-muted">
270
- <div class="signin-card-result-body">
271
- <div class="signin-card-result-title">Sign-in canceled</div>
272
- </div>
273
- <div class="signin-card-result-actions">
274
- <button type="button" class="action small" onClick=${onRetry}>Try again</button>
275
- <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
276
- </div>
277
- </div>
278
- ` : null}
279
- ${lines && lines.length ? html`
280
- <details class="signin-card-log">
281
- <summary>CLI output · ${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</summary>
282
- <pre>${lines.join('\n')}</pre>
283
- </details>
284
- ` : null}
285
- </div>`;
286
- }
287
-
288
- export function RemotePage() {
289
- clockTick.value; // re-tick fmtAgo "last seen" labels
290
- // Hydrate from a localStorage cache so the page renders the same
291
- // shape it had at the end of the previous visit — provider tiles,
292
- // signed-in state, tunnel id, share URL — instead of empty / placeholder
293
- // chrome that fills in after the slow /api/tunnel/status round-trip
294
- // (700ms+ on a cold probe). The cached snapshot is overwritten by
295
- // refresh() the moment the live response lands.
296
- const cachedStatus = (() => {
297
- try {
298
- const raw = localStorage.getItem('ccsm.remote-status-cache');
299
- return raw ? JSON.parse(raw) : null;
300
- } catch { return null; }
301
- })();
302
- const [status, setStatus] = useState(cachedStatus);
303
- const [provider, setProvider] = useState(() => {
304
- if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
305
- if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
306
- if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
307
- return 'devtunnel';
308
- });
309
- const [token, setTokenLocal] = useState(cachedStatus?.token || '');
310
- const [busy, setBusy] = useState(false);
311
- const [deviceList, setDeviceList] = useState([]);
312
- const pollRef = useRef(null);
313
-
314
- // Tunnel status and the device list are fetched INDEPENDENTLY, not as a
315
- // bundled Promise.all. /api/tunnel/status can lag behind a cold provider
316
- // probe; the device list is cheap. Coupling them made the (fast) device
317
- // list wait on the (slow) status round-trip, so the whole page appeared
318
- // to refresh in one delayed lump. Now each updates its own state the
319
- // moment its own fetch lands.
320
- async function refreshStatus() {
321
- try {
322
- const s = await api('GET', '/api/tunnel/status');
323
- setStatus(s);
324
- setTokenLocal((cur) => cur || s.token || '');
325
- setProvider((cur) => {
326
- if (s.running && s.provider) return s.provider;
327
- if (cur) return cur;
328
- if (s.providers?.devtunnel?.installed) return 'devtunnel';
329
- if (s.providers?.cloudflared?.installed) return 'cloudflared';
330
- return cur || 'devtunnel';
331
- });
332
- // Snapshot for the next mount. Skip the per-call `log` so the
333
- // cache stays small.
334
- try {
335
- localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
336
- ...s, log: undefined,
337
- }));
338
- } catch {}
339
- } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
340
- }
341
- async function refreshDevices() {
342
- try {
343
- const devs = await api('GET', '/api/devices');
344
- setDeviceList(devs.devices || []);
345
- } catch { /* non-critical — keep the last good list on a transient error */ }
346
- }
347
- function refresh() { refreshStatus(); refreshDevices(); }
348
-
349
- useEffect(() => {
350
- refresh();
351
- pollRef.current = setInterval(refresh, 2500);
352
- return () => clearInterval(pollRef.current);
353
- }, []);
354
-
355
- async function onApproveDevice(id) {
356
- try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
357
- catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
358
- }
359
- async function onRejectDevice(id) {
360
- try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
361
- catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
362
- }
363
- async function onDeleteDevice(d) {
364
- const ok = await ccsmConfirm(
365
- `Forget "${d.label || d.id}"? The device disappears from this list. If it ever tries again it'll come back as a fresh pending request.`,
366
- { title: 'Delete device record', okLabel: 'Delete', danger: true },
367
- );
368
- if (!ok) return;
369
- try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
370
- catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
371
- }
372
- async function onRevokeDevice(d) {
373
- const ok = await ccsmConfirm(`Revoke access for "${d.label || d.id}"? Any open tabs lose access immediately.`, {
374
- title: 'Revoke device', okLabel: 'Revoke', danger: true,
375
- });
376
- if (!ok) return;
377
- try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
378
- catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
379
- }
380
- async function onRenameDevice(d) {
381
- const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
382
- if (next === null) return;
383
- try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
384
- catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
385
- }
386
-
387
- async function onStart() {
388
- setBusy(true);
389
- try {
390
- // Auto-mint a token if the user hasn't generated one yet — the
391
- // registration token is now an implementation detail of starting
392
- // a tunnel rather than a separate setup step.
393
- let tok = token;
394
- if (!tok || tok.length < 8) {
395
- tok = genToken();
396
- setTokenLocal(tok);
397
- try { await api('POST', '/api/tunnel/token', { token: tok }); }
398
- catch (e) { /* the start call below will fail too — surface that */ }
399
- }
400
- const s = await api('POST', '/api/tunnel/start', { provider, token: tok });
401
- setStatus(s);
402
- setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
403
- } catch (e) {
404
- setToast(`start failed · ${e.message}`, 'error');
405
- } finally { setBusy(false); }
406
- }
407
- async function onStop() {
408
- setBusy(true);
409
- try {
410
- const s = await api('POST', '/api/tunnel/stop');
411
- setStatus(s);
412
- setToast('Tunnel stopped', 'ok');
413
- } catch (e) { setToast(`stop failed · ${e.message}`, 'error'); }
414
- finally { setBusy(false); }
415
- }
416
- // Generate is the only path that mutates the token now — local React
417
- // state and the server's stored token stay in lockstep, so the Share
418
- // URL preview always embeds a token the server will accept. (The
419
- // previous design had a separate Save step; users would Generate +
420
- // copy the URL without saving, then the remote would 401 because
421
- // its embedded token didn't match what the server still had.)
422
- async function onGenerateToken() {
423
- const fresh = genToken();
424
- setTokenLocal(fresh);
425
- try {
426
- // When auto-start is on the token must be PERSISTED, else the
427
- // rotated token is lost on the next backend restart and every
428
- // share URL built from it 401s. Route through the persisting
429
- // endpoint in that case; otherwise the in-memory-only token
430
- // endpoint is enough.
431
- const s = status?.autoStart
432
- ? await api('POST', '/api/tunnel/autostart', { autoStart: true, provider, token: fresh })
433
- : await api('POST', '/api/tunnel/token', { token: fresh });
434
- setStatus(s);
435
- setToast('New token in effect', 'ok');
436
- } catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
437
- }
438
- // Persist (or clear) the auto-start preference. On enable with no
439
- // token yet, mint one first so the backend has something to reuse on
440
- // its next startup. Approved devices keep working regardless of the
441
- // token — it only gates NEW device registration.
442
- async function onToggleAutoStart(next) {
443
- setBusy(true);
444
- try {
445
- let tok = token;
446
- if (next && (!tok || tok.length < 8)) { tok = genToken(); setTokenLocal(tok); }
447
- const s = await api('POST', '/api/tunnel/autostart',
448
- next ? { autoStart: true, provider, token: tok } : { autoStart: false });
449
- setStatus(s);
450
- setToast(next ? 'Auto-start on · tunnel comes up when ccsm starts' : 'Auto-start off', 'ok');
451
- } catch (e) {
452
- setToast(`auto-start ${next ? 'enable' : 'disable'} failed · ${e.message}`, 'error');
453
- } finally { setBusy(false); }
454
- }
455
- async function onInstall(p) {
456
- const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
457
- title: 'Install tunnel provider', okLabel: 'Install',
458
- });
459
- if (!ok) return;
460
- try {
461
- await api('POST', '/api/tunnel/install', { provider: p });
462
- setToast(`${p} install running in background · check back in a minute`, 'ok');
463
- } catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
464
- }
465
- function onLogin(p) {
466
- if (p !== 'devtunnel') return;
467
- // Kick off `devtunnel user login -d` on the host and let the
468
- // panel below render the device code + URL. /status polling
469
- // (every 2.5s) picks up the eventual outcome.
470
- (async () => {
471
- try {
472
- const r = await api('POST', '/api/tunnel/devtunnel/login', { mode: 'microsoft' });
473
- if (r?.login) setStatus((cur) => cur ? { ...cur, login: r.login } : cur);
474
- refresh();
475
- } catch (e) { setToast(`sign-in failed · ${e.message}`, 'error'); }
476
- })();
477
- }
478
- async function onLoginCancel() {
479
- try { await api('POST', '/api/tunnel/devtunnel/login/cancel'); refresh(); }
480
- catch (e) { setToast(`cancel failed · ${e.message}`, 'error'); }
481
- }
482
- async function onLoginDismiss() {
483
- try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
484
- catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
485
- }
486
- async function onResetDevtunnelId() {
487
- const ok = await ccsmConfirm(
488
- `Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
489
- { title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
490
- );
491
- if (!ok) return;
492
- try {
493
- await api('POST', '/api/tunnel/devtunnel/reset');
494
- refresh();
495
- setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
496
- } catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
497
- }
498
-
499
- const running = status?.running;
500
- const url = status?.url;
501
- const share = shareUrl(url, token);
502
- const log = status?.log || [];
503
- const cf = status?.providers?.cloudflared;
504
- const dt = status?.providers?.devtunnel;
505
- const dtLogin = status?.login || null;
506
- const dtLoggingIn = dtLogin?.status === 'running';
507
- // First /api/tunnel/status round-trip is the slow one — even with
508
- // the 30s server-side cache + parallel probe, a cold call shells
509
- // out and adds ~700ms. We render the full page immediately and let
510
- // individual fields show their own "probing…" state instead of
511
- // gating the whole panel behind a centered spinner.
512
-
513
- return html`
514
- <${PageTitleBar} title="Remote" />
515
- <div class="settings-scroll">
516
-
517
- <${Section}
518
- title="Connection"
519
- meta=${html`Pick which CLI ccsm spawns for the tunnel.`}>
520
- <div class="config-grid">
521
- <div class="field">
522
- <span class="label">Provider</span>
523
- <div class="provider-tile-row">
524
- <${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
525
- hint="Requires sign-in"
526
- icon=${html`<${IconMicrosoftColor} size=${32} />`}
527
- selected=${provider === 'devtunnel'}
528
- disabled=${running}
529
- onSelect=${setProvider} />
530
- <${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
531
- hint="Anonymous · no login"
532
- icon=${html`<${IconCloudflareColor} size=${32} />`}
533
- selected=${provider === 'cloudflared'}
534
- disabled=${running}
535
- onSelect=${setProvider} />
536
- </div>
537
- ${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
538
- </div>
539
- ${provider === 'devtunnel' ? html`
540
- <div class="field">
541
- <span class="label">Microsoft Dev Tunnel</span>
542
- <div class="remote-status-line">
543
- <${ProviderStatus} id="devtunnel" info=${dt}
544
- onInstall=${() => onInstall('devtunnel')}
545
- onLogin=${() => onLogin('devtunnel')}
546
- loggingIn=${dtLoggingIn} />
547
- </div>
548
- ${dtLogin ? html`
549
- <${DevtunnelLoginPanel}
550
- login=${dtLogin}
551
- onCancel=${onLoginCancel}
552
- onDismiss=${onLoginDismiss}
553
- onRetry=${() => onLogin('devtunnel')} />
554
- ` : null}
555
- ${dt?.loggedIn ? html`
556
- <${DevtunnelTunnelIdRow}
557
- tunnelId=${status?.tunnelId}
558
- running=${running && status?.provider === 'devtunnel'}
559
- onReset=${onResetDevtunnelId} />
560
- ` : null}
561
- </div>
562
- ` : null}
563
- ${provider === 'cloudflared' ? html`
564
- <div class="field">
565
- <span class="label">Cloudflare Tunnel</span>
566
- <div class="remote-status-line">
567
- <${ProviderStatus} id="cloudflared" info=${cf}
568
- onInstall=${() => onInstall('cloudflared')} />
569
- </div>
570
- </div>
571
- ` : null}
572
- </div>
573
- </${Section}>
574
-
575
- <${Section}
576
- title="Tunnel"
577
- meta=${running
578
- ? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
579
- : html`Not running.`}>
580
- <div class="tunnel-autostart">
581
- <label class="tunnel-autostart-row">
582
- <input type="checkbox" checked=${!!status?.autoStart} disabled=${busy}
583
- onChange=${(e) => onToggleAutoStart(e.target.checked)} />
584
- <span class="tunnel-autostart-label">Start this tunnel automatically when ccsm starts</span>
585
- </label>
586
- ${status?.autoStart && provider === 'cloudflared' ? html`
587
- <span class="hint tunnel-autostart-hint">
588
- Cloudflare quick tunnels get a new URL each launch — the share URL will change on
589
- restart and approved devices must re-register. Use Microsoft Dev Tunnel for a stable URL.
590
- </span>` : null}
591
- </div>
592
- ${!running ? html`
593
- <div class="tunnel-hero">
594
- <div class="tunnel-hero-body">
595
- <div class="tunnel-hero-title">Bring this backend online</div>
596
- <div class="tunnel-hero-meta">
597
- ccsm will spawn
598
- <code>${provider === 'devtunnel' ? 'devtunnel' : 'cloudflared'}</code>
599
- and wait for it to print a public URL.
600
- </div>
601
- </div>
602
- <button type="button" class="action tunnel-hero-cta"
603
- disabled=${busy}
604
- onClick=${onStart}>
605
- <${IconExternal} /> ${busy ? 'Starting…' : 'Start tunnel'}
606
- </button>
607
- </div>
608
- ` : html`
609
- <div class="tunnel-live">
610
- <div class="tunnel-live-head">
611
- <span class="tunnel-live-state">
612
- <span class="tunnel-live-dot"></span>
613
- Live
614
- </span>
615
- <span class="tunnel-live-divider">·</span>
616
- <span class="tunnel-live-provider">${status?.provider === 'devtunnel' ? 'Microsoft Dev Tunnel' : 'Cloudflare Tunnel'}</span>
617
- <span class="tunnel-live-divider">·</span>
618
- <span class="tunnel-live-meta">since ${new Date(status.startedAt).toLocaleTimeString()}</span>
619
- <button type="button" class="tunnel-stop-link"
620
- disabled=${busy}
621
- onClick=${onStop}>
622
- <${IconClose} /> ${busy ? 'Stopping…' : 'Stop tunnel'}
623
- </button>
624
- </div>
625
- ${url ? html`
626
- <div class="tunnel-share">
627
- <div class="tunnel-share-label">Share URL</div>
628
- <div class="tunnel-share-url">
629
- <code class="tunnel-share-value">${share}</code>
630
- <div class="tunnel-share-actions">
631
- <button type="button" class="action small" onClick=${() => copy(share)}>
632
- <${IconCopy} /> Copy
633
- </button>
634
- <a class="action small" href=${share} target="_blank" rel="noreferrer noopener">
635
- <${IconExternal} /> Open
636
- </a>
637
- </div>
638
- </div>
639
- <div class="tunnel-share-hint">
640
- Send this to the remote device · token embedded, stripped from the URL on first arrival.
641
- </div>
642
- </div>
643
- ` : html`
644
- <div class="tunnel-share is-waiting">
645
- <div class="signin-card-spinner" aria-hidden="true"></div>
646
- <span>Waiting for the CLI to print a public URL…</span>
647
- </div>
648
- `}
649
- ${log.length ? html`
650
- <details class="remote-log tunnel-log">
651
- <summary>CLI log · ${log.length} lines</summary>
652
- <pre>${log.join('\n')}</pre>
653
- </details>
654
- ` : null}
655
- </div>
656
- `}
657
- </${Section}>
658
-
659
- <${Section}
660
- title="Registration token"
661
- meta=${html`Auto-generated. Only used to register new devices — approved devices keep working after a rotate.`}>
662
- <div class="config-grid">
663
- <div class="field">
664
- <span class="label">Token</span>
665
- <div class="remote-token-row">
666
- <input type="text" class="input remote-token-input"
667
- readonly
668
- placeholder="auto-generated on first Start tunnel"
669
- value=${token} />
670
- <button type="button" class="action" title="Mint a fresh token (invalidates outstanding share URLs)"
671
- onClick=${onGenerateToken}>
672
- <${IconRecycle} /> ${token ? 'Rotate' : 'Generate'}
673
- </button>
674
- <button type="button" class="action"
675
- disabled=${!token}
676
- onClick=${() => copy(token)}>
677
- <${IconCopy} /> Copy
678
- </button>
679
- </div>
680
- <span class="hint">
681
- ${(!status?.token && !token)
682
- ? html`No token yet — one is minted automatically the first time you start a tunnel.`
683
- : html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
684
- </span>
685
- </div>
686
- </div>
687
- </${Section}>
688
-
689
- <${Section}
690
- title="Devices"
691
- meta=${html`Approve each new device once.`}>
692
- ${(() => {
693
- const pending = deviceList.filter((d) => d.status === 'pending');
694
- const approved = deviceList.filter((d) => d.status === 'approved');
695
- const rejected = deviceList.filter((d) => d.status === 'rejected');
696
- if (!deviceList.length) {
697
- return html`<p class="remote-empty">No devices yet. Send the share URL to a phone or another laptop to add the first one.</p>`;
698
- }
699
- return html`
700
- <div class="remote-devices">
701
- ${pending.length ? html`
702
- <div class="remote-devices-group">
703
- <div class="remote-devices-group-head">
704
- <span class="remote-devices-group-title">Pending approval</span>
705
- <span class="remote-devices-group-count">${pending.length}</span>
706
- </div>
707
- ${pending.map((d) => html`<${DeviceRow}
708
- key=${d.id} d=${d} kind="pending"
709
- onApprove=${() => onApproveDevice(d.id)}
710
- onReject=${() => onRejectDevice(d.id)} />`)}
711
- </div>
712
- ` : null}
713
- ${approved.length ? html`
714
- <div class="remote-devices-group">
715
- <div class="remote-devices-group-head">
716
- <span class="remote-devices-group-title">Approved</span>
717
- <span class="remote-devices-group-count">${approved.length}</span>
718
- </div>
719
- ${approved.map((d) => html`<${DeviceRow}
720
- key=${d.id} d=${d} kind="approved"
721
- onRename=${() => onRenameDevice(d)}
722
- onRevoke=${() => onRevokeDevice(d)} />`)}
723
- </div>
724
- ` : null}
725
- ${rejected.length ? html`
726
- <div class="remote-devices-group">
727
- <div class="remote-devices-group-head">
728
- <span class="remote-devices-group-title">Rejected</span>
729
- <span class="remote-devices-group-count">${rejected.length}</span>
730
- <span class="remote-devices-group-hint">auto-clears 1h after rejection</span>
731
- </div>
732
- ${rejected.map((d) => html`<${DeviceRow}
733
- key=${d.id} d=${d} kind="rejected"
734
- onApprove=${() => onApproveDevice(d.id)}
735
- onDelete=${() => onDeleteDevice(d)} />`)}
736
- </div>
737
- ` : null}
738
- </div>`;
739
- })()}
740
- </${Section}>
741
-
742
- </div>`;
743
- }
1
+ // Remote · expose this backend over a public tunnel URL so the same
2
+ // frontend can be loaded from a phone / another laptop / wherever.
3
+ // All API + WS calls are gated by a token the user sets here; the
4
+ // share URL embeds it (?token=…) so the remote browser captures it
5
+ // on first arrival and stashes it in localStorage.
6
+ //
7
+ // Layout mirrors ConfigurePage: .settings-scroll wrapper → Section →
8
+ // .config-grid → .field rows with label + content. No bespoke cards.
9
+
10
+ import { html } from '../html.js';
11
+ import { useState, useEffect, useRef } from 'preact/hooks';
12
+ import { api } from '../api.js';
13
+ import { PageTitleBar } from '../components/PageTitleBar.js';
14
+ import { setToast } from '../toast.js';
15
+ import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
16
+ import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose, IconCloudflareColor, IconMicrosoftColor } from '../icons.js';
17
+ import { fmtAgo } from '../util.js';
18
+ import { clockTick } from '../state.js';
19
+
20
+ function genToken() {
21
+ const a = new Uint8Array(18);
22
+ crypto.getRandomValues(a);
23
+ let s = '';
24
+ for (const b of a) s += String.fromCharCode(b);
25
+ return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
26
+ }
27
+
28
+ async function copy(text) {
29
+ try {
30
+ await navigator.clipboard.writeText(text);
31
+ setToast('Copied', 'ok');
32
+ } catch {
33
+ setToast('Copy failed · select + Ctrl+C', 'error');
34
+ }
35
+ }
36
+
37
+ function shareUrl(tunnelUrl, token) {
38
+ if (!tunnelUrl || !token) return '';
39
+ try {
40
+ const u = new URL(tunnelUrl);
41
+ u.searchParams.set('token', token);
42
+ return u.toString();
43
+ } catch { return ''; }
44
+ }
45
+
46
+ function Section({ title, meta, children }) {
47
+ return html`
48
+ <section class="settings-section">
49
+ <header class="settings-section-head">
50
+ <h2 class="settings-section-title">${title}</h2>
51
+ ${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
52
+ </header>
53
+ <div class="settings-section-body">${children}</div>
54
+ </section>`;
55
+ }
56
+
57
+ function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete }) {
58
+ const lastSeen = d.lastSeen ? fmtAgo(d.lastSeen) : '—';
59
+ const ipShort = d.ip ? d.ip.split(',')[0].trim() : null;
60
+ return html`
61
+ <div class=${`remote-device is-${kind}`}>
62
+ <div class="remote-device-main">
63
+ <div class="remote-device-label">
64
+ ${d.code ? html`<code class="remote-device-code" title="Match this with the code shown on the requesting device">${d.code}</code>` : null}
65
+ <span class="remote-device-name">${d.label || 'Unknown device'}</span>
66
+ ${kind === 'approved' ? html`
67
+ <button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
68
+ ` : null}
69
+ </div>
70
+ <div class="remote-device-meta">
71
+ ${ipShort ? html`<span class="mono">${ipShort}</span> · ` : null}
72
+ <span>seen ${lastSeen}</span>
73
+ ${d.userAgent ? html` · <span class="remote-device-ua" title=${d.userAgent}>${d.userAgent.slice(0, 60)}${d.userAgent.length > 60 ? '…' : ''}</span>` : null}
74
+ </div>
75
+ </div>
76
+ <div class="remote-device-actions">
77
+ ${kind === 'pending' ? html`
78
+ <button class="action primary small" onClick=${onApprove}>Approve</button>
79
+ <button class="action subtle small" onClick=${onReject}>Reject</button>
80
+ ` : null}
81
+ ${kind === 'approved' ? html`
82
+ <button class="action subtle danger small" onClick=${onRevoke}><${IconClose} /> Revoke</button>
83
+ ` : null}
84
+ ${kind === 'rejected' ? html`
85
+ <button class="action subtle small" onClick=${onApprove}>Re-approve</button>
86
+ <button class="action subtle danger small" onClick=${onDelete}><${IconClose} /> Delete</button>
87
+ ` : null}
88
+ </div>
89
+ </div>`;
90
+ }
91
+
92
+ function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
93
+ return html`
94
+ <button type="button"
95
+ class=${`provider-tile${selected ? ' is-selected' : ''}${disabled ? ' is-disabled' : ''}`}
96
+ aria-pressed=${selected ? 'true' : 'false'}
97
+ disabled=${disabled}
98
+ onClick=${() => !disabled && onSelect(id)}>
99
+ <span class="provider-tile-icon">${icon}</span>
100
+ <span class="provider-tile-body">
101
+ <span class="provider-tile-label">${label}</span>
102
+ ${hint ? html`<span class="provider-tile-hint">${hint}</span>` : null}
103
+ </span>
104
+ </button>`;
105
+ }
106
+
107
+ // Tiny inline row shown under the signed-in Microsoft Dev Tunnel
108
+ // status. Displays the persisted (named) tunnel id ccsm reuses across
109
+ // restarts so the public URL stays stable — and lets the user rotate
110
+ // it on demand. Reset requires the tunnel to be stopped first; the
111
+ // server-side route also enforces this.
112
+ function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
113
+ if (!tunnelId) {
114
+ return html`
115
+ <div class="tunnel-id-row is-empty">
116
+ <span class="tunnel-id-label">Tunnel id</span>
117
+ <span class="tunnel-id-value-empty">none yet · minted on next Start</span>
118
+ </div>`;
119
+ }
120
+ return html`
121
+ <div class="tunnel-id-row">
122
+ <span class="tunnel-id-label">Tunnel id</span>
123
+ <code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
124
+ <button type="button" class="action subtle small tunnel-id-reset"
125
+ disabled=${running}
126
+ title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
127
+ onClick=${onReset}>
128
+ <${IconRecycle} /> Reset
129
+ </button>
130
+ </div>`;
131
+ }
132
+
133
+ function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
134
+ if (!info) return html`<span class="provider-status-muted">probing…</span>`;
135
+ if (!info.installed) {
136
+ return html`
137
+ <div class="provider-status">
138
+ <span class="provider-status-state is-warn">
139
+ <span class="provider-status-dot is-warn"></span> Not installed
140
+ </span>
141
+ <button type="button" class="action small" onClick=${onInstall}>
142
+ Install via winget
143
+ </button>
144
+ </div>`;
145
+ }
146
+ if (id !== 'devtunnel') {
147
+ // Cloudflare quick tunnel · no account state, just version.
148
+ return html`
149
+ <div class="provider-status">
150
+ <span class="provider-status-state is-ok">
151
+ <span class="provider-status-dot is-ok"></span> Ready · anonymous
152
+ </span>
153
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
154
+ </div>`;
155
+ }
156
+ // devtunnel · signed-in / signed-out states each get their own row.
157
+ if (!info.loggedIn) {
158
+ // While a sign-in flow is in flight the signin-card below this
159
+ // row carries its own header + spinner + cancel button. Showing
160
+ // a second "Signing in…" CTA here is just noise — collapse the
161
+ // whole signed-out block down to a thin status line until the
162
+ // card resolves one way or the other.
163
+ if (loggingIn) {
164
+ return html`
165
+ <div class="provider-status">
166
+ <span class="provider-status-state">
167
+ <span class="provider-status-dot"></span> Signing in…
168
+ </span>
169
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
170
+ </div>`;
171
+ }
172
+ return html`
173
+ <div class="provider-status">
174
+ <span class="provider-status-state is-warn">
175
+ <span class="provider-status-dot is-warn"></span> Not signed in
176
+ </span>
177
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
178
+ <button type="button" class="btn-signin-microsoft provider-status-signin" onClick=${onLogin}>
179
+ <${IconMicrosoftColor} size=${18} />
180
+ <span>Sign in with Microsoft</span>
181
+ </button>
182
+ </div>`;
183
+ }
184
+ return html`
185
+ <div class="provider-status">
186
+ <span class="provider-status-state is-ok">
187
+ <span class="provider-status-dot is-ok"></span> Signed in
188
+ </span>
189
+ <span class="provider-status-user">${info.user}</span>
190
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
191
+ <button type="button" class="action subtle small provider-status-switch" onClick=${onLogin}>
192
+ Switch
193
+ </button>
194
+ </div>`;
195
+ }
196
+
197
+ // Device-code login panel. Shown when a `devtunnel user login -d` flow
198
+ // is in flight or just finished. The user clicks Open, signs in on
199
+ // microsoft.com, and we flip to "Signed in" automatically when the
200
+ // child exits 0 (the probe cache gets invalidated on exit).
201
+ function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
202
+ if (!login) return null;
203
+ const { status, url, code, error, user, lines } = login;
204
+ const running = status === 'running';
205
+ const done = status === 'done';
206
+ const failed = status === 'error';
207
+ const canceled = status === 'canceled';
208
+ const host = (() => { try { return new URL(url).host; } catch { return url || ''; } })();
209
+ return html`
210
+ <div class=${`signin-card is-${status}`}>
211
+ ${running ? html`
212
+ <div class="signin-card-header">
213
+ <span class="signin-card-spinner" aria-hidden="true"></span>
214
+ <span class="signin-card-eyebrow">Signing in to Microsoft</span>
215
+ <button type="button" class="signin-card-cancel" onClick=${onCancel} title="Cancel sign-in">
216
+ <${IconClose} /> Cancel
217
+ </button>
218
+ </div>
219
+ <div class="signin-card-code-block">
220
+ <span class="signin-card-code-label">Device code</span>
221
+ <div class="signin-card-code-row">
222
+ ${code ? html`
223
+ <code class="signin-card-code">${code}</code>
224
+ <button type="button" class="action subtle small signin-card-code-copy"
225
+ title="Copy code" onClick=${() => copy(code)}>
226
+ <${IconCopy} />
227
+ </button>
228
+ ` : html`<span class="signin-card-code-pending">generating…</span>`}
229
+ </div>
230
+ </div>
231
+ <ol class="signin-card-steps">
232
+ <li>
233
+ ${url ? html`
234
+ <a class="signin-card-open" href=${url} target="_blank" rel="noreferrer noopener">
235
+ <${IconExternal} /> Open <span class="signin-card-host">${host}</span>
236
+ </a>
237
+ ` : html`<span class="signin-card-step-muted">Waiting for sign-in URL…</span>`}
238
+ </li>
239
+ <li>Paste the device code shown above.</li>
240
+ <li>Pick an account and approve — this page flips automatically.</li>
241
+ </ol>
242
+ ` : null}
243
+ ${done ? html`
244
+ <div class="signin-card-result is-ok">
245
+ <span class="signin-card-result-icon" aria-hidden="true">✓</span>
246
+ <div class="signin-card-result-body">
247
+ <div class="signin-card-result-title">Signed in</div>
248
+ <div class="signin-card-result-meta">
249
+ ${user ? html`as <code>${user}</code> · ` : ''}you can start the tunnel now.
250
+ </div>
251
+ </div>
252
+ <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
253
+ </div>
254
+ ` : null}
255
+ ${failed ? html`
256
+ <div class="signin-card-result is-error">
257
+ <span class="signin-card-result-icon" aria-hidden="true">!</span>
258
+ <div class="signin-card-result-body">
259
+ <div class="signin-card-result-title">Sign-in failed</div>
260
+ <div class="signin-card-result-meta">${error || 'devtunnel exited with an error.'}</div>
261
+ </div>
262
+ <div class="signin-card-result-actions">
263
+ <button type="button" class="action small" onClick=${onRetry}>Try again</button>
264
+ <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
265
+ </div>
266
+ </div>
267
+ ` : null}
268
+ ${canceled ? html`
269
+ <div class="signin-card-result is-muted">
270
+ <div class="signin-card-result-body">
271
+ <div class="signin-card-result-title">Sign-in canceled</div>
272
+ </div>
273
+ <div class="signin-card-result-actions">
274
+ <button type="button" class="action small" onClick=${onRetry}>Try again</button>
275
+ <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
276
+ </div>
277
+ </div>
278
+ ` : null}
279
+ ${lines && lines.length ? html`
280
+ <details class="signin-card-log">
281
+ <summary>CLI output · ${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</summary>
282
+ <pre>${lines.join('\n')}</pre>
283
+ </details>
284
+ ` : null}
285
+ </div>`;
286
+ }
287
+
288
+ export function RemotePage() {
289
+ clockTick.value; // re-tick fmtAgo "last seen" labels
290
+ // Hydrate from a localStorage cache so the page renders the same
291
+ // shape it had at the end of the previous visit — provider tiles,
292
+ // signed-in state, tunnel id, share URL — instead of empty / placeholder
293
+ // chrome that fills in after the slow /api/tunnel/status round-trip
294
+ // (700ms+ on a cold probe). The cached snapshot is overwritten by
295
+ // refresh() the moment the live response lands.
296
+ const cachedStatus = (() => {
297
+ try {
298
+ const raw = localStorage.getItem('ccsm.remote-status-cache');
299
+ return raw ? JSON.parse(raw) : null;
300
+ } catch { return null; }
301
+ })();
302
+ const [status, setStatus] = useState(cachedStatus);
303
+ const [provider, setProvider] = useState(() => {
304
+ if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
305
+ if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
306
+ if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
307
+ return 'devtunnel';
308
+ });
309
+ const [token, setTokenLocal] = useState(cachedStatus?.token || '');
310
+ const [busy, setBusy] = useState(false);
311
+ const [deviceList, setDeviceList] = useState([]);
312
+ const pollRef = useRef(null);
313
+
314
+ // Tunnel status and the device list are fetched INDEPENDENTLY, not as a
315
+ // bundled Promise.all. /api/tunnel/status can lag behind a cold provider
316
+ // probe; the device list is cheap. Coupling them made the (fast) device
317
+ // list wait on the (slow) status round-trip, so the whole page appeared
318
+ // to refresh in one delayed lump. Now each updates its own state the
319
+ // moment its own fetch lands.
320
+ async function refreshStatus() {
321
+ try {
322
+ const s = await api('GET', '/api/tunnel/status');
323
+ setStatus(s);
324
+ setTokenLocal((cur) => cur || s.token || '');
325
+ setProvider((cur) => {
326
+ if (s.running && s.provider) return s.provider;
327
+ if (cur) return cur;
328
+ if (s.providers?.devtunnel?.installed) return 'devtunnel';
329
+ if (s.providers?.cloudflared?.installed) return 'cloudflared';
330
+ return cur || 'devtunnel';
331
+ });
332
+ // Snapshot for the next mount. Skip the per-call `log` so the
333
+ // cache stays small.
334
+ try {
335
+ localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
336
+ ...s, log: undefined,
337
+ }));
338
+ } catch {}
339
+ } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
340
+ }
341
+ async function refreshDevices() {
342
+ try {
343
+ const devs = await api('GET', '/api/devices');
344
+ setDeviceList(devs.devices || []);
345
+ } catch { /* non-critical — keep the last good list on a transient error */ }
346
+ }
347
+ function refresh() { refreshStatus(); refreshDevices(); }
348
+
349
+ useEffect(() => {
350
+ refresh();
351
+ pollRef.current = setInterval(refresh, 2500);
352
+ return () => clearInterval(pollRef.current);
353
+ }, []);
354
+
355
+ async function onApproveDevice(id) {
356
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
357
+ catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
358
+ }
359
+ async function onRejectDevice(id) {
360
+ try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
361
+ catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
362
+ }
363
+ async function onDeleteDevice(d) {
364
+ const ok = await ccsmConfirm(
365
+ `Forget "${d.label || d.id}"? The device disappears from this list. If it ever tries again it'll come back as a fresh pending request.`,
366
+ { title: 'Delete device record', okLabel: 'Delete', danger: true },
367
+ );
368
+ if (!ok) return;
369
+ try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
370
+ catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
371
+ }
372
+ async function onRevokeDevice(d) {
373
+ const ok = await ccsmConfirm(`Revoke access for "${d.label || d.id}"? Any open tabs lose access immediately.`, {
374
+ title: 'Revoke device', okLabel: 'Revoke', danger: true,
375
+ });
376
+ if (!ok) return;
377
+ try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
378
+ catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
379
+ }
380
+ async function onRenameDevice(d) {
381
+ const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
382
+ if (next === null) return;
383
+ try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
384
+ catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
385
+ }
386
+
387
+ async function onStart() {
388
+ setBusy(true);
389
+ try {
390
+ // Auto-mint a token if the user hasn't generated one yet — the
391
+ // registration token is now an implementation detail of starting
392
+ // a tunnel rather than a separate setup step.
393
+ let tok = token;
394
+ if (!tok || tok.length < 8) {
395
+ tok = genToken();
396
+ setTokenLocal(tok);
397
+ try { await api('POST', '/api/tunnel/token', { token: tok }); }
398
+ catch (e) { /* the start call below will fail too — surface that */ }
399
+ }
400
+ const s = await api('POST', '/api/tunnel/start', { provider, token: tok });
401
+ setStatus(s);
402
+ setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
403
+ } catch (e) {
404
+ setToast(`start failed · ${e.message}`, 'error');
405
+ } finally { setBusy(false); }
406
+ }
407
+ async function onStop() {
408
+ setBusy(true);
409
+ try {
410
+ const s = await api('POST', '/api/tunnel/stop');
411
+ setStatus(s);
412
+ setToast('Tunnel stopped', 'ok');
413
+ } catch (e) { setToast(`stop failed · ${e.message}`, 'error'); }
414
+ finally { setBusy(false); }
415
+ }
416
+ // Generate is the only path that mutates the token now — local React
417
+ // state and the server's stored token stay in lockstep, so the Share
418
+ // URL preview always embeds a token the server will accept. (The
419
+ // previous design had a separate Save step; users would Generate +
420
+ // copy the URL without saving, then the remote would 401 because
421
+ // its embedded token didn't match what the server still had.)
422
+ async function onGenerateToken() {
423
+ const fresh = genToken();
424
+ setTokenLocal(fresh);
425
+ try {
426
+ // When auto-start is on the token must be PERSISTED, else the
427
+ // rotated token is lost on the next backend restart and every
428
+ // share URL built from it 401s. Route through the persisting
429
+ // endpoint in that case; otherwise the in-memory-only token
430
+ // endpoint is enough.
431
+ const s = status?.autoStart
432
+ ? await api('POST', '/api/tunnel/autostart', { autoStart: true, provider, token: fresh })
433
+ : await api('POST', '/api/tunnel/token', { token: fresh });
434
+ setStatus(s);
435
+ setToast('New token in effect', 'ok');
436
+ } catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
437
+ }
438
+ // Persist (or clear) the auto-start preference. On enable with no
439
+ // token yet, mint one first so the backend has something to reuse on
440
+ // its next startup. Approved devices keep working regardless of the
441
+ // token — it only gates NEW device registration.
442
+ async function onToggleAutoStart(next) {
443
+ setBusy(true);
444
+ try {
445
+ let tok = token;
446
+ if (next && (!tok || tok.length < 8)) { tok = genToken(); setTokenLocal(tok); }
447
+ const s = await api('POST', '/api/tunnel/autostart',
448
+ next ? { autoStart: true, provider, token: tok } : { autoStart: false });
449
+ setStatus(s);
450
+ setToast(next ? 'Auto-start on · tunnel comes up when ccsm starts' : 'Auto-start off', 'ok');
451
+ } catch (e) {
452
+ setToast(`auto-start ${next ? 'enable' : 'disable'} failed · ${e.message}`, 'error');
453
+ } finally { setBusy(false); }
454
+ }
455
+ async function onInstall(p) {
456
+ const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
457
+ title: 'Install tunnel provider', okLabel: 'Install',
458
+ });
459
+ if (!ok) return;
460
+ try {
461
+ await api('POST', '/api/tunnel/install', { provider: p });
462
+ setToast(`${p} install running in background · check back in a minute`, 'ok');
463
+ } catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
464
+ }
465
+ function onLogin(p) {
466
+ if (p !== 'devtunnel') return;
467
+ // Kick off `devtunnel user login -d` on the host and let the
468
+ // panel below render the device code + URL. /status polling
469
+ // (every 2.5s) picks up the eventual outcome.
470
+ (async () => {
471
+ try {
472
+ const r = await api('POST', '/api/tunnel/devtunnel/login', { mode: 'microsoft' });
473
+ if (r?.login) setStatus((cur) => cur ? { ...cur, login: r.login } : cur);
474
+ refresh();
475
+ } catch (e) { setToast(`sign-in failed · ${e.message}`, 'error'); }
476
+ })();
477
+ }
478
+ async function onLoginCancel() {
479
+ try { await api('POST', '/api/tunnel/devtunnel/login/cancel'); refresh(); }
480
+ catch (e) { setToast(`cancel failed · ${e.message}`, 'error'); }
481
+ }
482
+ async function onLoginDismiss() {
483
+ try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
484
+ catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
485
+ }
486
+ async function onResetDevtunnelId() {
487
+ const ok = await ccsmConfirm(
488
+ `Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
489
+ { title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
490
+ );
491
+ if (!ok) return;
492
+ try {
493
+ await api('POST', '/api/tunnel/devtunnel/reset');
494
+ refresh();
495
+ setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
496
+ } catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
497
+ }
498
+
499
+ const running = status?.running;
500
+ const url = status?.url;
501
+ const share = shareUrl(url, token);
502
+ const log = status?.log || [];
503
+ const cf = status?.providers?.cloudflared;
504
+ const dt = status?.providers?.devtunnel;
505
+ const dtLogin = status?.login || null;
506
+ const dtLoggingIn = dtLogin?.status === 'running';
507
+ // First /api/tunnel/status round-trip is the slow one — even with
508
+ // the 30s server-side cache + parallel probe, a cold call shells
509
+ // out and adds ~700ms. We render the full page immediately and let
510
+ // individual fields show their own "probing…" state instead of
511
+ // gating the whole panel behind a centered spinner.
512
+
513
+ return html`
514
+ <${PageTitleBar} title="Remote" />
515
+ <div class="settings-scroll">
516
+
517
+ <${Section}
518
+ title="Connection"
519
+ meta=${html`Pick which CLI ccsm spawns for the tunnel.`}>
520
+ <div class="config-grid">
521
+ <div class="field">
522
+ <span class="label">Provider</span>
523
+ <div class="provider-tile-row">
524
+ <${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
525
+ hint="Requires sign-in"
526
+ icon=${html`<${IconMicrosoftColor} size=${32} />`}
527
+ selected=${provider === 'devtunnel'}
528
+ disabled=${running}
529
+ onSelect=${setProvider} />
530
+ <${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
531
+ hint="Anonymous · no login"
532
+ icon=${html`<${IconCloudflareColor} size=${32} />`}
533
+ selected=${provider === 'cloudflared'}
534
+ disabled=${running}
535
+ onSelect=${setProvider} />
536
+ </div>
537
+ ${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
538
+ </div>
539
+ ${provider === 'devtunnel' ? html`
540
+ <div class="field">
541
+ <span class="label">Microsoft Dev Tunnel</span>
542
+ <div class="remote-status-line">
543
+ <${ProviderStatus} id="devtunnel" info=${dt}
544
+ onInstall=${() => onInstall('devtunnel')}
545
+ onLogin=${() => onLogin('devtunnel')}
546
+ loggingIn=${dtLoggingIn} />
547
+ </div>
548
+ ${dtLogin ? html`
549
+ <${DevtunnelLoginPanel}
550
+ login=${dtLogin}
551
+ onCancel=${onLoginCancel}
552
+ onDismiss=${onLoginDismiss}
553
+ onRetry=${() => onLogin('devtunnel')} />
554
+ ` : null}
555
+ ${dt?.loggedIn ? html`
556
+ <${DevtunnelTunnelIdRow}
557
+ tunnelId=${status?.tunnelId}
558
+ running=${running && status?.provider === 'devtunnel'}
559
+ onReset=${onResetDevtunnelId} />
560
+ ` : null}
561
+ </div>
562
+ ` : null}
563
+ ${provider === 'cloudflared' ? html`
564
+ <div class="field">
565
+ <span class="label">Cloudflare Tunnel</span>
566
+ <div class="remote-status-line">
567
+ <${ProviderStatus} id="cloudflared" info=${cf}
568
+ onInstall=${() => onInstall('cloudflared')} />
569
+ </div>
570
+ </div>
571
+ ` : null}
572
+ </div>
573
+ </${Section}>
574
+
575
+ <${Section}
576
+ title="Tunnel"
577
+ meta=${running
578
+ ? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
579
+ : html`Not running.`}>
580
+ <div class="tunnel-autostart">
581
+ <label class="tunnel-autostart-row">
582
+ <input type="checkbox" checked=${!!status?.autoStart} disabled=${busy}
583
+ onChange=${(e) => onToggleAutoStart(e.target.checked)} />
584
+ <span class="tunnel-autostart-label">Start this tunnel automatically when ccsm starts</span>
585
+ </label>
586
+ ${status?.autoStart && provider === 'cloudflared' ? html`
587
+ <span class="hint tunnel-autostart-hint">
588
+ Cloudflare quick tunnels get a new URL each launch — the share URL will change on
589
+ restart and approved devices must re-register. Use Microsoft Dev Tunnel for a stable URL.
590
+ </span>` : null}
591
+ </div>
592
+ ${!running ? html`
593
+ <div class="tunnel-hero">
594
+ <div class="tunnel-hero-body">
595
+ <div class="tunnel-hero-title">Bring this backend online</div>
596
+ <div class="tunnel-hero-meta">
597
+ ccsm will spawn
598
+ <code>${provider === 'devtunnel' ? 'devtunnel' : 'cloudflared'}</code>
599
+ and wait for it to print a public URL.
600
+ </div>
601
+ </div>
602
+ <button type="button" class="action tunnel-hero-cta"
603
+ disabled=${busy}
604
+ onClick=${onStart}>
605
+ <${IconExternal} /> ${busy ? 'Starting…' : 'Start tunnel'}
606
+ </button>
607
+ </div>
608
+ ` : html`
609
+ <div class="tunnel-live">
610
+ <div class="tunnel-live-head">
611
+ <span class="tunnel-live-state">
612
+ <span class="tunnel-live-dot"></span>
613
+ Live
614
+ </span>
615
+ <span class="tunnel-live-divider">·</span>
616
+ <span class="tunnel-live-provider">${status?.provider === 'devtunnel' ? 'Microsoft Dev Tunnel' : 'Cloudflare Tunnel'}</span>
617
+ <span class="tunnel-live-divider">·</span>
618
+ <span class="tunnel-live-meta">since ${new Date(status.startedAt).toLocaleTimeString()}</span>
619
+ <button type="button" class="tunnel-stop-link"
620
+ disabled=${busy}
621
+ onClick=${onStop}>
622
+ <${IconClose} /> ${busy ? 'Stopping…' : 'Stop tunnel'}
623
+ </button>
624
+ </div>
625
+ ${url ? html`
626
+ <div class="tunnel-share">
627
+ <div class="tunnel-share-label">Share URL</div>
628
+ <div class="tunnel-share-url">
629
+ <code class="tunnel-share-value">${share}</code>
630
+ <div class="tunnel-share-actions">
631
+ <button type="button" class="action small" onClick=${() => copy(share)}>
632
+ <${IconCopy} /> Copy
633
+ </button>
634
+ <a class="action small" href=${share} target="_blank" rel="noreferrer noopener">
635
+ <${IconExternal} /> Open
636
+ </a>
637
+ </div>
638
+ </div>
639
+ <div class="tunnel-share-hint">
640
+ Send this to the remote device · token embedded, stripped from the URL on first arrival.
641
+ </div>
642
+ </div>
643
+ ` : html`
644
+ <div class="tunnel-share is-waiting">
645
+ <div class="signin-card-spinner" aria-hidden="true"></div>
646
+ <span>Waiting for the CLI to print a public URL…</span>
647
+ </div>
648
+ `}
649
+ ${log.length ? html`
650
+ <details class="remote-log tunnel-log">
651
+ <summary>CLI log · ${log.length} lines</summary>
652
+ <pre>${log.join('\n')}</pre>
653
+ </details>
654
+ ` : null}
655
+ </div>
656
+ `}
657
+ </${Section}>
658
+
659
+ <${Section}
660
+ title="Registration token"
661
+ meta=${html`Auto-generated. Only used to register new devices — approved devices keep working after a rotate.`}>
662
+ <div class="config-grid">
663
+ <div class="field">
664
+ <span class="label">Token</span>
665
+ <div class="remote-token-row">
666
+ <input type="text" class="input remote-token-input"
667
+ readonly
668
+ placeholder="auto-generated on first Start tunnel"
669
+ value=${token} />
670
+ <button type="button" class="action" title="Mint a fresh token (invalidates outstanding share URLs)"
671
+ onClick=${onGenerateToken}>
672
+ <${IconRecycle} /> ${token ? 'Rotate' : 'Generate'}
673
+ </button>
674
+ <button type="button" class="action"
675
+ disabled=${!token}
676
+ onClick=${() => copy(token)}>
677
+ <${IconCopy} /> Copy
678
+ </button>
679
+ </div>
680
+ <span class="hint">
681
+ ${(!status?.token && !token)
682
+ ? html`No token yet — one is minted automatically the first time you start a tunnel.`
683
+ : html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
684
+ </span>
685
+ </div>
686
+ </div>
687
+ </${Section}>
688
+
689
+ <${Section}
690
+ title="Devices"
691
+ meta=${html`Approve each new device once.`}>
692
+ ${(() => {
693
+ const pending = deviceList.filter((d) => d.status === 'pending');
694
+ const approved = deviceList.filter((d) => d.status === 'approved');
695
+ const rejected = deviceList.filter((d) => d.status === 'rejected');
696
+ if (!deviceList.length) {
697
+ return html`<p class="remote-empty">No devices yet. Send the share URL to a phone or another laptop to add the first one.</p>`;
698
+ }
699
+ return html`
700
+ <div class="remote-devices">
701
+ ${pending.length ? html`
702
+ <div class="remote-devices-group">
703
+ <div class="remote-devices-group-head">
704
+ <span class="remote-devices-group-title">Pending approval</span>
705
+ <span class="remote-devices-group-count">${pending.length}</span>
706
+ </div>
707
+ ${pending.map((d) => html`<${DeviceRow}
708
+ key=${d.id} d=${d} kind="pending"
709
+ onApprove=${() => onApproveDevice(d.id)}
710
+ onReject=${() => onRejectDevice(d.id)} />`)}
711
+ </div>
712
+ ` : null}
713
+ ${approved.length ? html`
714
+ <div class="remote-devices-group">
715
+ <div class="remote-devices-group-head">
716
+ <span class="remote-devices-group-title">Approved</span>
717
+ <span class="remote-devices-group-count">${approved.length}</span>
718
+ </div>
719
+ ${approved.map((d) => html`<${DeviceRow}
720
+ key=${d.id} d=${d} kind="approved"
721
+ onRename=${() => onRenameDevice(d)}
722
+ onRevoke=${() => onRevokeDevice(d)} />`)}
723
+ </div>
724
+ ` : null}
725
+ ${rejected.length ? html`
726
+ <div class="remote-devices-group">
727
+ <div class="remote-devices-group-head">
728
+ <span class="remote-devices-group-title">Rejected</span>
729
+ <span class="remote-devices-group-count">${rejected.length}</span>
730
+ <span class="remote-devices-group-hint">auto-clears 1h after rejection</span>
731
+ </div>
732
+ ${rejected.map((d) => html`<${DeviceRow}
733
+ key=${d.id} d=${d} kind="rejected"
734
+ onApprove=${() => onApproveDevice(d.id)}
735
+ onDelete=${() => onDeleteDevice(d)} />`)}
736
+ </div>
737
+ ` : null}
738
+ </div>`;
739
+ })()}
740
+ </${Section}>
741
+
742
+ </div>`;
743
+ }