@bakapiano/ccsm 0.18.6 → 0.19.0

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.
@@ -13,7 +13,7 @@ import { api } from '../api.js';
13
13
  import { PageTitleBar } from '../components/PageTitleBar.js';
14
14
  import { setToast } from '../toast.js';
15
15
  import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
16
- import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose } from '../icons.js';
16
+ import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose, IconCloudflareColor, IconMicrosoftColor } from '../icons.js';
17
17
  import { fmtAgo } from '../util.js';
18
18
  import { clockTick } from '../state.js';
19
19
 
@@ -61,7 +61,8 @@ function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete
61
61
  <div class=${`remote-device is-${kind}`}>
62
62
  <div class="remote-device-main">
63
63
  <div class="remote-device-label">
64
- ${d.label || 'Unknown device'}
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>
65
66
  ${kind === 'approved' ? html`
66
67
  <button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
67
68
  ` : null}
@@ -88,33 +89,174 @@ function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete
88
89
  </div>`;
89
90
  }
90
91
 
91
- function ProviderChip({ id, label, selected, onSelect }) {
92
+ function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
92
93
  return html`
93
- <label class=${`chip${selected ? ' checked' : ''}`}>
94
- <input type="radio" name="provider" value=${id} checked=${selected}
95
- onChange=${() => onSelect(id)} />
96
- ${label}
97
- </label>`;
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>`;
98
105
  }
99
106
 
100
- function ProviderStatus({ id, info, onInstall, onLogin }) {
101
- if (!info) return html`<span class="muted">probing…</span>`;
107
+ function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
108
+ if (!info) return html`<span class="provider-status-muted">probing…</span>`;
102
109
  if (!info.installed) {
103
110
  return html`
104
- <span class="warn">not installed</span>
105
- <button type="button" class="action subtle small" onClick=${onInstall}>
106
- Install via winget
107
- </button>`;
111
+ <div class="provider-status">
112
+ <span class="provider-status-state is-warn">
113
+ <span class="provider-status-dot is-warn"></span> Not installed
114
+ </span>
115
+ <button type="button" class="action small" onClick=${onInstall}>
116
+ Install via winget
117
+ </button>
118
+ </div>`;
119
+ }
120
+ if (id !== 'devtunnel') {
121
+ // Cloudflare quick tunnel · no account state, just version.
122
+ return html`
123
+ <div class="provider-status">
124
+ <span class="provider-status-state is-ok">
125
+ <span class="provider-status-dot is-ok"></span> Ready · anonymous
126
+ </span>
127
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
128
+ </div>`;
129
+ }
130
+ // devtunnel · signed-in / signed-out states each get their own row.
131
+ if (!info.loggedIn) {
132
+ // While a sign-in flow is in flight the signin-card below this
133
+ // row carries its own header + spinner + cancel button. Showing
134
+ // a second "Signing in…" CTA here is just noise — collapse the
135
+ // whole signed-out block down to a thin status line until the
136
+ // card resolves one way or the other.
137
+ if (loggingIn) {
138
+ return html`
139
+ <div class="provider-status">
140
+ <span class="provider-status-state">
141
+ <span class="provider-status-dot"></span> Signing in…
142
+ </span>
143
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
144
+ </div>`;
145
+ }
146
+ return html`
147
+ <div class="provider-status">
148
+ <span class="provider-status-state is-warn">
149
+ <span class="provider-status-dot is-warn"></span> Not signed in
150
+ </span>
151
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
152
+ <button type="button" class="btn-signin-microsoft provider-status-signin" onClick=${onLogin}>
153
+ <${IconMicrosoftColor} size=${18} />
154
+ <span>Sign in with Microsoft</span>
155
+ </button>
156
+ </div>`;
108
157
  }
109
- const tag = id === 'devtunnel'
110
- ? (info.loggedIn ? html`signed in as <code>${info.user}</code>` : html`<span class="warn">not signed in</span>`)
111
- : html`anonymous`;
112
158
  return html`
113
- <span>${tag}</span>
114
- ${info.version ? html` · <span class="mono small-mono">${info.version}</span>` : null}
115
- ${id === 'devtunnel' && !info.loggedIn ? html`
116
- <button type="button" class="action subtle small" onClick=${onLogin}>How to sign in</button>
117
- ` : null}`;
159
+ <div class="provider-status">
160
+ <span class="provider-status-state is-ok">
161
+ <span class="provider-status-dot is-ok"></span> Signed in
162
+ </span>
163
+ <span class="provider-status-user">${info.user}</span>
164
+ ${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
165
+ <button type="button" class="action subtle small provider-status-switch" onClick=${onLogin}>
166
+ Switch
167
+ </button>
168
+ </div>`;
169
+ }
170
+
171
+ // Device-code login panel. Shown when a `devtunnel user login -d` flow
172
+ // is in flight or just finished. The user clicks Open, signs in on
173
+ // microsoft.com, and we flip to "Signed in" automatically when the
174
+ // child exits 0 (the probe cache gets invalidated on exit).
175
+ function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
176
+ if (!login) return null;
177
+ const { status, url, code, error, user, lines } = login;
178
+ const running = status === 'running';
179
+ const done = status === 'done';
180
+ const failed = status === 'error';
181
+ const canceled = status === 'canceled';
182
+ const host = (() => { try { return new URL(url).host; } catch { return url || ''; } })();
183
+ return html`
184
+ <div class=${`signin-card is-${status}`}>
185
+ ${running ? html`
186
+ <div class="signin-card-header">
187
+ <span class="signin-card-spinner" aria-hidden="true"></span>
188
+ <span class="signin-card-eyebrow">Signing in to Microsoft</span>
189
+ <button type="button" class="signin-card-cancel" onClick=${onCancel} title="Cancel sign-in">
190
+ <${IconClose} /> Cancel
191
+ </button>
192
+ </div>
193
+ <div class="signin-card-code-block">
194
+ <span class="signin-card-code-label">Device code</span>
195
+ <div class="signin-card-code-row">
196
+ ${code ? html`
197
+ <code class="signin-card-code">${code}</code>
198
+ <button type="button" class="action subtle small signin-card-code-copy"
199
+ title="Copy code" onClick=${() => copy(code)}>
200
+ <${IconCopy} />
201
+ </button>
202
+ ` : html`<span class="signin-card-code-pending">generating…</span>`}
203
+ </div>
204
+ </div>
205
+ <ol class="signin-card-steps">
206
+ <li>
207
+ ${url ? html`
208
+ <a class="signin-card-open" href=${url} target="_blank" rel="noreferrer noopener">
209
+ <${IconExternal} /> Open <span class="signin-card-host">${host}</span>
210
+ </a>
211
+ ` : html`<span class="signin-card-step-muted">Waiting for sign-in URL…</span>`}
212
+ </li>
213
+ <li>Paste the device code shown above.</li>
214
+ <li>Pick an account and approve — this page flips automatically.</li>
215
+ </ol>
216
+ ` : null}
217
+ ${done ? html`
218
+ <div class="signin-card-result is-ok">
219
+ <span class="signin-card-result-icon" aria-hidden="true">✓</span>
220
+ <div class="signin-card-result-body">
221
+ <div class="signin-card-result-title">Signed in</div>
222
+ <div class="signin-card-result-meta">
223
+ ${user ? html`as <code>${user}</code> · ` : ''}you can start the tunnel now.
224
+ </div>
225
+ </div>
226
+ <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
227
+ </div>
228
+ ` : null}
229
+ ${failed ? html`
230
+ <div class="signin-card-result is-error">
231
+ <span class="signin-card-result-icon" aria-hidden="true">!</span>
232
+ <div class="signin-card-result-body">
233
+ <div class="signin-card-result-title">Sign-in failed</div>
234
+ <div class="signin-card-result-meta">${error || 'devtunnel exited with an error.'}</div>
235
+ </div>
236
+ <div class="signin-card-result-actions">
237
+ <button type="button" class="action small" onClick=${onRetry}>Try again</button>
238
+ <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
239
+ </div>
240
+ </div>
241
+ ` : null}
242
+ ${canceled ? html`
243
+ <div class="signin-card-result is-muted">
244
+ <div class="signin-card-result-body">
245
+ <div class="signin-card-result-title">Sign-in canceled</div>
246
+ </div>
247
+ <div class="signin-card-result-actions">
248
+ <button type="button" class="action small" onClick=${onRetry}>Try again</button>
249
+ <button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
250
+ </div>
251
+ </div>
252
+ ` : null}
253
+ ${lines && lines.length ? html`
254
+ <details class="signin-card-log">
255
+ <summary>CLI output · ${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</summary>
256
+ <pre>${lines.join('\n')}</pre>
257
+ </details>
258
+ ` : null}
259
+ </div>`;
118
260
  }
119
261
 
120
262
  export function RemotePage() {
@@ -124,12 +266,6 @@ export function RemotePage() {
124
266
  const [token, setTokenLocal] = useState('');
125
267
  const [busy, setBusy] = useState(false);
126
268
  const [deviceList, setDeviceList] = useState([]);
127
- // First /api/tunnel/status round-trip is the slow one — even with
128
- // 30s server-side cache + parallel probe, a cold call shells out to
129
- // where.exe / --version / `devtunnel user show` and adds ~700ms.
130
- // We hide the form behind a spinner during that window so the user
131
- // doesn't see empty radios + placeholders that suddenly fill in.
132
- const [loading, setLoading] = useState(true);
133
269
  const pollRef = useRef(null);
134
270
 
135
271
  async function refresh() {
@@ -149,7 +285,6 @@ export function RemotePage() {
149
285
  return cur || 'cloudflared';
150
286
  });
151
287
  } catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
152
- finally { setLoading(false); }
153
288
  }
154
289
 
155
290
  useEffect(() => {
@@ -191,13 +326,19 @@ export function RemotePage() {
191
326
  }
192
327
 
193
328
  async function onStart() {
194
- if (!token || token.length < 8) {
195
- setToast('Token must be at least 8 characters', 'error');
196
- return;
197
- }
198
329
  setBusy(true);
199
330
  try {
200
- const s = await api('POST', '/api/tunnel/start', { provider, token });
331
+ // Auto-mint a token if the user hasn't generated one yet — the
332
+ // registration token is now an implementation detail of starting
333
+ // a tunnel rather than a separate setup step.
334
+ let tok = token;
335
+ if (!tok || tok.length < 8) {
336
+ tok = genToken();
337
+ setTokenLocal(tok);
338
+ try { await api('POST', '/api/tunnel/token', { token: tok }); }
339
+ catch (e) { /* the start call below will fail too — surface that */ }
340
+ }
341
+ const s = await api('POST', '/api/tunnel/start', { provider, token: tok });
201
342
  setStatus(s);
202
343
  setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
203
344
  } catch (e) {
@@ -239,10 +380,25 @@ export function RemotePage() {
239
380
  } catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
240
381
  }
241
382
  function onLogin(p) {
242
- if (p === 'devtunnel') {
243
- copy('devtunnel user login');
244
- setToast('Command copied · paste in a terminal to sign in', 'ok');
245
- }
383
+ if (p !== 'devtunnel') return;
384
+ // Kick off `devtunnel user login -d` on the host and let the
385
+ // panel below render the device code + URL. /status polling
386
+ // (every 2.5s) picks up the eventual outcome.
387
+ (async () => {
388
+ try {
389
+ const r = await api('POST', '/api/tunnel/devtunnel/login', { mode: 'microsoft' });
390
+ if (r?.login) setStatus((cur) => cur ? { ...cur, login: r.login } : cur);
391
+ refresh();
392
+ } catch (e) { setToast(`sign-in failed · ${e.message}`, 'error'); }
393
+ })();
394
+ }
395
+ async function onLoginCancel() {
396
+ try { await api('POST', '/api/tunnel/devtunnel/login/cancel'); refresh(); }
397
+ catch (e) { setToast(`cancel failed · ${e.message}`, 'error'); }
398
+ }
399
+ async function onLoginDismiss() {
400
+ try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
401
+ catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
246
402
  }
247
403
 
248
404
  const running = status?.running;
@@ -251,17 +407,13 @@ export function RemotePage() {
251
407
  const log = status?.log || [];
252
408
  const cf = status?.providers?.cloudflared;
253
409
  const dt = status?.providers?.devtunnel;
254
-
255
- if (loading) {
256
- return html`
257
- <${PageTitleBar} title="Remote" />
258
- <div class="settings-scroll">
259
- <div class="remote-loading">
260
- <div class="remote-loading-spinner" aria-hidden="true"></div>
261
- <p>Probing tunnel providers…</p>
262
- </div>
263
- </div>`;
264
- }
410
+ const dtLogin = status?.login || null;
411
+ const dtLoggingIn = dtLogin?.status === 'running';
412
+ // First /api/tunnel/status round-trip is the slow one — even with
413
+ // the 30s server-side cache + parallel probe, a cold call shells
414
+ // out and adds ~700ms. We render the full page immediately and let
415
+ // individual fields show their own "probing…" state instead of
416
+ // gating the whole panel behind a centered spinner.
265
417
 
266
418
  return html`
267
419
  <${PageTitleBar} title="Remote" />
@@ -269,49 +421,142 @@ export function RemotePage() {
269
421
 
270
422
  <${Section}
271
423
  title="Connection"
272
- meta=${html`Pick the tunnel CLI ccsm should spawn. Loopback callers on this machine bypass the token automatically.`}>
424
+ meta=${html`Pick which CLI ccsm spawns for the tunnel.`}>
273
425
  <div class="config-grid">
274
426
  <div class="field">
275
427
  <span class="label">Provider</span>
276
- <div class="chip-row">
277
- <${ProviderChip} id="cloudflared" label="Cloudflare Tunnel"
278
- selected=${provider === 'cloudflared'} onSelect=${setProvider} />
279
- <${ProviderChip} id="devtunnel" label="Microsoft Dev Tunnel"
280
- selected=${provider === 'devtunnel'} onSelect=${setProvider} />
428
+ <div class="provider-tile-row">
429
+ <${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
430
+ hint="Anonymous · no login"
431
+ icon=${html`<${IconCloudflareColor} size=${32} />`}
432
+ selected=${provider === 'cloudflared'}
433
+ disabled=${running}
434
+ onSelect=${setProvider} />
435
+ <${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
436
+ hint="Requires sign-in"
437
+ icon=${html`<${IconMicrosoftColor} size=${32} />`}
438
+ selected=${provider === 'devtunnel'}
439
+ disabled=${running}
440
+ onSelect=${setProvider} />
281
441
  </div>
442
+ ${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
282
443
  </div>
283
- <div class="field">
284
- <span class="label">Cloudflare Tunnel</span>
285
- <div class="remote-status-line">
286
- <${ProviderStatus} id="cloudflared" info=${cf}
287
- onInstall=${() => onInstall('cloudflared')} />
444
+ ${provider === 'cloudflared' ? html`
445
+ <div class="field">
446
+ <span class="label">Cloudflare Tunnel</span>
447
+ <div class="remote-status-line">
448
+ <${ProviderStatus} id="cloudflared" info=${cf}
449
+ onInstall=${() => onInstall('cloudflared')} />
450
+ </div>
451
+ </div>
452
+ ` : null}
453
+ ${provider === 'devtunnel' ? html`
454
+ <div class="field">
455
+ <span class="label">Microsoft Dev Tunnel</span>
456
+ <div class="remote-status-line">
457
+ <${ProviderStatus} id="devtunnel" info=${dt}
458
+ onInstall=${() => onInstall('devtunnel')}
459
+ onLogin=${() => onLogin('devtunnel')}
460
+ loggingIn=${dtLoggingIn} />
461
+ </div>
462
+ ${dtLogin ? html`
463
+ <${DevtunnelLoginPanel}
464
+ login=${dtLogin}
465
+ onCancel=${onLoginCancel}
466
+ onDismiss=${onLoginDismiss}
467
+ onRetry=${() => onLogin('devtunnel')} />
468
+ ` : null}
469
+ </div>
470
+ ` : null}
471
+ </div>
472
+ </${Section}>
473
+
474
+ <${Section}
475
+ title="Tunnel"
476
+ meta=${running
477
+ ? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
478
+ : html`Not running.`}>
479
+ ${!running ? html`
480
+ <div class="tunnel-hero">
481
+ <div class="tunnel-hero-body">
482
+ <div class="tunnel-hero-title">Bring this backend online</div>
483
+ <div class="tunnel-hero-meta">
484
+ ccsm will spawn
485
+ <code>${provider === 'devtunnel' ? 'devtunnel' : 'cloudflared'}</code>
486
+ and wait for it to print a public URL.
487
+ </div>
288
488
  </div>
489
+ <button type="button" class="action tunnel-hero-cta"
490
+ disabled=${busy}
491
+ onClick=${onStart}>
492
+ <${IconExternal} /> ${busy ? 'Starting…' : 'Start tunnel'}
493
+ </button>
289
494
  </div>
290
- <div class="field">
291
- <span class="label">Microsoft Dev Tunnel</span>
292
- <div class="remote-status-line">
293
- <${ProviderStatus} id="devtunnel" info=${dt}
294
- onInstall=${() => onInstall('devtunnel')}
295
- onLogin=${() => onLogin('devtunnel')} />
495
+ ` : html`
496
+ <div class="tunnel-live">
497
+ <div class="tunnel-live-head">
498
+ <span class="tunnel-live-state">
499
+ <span class="tunnel-live-dot"></span>
500
+ Live
501
+ </span>
502
+ <span class="tunnel-live-divider">·</span>
503
+ <span class="tunnel-live-provider">${status?.provider === 'devtunnel' ? 'Microsoft Dev Tunnel' : 'Cloudflare Tunnel'}</span>
504
+ <span class="tunnel-live-divider">·</span>
505
+ <span class="tunnel-live-meta">since ${new Date(status.startedAt).toLocaleTimeString()}</span>
506
+ <button type="button" class="tunnel-stop-link"
507
+ disabled=${busy}
508
+ onClick=${onStop}>
509
+ <${IconClose} /> ${busy ? 'Stopping…' : 'Stop tunnel'}
510
+ </button>
296
511
  </div>
512
+ ${url ? html`
513
+ <div class="tunnel-share">
514
+ <div class="tunnel-share-label">Share URL</div>
515
+ <div class="tunnel-share-url">
516
+ <code class="tunnel-share-value">${share}</code>
517
+ <div class="tunnel-share-actions">
518
+ <button type="button" class="action small" onClick=${() => copy(share)}>
519
+ <${IconCopy} /> Copy
520
+ </button>
521
+ <a class="action small" href=${share} target="_blank" rel="noreferrer noopener">
522
+ <${IconExternal} /> Open
523
+ </a>
524
+ </div>
525
+ </div>
526
+ <div class="tunnel-share-hint">
527
+ Send this to the remote device · token embedded, stripped from the URL on first arrival.
528
+ </div>
529
+ </div>
530
+ ` : html`
531
+ <div class="tunnel-share is-waiting">
532
+ <div class="signin-card-spinner" aria-hidden="true"></div>
533
+ <span>Waiting for the CLI to print a public URL…</span>
534
+ </div>
535
+ `}
536
+ ${log.length ? html`
537
+ <details class="remote-log tunnel-log">
538
+ <summary>CLI log · ${log.length} lines</summary>
539
+ <pre>${log.join('\n')}</pre>
540
+ </details>
541
+ ` : null}
297
542
  </div>
298
- </div>
543
+ `}
299
544
  </${Section}>
300
545
 
301
546
  <${Section}
302
547
  title="Registration token"
303
- meta=${html`Embedded in the share URL. Only needed to <em>register</em> a new device for approval once you approve a device, it keeps working even if you rotate the token.`}>
548
+ meta=${html`Auto-generated. Only used to register new devicesapproved devices keep working after a rotate.`}>
304
549
  <div class="config-grid">
305
550
  <div class="field">
306
551
  <span class="label">Token</span>
307
552
  <div class="remote-token-row">
308
553
  <input type="text" class="input remote-token-input"
309
554
  readonly
310
- placeholder="click Generate to create a token"
555
+ placeholder="auto-generated on first Start tunnel"
311
556
  value=${token} />
312
- <button type="button" class="action" title="Generate a new token and save it"
557
+ <button type="button" class="action" title="Mint a fresh token (invalidates outstanding share URLs)"
313
558
  onClick=${onGenerateToken}>
314
- <${IconRecycle} /> Generate
559
+ <${IconRecycle} /> ${token ? 'Rotate' : 'Generate'}
315
560
  </button>
316
561
  <button type="button" class="action"
317
562
  disabled=${!token}
@@ -321,72 +566,16 @@ export function RemotePage() {
321
566
  </div>
322
567
  <span class="hint">
323
568
  ${(!status?.token && !token)
324
- ? html`<span class="warn">No token set · new devices can't register.</span>`
569
+ ? html`No token yet one is minted automatically the first time you start a tunnel.`
325
570
  : html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
326
571
  </span>
327
572
  </div>
328
573
  </div>
329
574
  </${Section}>
330
575
 
331
- <${Section}
332
- title="Tunnel"
333
- meta=${running
334
- ? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
335
- : html`Not running.`}>
336
- <div class="config-grid">
337
- <div class="field">
338
- <span class="label">State</span>
339
- <div>
340
- ${!running ? html`
341
- <button type="button" class="action primary"
342
- disabled=${busy || !token || token.length < 8}
343
- onClick=${onStart}>
344
- Start tunnel
345
- </button>
346
- ` : html`
347
- <button type="button" class="action danger"
348
- disabled=${busy}
349
- onClick=${onStop}>
350
- Stop tunnel
351
- </button>
352
- `}
353
- ${running && !url ? html`<span class="hint inline">Waiting for URL…</span>` : null}
354
- </div>
355
- </div>
356
-
357
- ${running && url ? html`
358
- <div class="field">
359
- <span class="label">Share URL</span>
360
- <div class="remote-url-line">
361
- <code class="remote-url-value">${share}</code>
362
- <button type="button" class="action" onClick=${() => copy(share)}>
363
- <${IconCopy} /> Copy
364
- </button>
365
- <a class="action" href=${share} target="_blank" rel="noreferrer noopener">
366
- <${IconExternal} /> Open
367
- </a>
368
- </div>
369
- <span class="hint">
370
- Send this to the remote device · token embedded, stripped from the URL on first arrival.
371
- </span>
372
- </div>
373
- ` : null}
374
-
375
- ${log.length ? html`
376
- <div class="field">
377
- <span class="label">CLI log</span>
378
- <details class="remote-log">
379
- <summary>${log.length} lines</summary>
380
- <pre>${log.join('\n')}</pre>
381
- </details>
382
- </div>
383
- ` : null}
384
- </div>
385
- </${Section}>
386
-
387
576
  <${Section}
388
577
  title="Devices"
389
- meta=${html`Browsers that loaded the share URL. Approve once per device — token alone is not enough.`}>
578
+ meta=${html`Approve each new device once.`}>
390
579
  ${(() => {
391
580
  const pending = deviceList.filter((d) => d.status === 'pending');
392
581
  const approved = deviceList.filter((d) => d.status === 'approved');
@@ -437,28 +626,5 @@ export function RemotePage() {
437
626
  })()}
438
627
  </${Section}>
439
628
 
440
- <${Section} title="How access works" meta="What the token does, what an approved device gets, and how to lock things back down.">
441
- <dl class="remote-facts">
442
- <div class="remote-fact">
443
- <dt>The token is just a knock</dt>
444
- <dd>
445
- The share URL embeds the token and the remote browser uses it once — only to register itself in the <strong>Pending</strong> list. After that the URL + token grant nothing on their own. Until you click <strong>Approve</strong>, the visitor's <code>/api/*</code> calls all return 403.
446
- </dd>
447
- </div>
448
- <div class="remote-fact">
449
- <dt>Approved devices are sticky</dt>
450
- <dd>
451
- Once approved, the device's per-browser UUID becomes the credential — every API + WebSocket call rides on that alone. <strong>Rotating the token doesn't kick them out</strong>; that only blocks new arrivals. To lock an existing device out, hit <strong>Revoke</strong> in the Devices list above.
452
- </dd>
453
- </div>
454
- <div class="remote-fact">
455
- <dt>This machine is exempt</dt>
456
- <dd>
457
- Loopback callers (<code>localhost</code>, <code>127.0.0.1</code>) skip both checks — your own browser on this host needs nothing. Tunnel traffic is distinguished by the <code>X-Forwarded-*</code> headers the proxies inject, so it can't masquerade as local.
458
- </dd>
459
- </div>
460
- </dl>
461
- </${Section}>
462
-
463
629
  </div>`;
464
630
  }
@@ -17,6 +17,12 @@ export const serverHealth = signal({ state: 'connecting' });
17
17
  // frontend session. Gates UI (HealthOverlay) so it doesn't pop on the
18
18
  // very first boot probe while the page is still wiring up.
19
19
  export const hasBootedOnline = signal(false);
20
+ // Set true the moment the user clicks "Restart backend" — the
21
+ // RestartOverlay reads this signal and blocks the whole page until
22
+ // the next health poll returns a fresh PID. Cleared by the overlay
23
+ // itself on reconnect. Kept here (not in ConfigurePage local state)
24
+ // so a stale tab on another page can't miss the in-flight restart.
25
+ export const restartInFlight = signal(null); // { startedAt, prevPid } | null
20
26
 
21
27
  // ── ui state (persisted in localStorage where noted) ───────────
22
28
  export const activeTab = signal('sessions');
package/server.js CHANGED
@@ -66,7 +66,7 @@ app.use((req, res, next) => {
66
66
  if (origin && ALLOWED_ORIGINS.has(origin)) {
67
67
  res.setHeader('Access-Control-Allow-Origin', origin);
68
68
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
69
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id');
69
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-Device-Code');
70
70
  res.setHeader('Vary', 'Origin');
71
71
  }
72
72
  if (req.method === 'OPTIONS') return res.sendStatus(204);
@@ -123,6 +123,7 @@ async function deviceGate(req, res, next) {
123
123
  try { await devices.record(id, {
124
124
  userAgent: req.headers['user-agent'] || '',
125
125
  ip: String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(),
126
+ code: req.headers['x-device-code'] || '',
126
127
  }); } catch { /* lastSeen bump is best-effort */ }
127
128
  if (d.status === 'approved') return next();
128
129
  return res.status(403).json({
@@ -1071,6 +1072,26 @@ app.post('/api/tunnel/install', asyncH(async (req, res) => {
1071
1072
  res.status(400).json({ error: e.message });
1072
1073
  }
1073
1074
  }));
1075
+ // Interactive `devtunnel user login -d` driver. The Remote page POSTs
1076
+ // here to start a device-code flow, then polls /api/tunnel/status to
1077
+ // learn the URL+code it should display and the eventual outcome —
1078
+ // avoids the older "copy this command into a shell" UX.
1079
+ app.post('/api/tunnel/devtunnel/login', asyncH(async (req, res) => {
1080
+ const { mode } = req.body || {};
1081
+ try {
1082
+ const snap = await tunnel.startDevtunnelLogin({ mode });
1083
+ res.json({ ok: true, login: snap });
1084
+ } catch (e) {
1085
+ res.status(400).json({ error: e.message });
1086
+ }
1087
+ }));
1088
+ app.post('/api/tunnel/devtunnel/login/cancel', asyncH(async (_req, res) => {
1089
+ res.json({ ok: true, login: tunnel.cancelDevtunnelLogin() });
1090
+ }));
1091
+ app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
1092
+ tunnel.clearDevtunnelLogin();
1093
+ res.json({ ok: true });
1094
+ }));
1074
1095
 
1075
1096
  // ---- devices ----
1076
1097
  //
@@ -1099,7 +1120,8 @@ app.get('/api/devices/me', asyncH(async (req, res) => {
1099
1120
  }
1100
1121
  const ua = req.headers['user-agent'] || '';
1101
1122
  const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
1102
- const d = await devices.record(id, { userAgent: ua, ip });
1123
+ const code = String(req.headers['x-device-code'] || (req.query && req.query.code) || '').slice(0, 8);
1124
+ const d = await devices.record(id, { userAgent: ua, ip, code });
1103
1125
  res.json(d);
1104
1126
  }));
1105
1127
  app.get('/api/devices', asyncH(async (_req, res) => {