@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.
package/public/js/api.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { signal } from '@preact/signals';
5
5
  import * as S from './state.js';
6
- import { httpBase, getToken, getDeviceId, isRemoteAccess } from './backend.js';
6
+ import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess } from './backend.js';
7
7
 
8
8
  // Global pending-approval signal. Flipped to true whenever any /api
9
9
  // call returns 403 {pending:true}; PendingApprovalOverlay watches this
@@ -26,6 +26,12 @@ export async function api(method, url, body) {
26
26
  // for any tunnel-served page to clear the device-approval gate.
27
27
  const dev = getDeviceId();
28
28
  if (dev) opts.headers['X-Device-Id'] = dev;
29
+ // 4-digit identification code (see getDeviceCode in backend.js).
30
+ // Server stores it on first sight; the Remote page renders it
31
+ // alongside each pending device so the operator can confirm "yes,
32
+ // this is the request I just made on my phone" before approving.
33
+ const code = getDeviceCode();
34
+ if (code) opts.headers['X-Device-Code'] = code;
29
35
  if (body !== undefined) opts.body = JSON.stringify(body);
30
36
  const r = await fetch(httpBase() + url, opts);
31
37
  const text = await r.text();
@@ -37,14 +43,24 @@ export async function api(method, url, body) {
37
43
  // checks.
38
44
  if (isRemoteAccess()) {
39
45
  if (r.status === 403 && json && (json.pending || json.rejected)) {
40
- pendingDevice.value = { ...json, at: Date.now() };
46
+ // Merge into the existing pendingDevice rather than overwriting
47
+ // so the "we recorded you at HH:MM" detail (only present on the
48
+ // initial /me hit, not subsequent gate 403s) survives. Without
49
+ // this merge, the first failing /api/sessions tick after the
50
+ // overlay mounts wipes the firstSeen timestamp and the copy
51
+ // reverts to a generic "The host machine got your request".
52
+ const prev = pendingDevice.value || {};
53
+ pendingDevice.value = { ...prev, ...json, at: Date.now() };
41
54
  } else if (r.status === 401) {
42
55
  // Server doesn't recognise our device — either fresh page load
43
- // (no /api/devices/me hit yet) or our record got deleted /
44
- // pruned. Drop into the pending overlay; its /me poll will
45
- // re-register us using the token we still have in localStorage,
46
- // and the response sets pendingDevice to the correct state.
47
- pendingDevice.value = { pending: true, at: Date.now() };
56
+ // (no /api/devices/me hit yet) or our record got pruned (24h
57
+ // pending TTL) AND our token no longer matches the host's
58
+ // current one. PendingApprovalOverlay's /me poll will try to
59
+ // re-register; on token mismatch /me itself 401s and the
60
+ // overlay flips into "token expired" state. We just nudge the
61
+ // overlay alive here.
62
+ const prev = pendingDevice.value || {};
63
+ pendingDevice.value = { ...prev, pending: true, at: Date.now() };
48
64
  }
49
65
  }
50
66
  throw new Error(json.error || `HTTP ${r.status}`);
@@ -82,3 +82,29 @@ export function getDeviceId() {
82
82
  return null;
83
83
  }
84
84
  }
85
+
86
+ // Per-device 4-digit human-verification code. Sent alongside the
87
+ // device id so the operator approving on the host can match what
88
+ // they see in the Remote page against what the requesting user
89
+ // reads off their own screen — guards against approving the wrong
90
+ // pending request when two devices arrive in quick succession.
91
+ // Purely identification, NOT a credential — no secrecy assumed.
92
+ const LS_DEVICE_CODE = 'ccsm.deviceCode';
93
+
94
+ export function getDeviceCode() {
95
+ try {
96
+ let c = localStorage.getItem(LS_DEVICE_CODE);
97
+ if (!c || !/^\d{4}$/.test(c)) {
98
+ // 1000..9999 inclusive so the leading digit is never 0 — keeps
99
+ // the code visually consistent at 4 characters wherever it
100
+ // shows up. Random.value covers 9000 possibilities, plenty for
101
+ // a "which of these is yours" disambiguator.
102
+ const n = 1000 + Math.floor(Math.random() * 9000);
103
+ c = String(n);
104
+ localStorage.setItem(LS_DEVICE_CODE, c);
105
+ }
106
+ return c;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
@@ -8,6 +8,7 @@ import { Toast } from './Toast.js';
8
8
  import { DialogHost } from './DialogHost.js';
9
9
  import { HealthOverlay } from './HealthOverlay.js';
10
10
  import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
11
+ import { RestartOverlay } from './RestartOverlay.js';
11
12
  import { MobileNavFab } from './MobileNavFab.js';
12
13
  import { isMobile, mobileDrawerOpen } from '../state.js';
13
14
  import { SessionsPage } from '../pages/SessionsPage.js';
@@ -65,6 +66,7 @@ export function App() {
65
66
  <${Toast} />
66
67
  <${DialogHost} />
67
68
  <${HealthOverlay} />
69
+ <${RestartOverlay} />
68
70
  <${PendingApprovalOverlay} />
69
71
  <${MobileNavFab} />
70
72
  </div>`;
@@ -13,48 +13,22 @@
13
13
  import { html } from '../html.js';
14
14
  import { useEffect, useState } from 'preact/hooks';
15
15
  import { serverHealth } from '../state.js';
16
- import { refreshAll, pollHealth } from '../api.js';
16
+ import { refreshAll } from '../api.js';
17
17
  import { BrandMark } from '../icons.js';
18
18
 
19
- // Silent ccsm:// launch via hidden iframe. Same trick as the router.
20
- // If the protocol is registered AND the user has already OK'd the
21
- // Windows confirmation prompt, ccsm wakes up within ~2s and the
22
- // banner auto-dismisses on the next health poll. On a cold first
23
- // visit (protocol not registered, or "Always allow" not yet ticked),
24
- // the iframe noops silently and the manual "Start ccsm" button is
25
- // still there as fallback.
26
- function silentProtocolLaunch() {
27
- try {
28
- const f = document.createElement('iframe');
29
- f.style.display = 'none';
30
- f.src = 'ccsm://start';
31
- document.body.appendChild(f);
32
- setTimeout(() => { try { f.remove(); } catch {} }, 1500);
33
- } catch {}
34
- }
35
-
36
19
  export function OfflineBanner() {
37
20
  const h = serverHealth.value;
38
21
  const offline = h.state === 'offline';
39
22
  const [clicked, setClicked] = useState(false);
40
- const [autoTried, setAutoTried] = useState(false);
41
23
 
42
- // First time we see offline state, try a silent ccsm:// launch and
43
- // tighten the health-poll cadence for a few seconds so the redirect
44
- // happens within ~2-3s without any visible UI flash.
45
- useEffect(() => {
46
- if (!offline || autoTried) return;
47
- setAutoTried(true);
48
- silentProtocolLaunch();
49
- let n = 0;
50
- const tick = async () => {
51
- if (n++ > 12) return; // ~6s of tight polling
52
- await pollHealth();
53
- if (serverHealth.value.state === 'online') return;
54
- setTimeout(tick, 500);
55
- };
56
- setTimeout(tick, 500);
57
- }, [offline]);
24
+ // We used to silently fire ccsm://start via a hidden iframe the
25
+ // moment the backend went offline. Even when the user had OK'd
26
+ // "Always allow" once, some browsers still flashed a momentary
27
+ // confirmation prompt — and first-time visitors got a "Open
28
+ // ccsm.cmd?" dialog with no apparent trigger, which is a bad UX.
29
+ // The Start button below is the only path that fires the protocol
30
+ // now; the health poll picks the backend up automatically once
31
+ // the user clicks it (or starts ccsm from a terminal / shortcut).
58
32
 
59
33
  useEffect(() => {
60
34
  if (h.state === 'online' && clicked) {
@@ -13,6 +13,7 @@
13
13
  import { html } from '../html.js';
14
14
  import { useEffect } from 'preact/hooks';
15
15
  import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
16
+ import { getDeviceCode } from '../backend.js';
16
17
  import { BrandMark } from '../icons.js';
17
18
 
18
19
  const POLL_MS = 3000;
@@ -41,15 +42,38 @@ export function PendingApprovalOverlay() {
41
42
  loadConfig().catch(() => {});
42
43
  refreshAll().catch(() => {});
43
44
  } else if (d) {
45
+ // Preserve any fields already set (firstSeen survives the
46
+ // overlay's repeated polls; staleToken from a previous tick
47
+ // gets cleared since we just got a 200).
44
48
  pendingDevice.value = {
49
+ ...(pendingDevice.value || {}),
45
50
  pending: d.status === 'pending',
46
51
  rejected: d.status === 'rejected',
47
52
  deviceId: d.id,
48
53
  firstSeen: d.firstSeen,
54
+ staleToken: false,
49
55
  at: Date.now(),
50
56
  };
51
57
  }
52
- } catch { /* network blip — try again next tick */ }
58
+ } catch (e) {
59
+ // /me 401s in exactly one situation: our device record was
60
+ // pruned (24h pending TTL) AND the host's current token no
61
+ // longer matches the one in our localStorage. We can't
62
+ // self-recover from this — a fresh share URL is needed. Surface
63
+ // it so the user gets honest feedback instead of an indefinite
64
+ // "Waiting for approval" loop.
65
+ const msg = String(e?.message || '');
66
+ if (/401/.test(msg) || /token/i.test(msg)) {
67
+ pendingDevice.value = {
68
+ ...(pendingDevice.value || {}),
69
+ staleToken: true,
70
+ pending: false,
71
+ rejected: false,
72
+ at: Date.now(),
73
+ };
74
+ }
75
+ /* anything else: network blip — try again next tick */
76
+ }
53
77
  };
54
78
  const id = setInterval(tick, POLL_MS);
55
79
  tick();
@@ -59,13 +83,24 @@ export function PendingApprovalOverlay() {
59
83
  if (!p) return null;
60
84
 
61
85
  const rejected = !!p.rejected;
86
+ const staleToken = !!p.staleToken;
62
87
  const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
63
88
 
64
89
  return html`
65
90
  <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
66
91
  <div class="offline-card">
67
92
  <div class="offline-brand"><${BrandMark} /></div>
68
- ${rejected ? html`
93
+ ${staleToken ? html`
94
+ <h1 class="offline-title">Link expired</h1>
95
+ <p class="offline-copy">
96
+ This share link no longer works — the host either rotated the
97
+ registration token or this device was pruned after sitting
98
+ unapproved for 24 hours.
99
+ </p>
100
+ <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
101
+ Ask the operator to send a fresh share URL from the Remote page.
102
+ </p>
103
+ ` : rejected ? html`
69
104
  <h1 class="offline-title">Access declined</h1>
70
105
  <p class="offline-copy">
71
106
  The host machine rejected this device. If you think this was a
@@ -77,6 +112,13 @@ export function PendingApprovalOverlay() {
77
112
  The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
78
113
  Approve this device from the Remote page over there to continue.
79
114
  </p>
115
+ ${getDeviceCode() ? html`
116
+ <div class="offline-code-block">
117
+ <div class="offline-code-label">Your code</div>
118
+ <div class="offline-code">${getDeviceCode()}</div>
119
+ <div class="offline-code-hint">Match this against what the host sees before they Approve.</div>
120
+ </div>
121
+ ` : null}
80
122
  <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
81
123
  We'll auto-unlock the moment the host clicks Approve.
82
124
  </p>
@@ -0,0 +1,36 @@
1
+ // Small top-of-viewport banner shown while a user-initiated backend
2
+ // restart is in flight. Used to be a full-screen blocking modal;
3
+ // turned out the user just wanted visible "we're working" feedback,
4
+ // not a giant card-and-button covering the page. Self-dismisses when
5
+ // /api/health reports a fresh PID (different from the one we captured
6
+ // at click time), or after 30s as a safety net.
7
+
8
+ import { html } from '../html.js';
9
+ import { useEffect } from 'preact/hooks';
10
+ import { restartInFlight, serverHealth } from '../state.js';
11
+ import { refreshAll } from '../api.js';
12
+
13
+ export function RestartOverlay() {
14
+ const info = restartInFlight.value;
15
+ const h = serverHealth.value;
16
+
17
+ useEffect(() => {
18
+ if (!info) return;
19
+ if (h.state === 'online' && h.pid && h.pid !== info.prevPid) {
20
+ restartInFlight.value = null;
21
+ refreshAll().catch(() => {});
22
+ }
23
+ const id = setTimeout(() => {
24
+ if (restartInFlight.value === info) restartInFlight.value = null;
25
+ }, 30_000);
26
+ return () => clearTimeout(id);
27
+ }, [info, h.state, h.pid]);
28
+
29
+ if (!info) return null;
30
+
31
+ return html`
32
+ <div class="restart-banner" role="status" aria-live="polite">
33
+ <span class="restart-banner-spinner" aria-hidden="true"></span>
34
+ <span class="restart-banner-text">Restarting backend…</span>
35
+ </div>`;
36
+ }
@@ -358,10 +358,10 @@ export function Sidebar() {
358
358
 
359
359
  <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
360
360
  <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
361
- <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
362
361
  ${!isRemoteAccess() ? html`
363
362
  <${NavItem} tab="remote" icon=${html`<${IconRemote} />`} label="Remote" />
364
363
  ` : null}
364
+ <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
365
365
  </nav>
366
366
 
367
367
  ${!collapsed ? html`<${SessionTree} />` : null}
@@ -14,7 +14,8 @@ export const IconSessions = ic('0 0 24 24', html`
14
14
  `, 18);
15
15
 
16
16
  export const IconLaunch = ic('0 0 24 24', html`
17
- <path d="M7 17L17 7"/><path d="M9 7h8v8"/>
17
+ <path d="M19 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6"/>
18
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
18
19
  `, 18);
19
20
 
20
21
  export const IconConfigure = ic('0 0 24 24', html`
@@ -135,11 +136,11 @@ export const IconMoreVert = ic('0 0 24 24', html`
135
136
  // Remote nav tab; reads as "this machine is broadcasting" / "remote
136
137
  // access available".
137
138
  export const IconRemote = ic('0 0 24 24', html`
138
- <circle cx="12" cy="12" r="2"/>
139
- <path d="M8.5 8.5a5 5 0 0 0 0 7"/>
140
- <path d="M15.5 8.5a5 5 0 0 1 0 7"/>
141
- <path d="M5.5 5.5a9 9 0 0 0 0 13"/>
142
- <path d="M18.5 5.5a9 9 0 0 1 0 13"/>
139
+ <circle cx="18" cy="5" r="2.5"/>
140
+ <circle cx="6" cy="12" r="2.5"/>
141
+ <circle cx="18" cy="19" r="2.5"/>
142
+ <line x1="8.2" y1="10.8" x2="15.8" y2="6.2"/>
143
+ <line x1="8.2" y1="13.2" x2="15.8" y2="17.8"/>
143
144
  `, 18);
144
145
 
145
146
  // Copy — two stacked rectangles. For "copy to clipboard" affordance
@@ -163,6 +164,10 @@ export const IconCodexColor = () => html`
163
164
  <img src="./assets/codex-color.svg" alt="" width="18" height="18" style="display:block" />`;
164
165
  export const IconCopilotColor = () => html`
165
166
  <img src="./assets/copilot-color.svg" alt="" width="18" height="18" style="display:block" />`;
167
+ export const IconCloudflareColor = ({ size = 28 } = {}) => html`
168
+ <img src="./assets/cloudflare-color.svg" alt="" width=${size} height=${size} style="display:block" />`;
169
+ export const IconMicrosoftColor = ({ size = 28 } = {}) => html`
170
+ <img src="./assets/microsoft-color.svg" alt="" width=${size} height=${size} style="display:block" />`;
166
171
 
167
172
  // Pick the right icon for a CLI based on its type field.
168
173
  export const IconForCliType = (type) => {
package/public/js/main.js CHANGED
@@ -201,7 +201,19 @@ navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitleb
201
201
  // loadWorkspaces is included because the workspace "in use" flag is
202
202
  // derived from live session cwds server-side — without it, sessions
203
203
  // move in/out of a workspace silently and the grid stays stale.
204
+ // Skipped while a remote tab is sitting in the pending-approval
205
+ // overlay — every call would 403, fill the console with red, and the
206
+ // user can't see anything anyway. PendingApprovalOverlay handles its
207
+ // own re-hydrate the moment we get approved.
204
208
  setInterval(async () => {
209
+ if (pendingDevice.value) {
210
+ // Skip the data fetches (every one would 403) but still poll
211
+ // health so the OfflineBanner can show if the host goes down
212
+ // while we're sitting on the approval screen.
213
+ pollHealth();
214
+ clockTick.value = Date.now();
215
+ return;
216
+ }
205
217
  try {
206
218
  await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
207
219
  lastRefreshAt.value = Date.now();
@@ -217,6 +229,11 @@ navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitleb
217
229
  // caught by the post-close decision in server.js; long enough not to be
218
230
  // chatty.
219
231
  const ping = () => {
232
+ // While we're stuck on the pending-approval overlay, /api/heartbeat
233
+ // would 403 every 10s. Pointless noise — the host's watchdog is
234
+ // gated on real user activity anyway. Resumes automatically once
235
+ // pendingDevice clears.
236
+ if (pendingDevice.value) return Promise.resolve();
220
237
  const headers = {};
221
238
  // Heartbeat doesn't go through api.js' wrapper but still needs the
222
239
  // bearer token + device id when called via tunnel (the middleware
@@ -6,6 +6,7 @@ import { html } from '../html.js';
6
6
  import { useEffect, useState } from 'preact/hooks';
7
7
  import {
8
8
  config, configDirty, accentColor, folders, workspaces, serverHealth,
9
+ restartInFlight,
9
10
  setAccentColor, ACCENT_DEFAULT,
10
11
  } from '../state.js';
11
12
  import {
@@ -493,39 +494,55 @@ function VersionField() {
493
494
  const latest = info?.latest;
494
495
  const updateAvailable = !!info?.updateAvailable;
495
496
 
496
- const hint = info?.error
497
- ? "Couldn't reach npm registry."
498
- : updateAvailable ? `Update available · v${latest}`
499
- : latest ? "You're on the latest release."
500
- : 'Checks npm registry (cached 30 min).';
501
-
502
497
  return html`
503
- <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
504
- <span class="mono">v${current || '?'}</span>
505
- ${updateAvailable ? html`
506
- <button class="action primary" disabled=${upgrading} onClick=${onUpgrade}>
507
- ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
498
+ <div class=${`version-card${updateAvailable ? ' has-update' : info?.error ? ' has-error' : ''}`}>
499
+ <div class="version-card-main">
500
+ <div class="version-card-current">
501
+ <span class="version-card-label">Installed</span>
502
+ <span class="version-card-version">v${current || '?'}</span>
503
+ ${!updateAvailable && !info?.error && latest ? html`
504
+ <span class="version-card-badge">Latest</span>
505
+ ` : null}
506
+ </div>
507
+ <div class="version-card-meta">
508
+ ${info?.error
509
+ ? html`<span class="version-card-error">Couldn't reach npm registry · <code>${info.error}</code></span>`
510
+ : updateAvailable
511
+ ? html`Update available · <span class="mono">v${latest}</span>`
512
+ : latest
513
+ ? `You're on the latest release. Checks npm registry (cached 30 min).`
514
+ : 'Checks npm registry (cached 30 min).'}
515
+ </div>
516
+ </div>
517
+ <div class="version-card-actions">
518
+ ${updateAvailable ? html`
519
+ <button class="action primary" disabled=${upgrading} onClick=${onUpgrade}>
520
+ ${upgrading ? 'Upgrading…' : `Upgrade to v${latest}`}
521
+ </button>
522
+ ` : null}
523
+ <button class="action version-card-check" disabled=${checking || upgrading} onClick=${() => refresh(true)}>
524
+ <${IconRefresh} /> ${checking ? 'Checking…' : 'Check now'}
508
525
  </button>
509
- ` : null}
510
- <button class="action" disabled=${checking || upgrading} onClick=${() => refresh(true)}>
511
- ${checking ? 'Checking…' : 'Check for updates'}
512
- </button>
513
- <span class="hint">${hint}</span>
526
+ </div>
514
527
  </div>
515
528
  `;
516
529
  }
517
530
 
518
531
  function RestartButton() {
519
- const [busy, setBusy] = useState(false);
520
532
  const onClick = async () => {
521
533
  const ok = await ccsmConfirm(
522
534
  'Restart the ccsm backend? Active sessions will be killed and reattached on next launch.',
523
535
  { okLabel: 'Restart', danger: true });
524
536
  if (!ok) return;
525
- setBusy(true);
537
+ // Drop the fullscreen RestartOverlay BEFORE firing /api/restart —
538
+ // the request itself takes ~0ms (response is "ok, restarting") but
539
+ // the server then begins tearing PTYs down. If we wait for the
540
+ // response before opening the overlay, the user gets a frozen
541
+ // button + half-a-second of confusion.
542
+ const prevPid = serverHealth.value.pid || null;
543
+ restartInFlight.value = { startedAt: Date.now(), prevPid };
526
544
  try {
527
545
  const r = await restartBackend();
528
- setToast('restarting backend…');
529
546
  if (r?.closeFrontend) {
530
547
  // Backend respawn will pop a fresh browser window — close this
531
548
  // one so the user isn't stuck on the OfflineBanner during the
@@ -534,17 +551,18 @@ function RestartButton() {
534
551
  // which is the right behavior for them.
535
552
  setTimeout(() => { try { window.close(); } catch {} }, 400);
536
553
  }
554
+ // RestartOverlay self-dismisses once /api/health reports a fresh
555
+ // pid, so no further work here. If the new backend never comes
556
+ // back, the overlay has its own 30s safety timeout + OfflineBanner
557
+ // takes over.
537
558
  } catch (e) {
538
- setBusy(false);
559
+ restartInFlight.value = null;
539
560
  setToast(e.message, 'error');
540
561
  }
541
562
  };
542
563
  return html`
543
- <div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
544
- <button class="action" disabled=${busy} onClick=${onClick}>
545
- ${busy ? 'Restarting…' : 'Restart backend'}
546
- </button>
547
- <span class="hint">Stops the server, then spawns a fresh one on the same port.</span>
564
+ <div class="restart-button-wrap">
565
+ <button class="action" onClick=${onClick}>Restart backend</button>
548
566
  </div>
549
567
  `;
550
568
  }