@bakapiano/ccsm 0.17.11 → 0.18.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.
@@ -0,0 +1,86 @@
1
+ // Full-screen blocker shown on the remote browser while waiting for
2
+ // the host to approve this device. Triggered by api.js setting the
3
+ // pendingDevice signal in response to a 403 {pending:true}.
4
+ //
5
+ // While visible, polls /api/devices/me every 3s. When the server
6
+ // flips status to 'approved', the next polled api() call clears
7
+ // pendingDevice (api.js does that on any 2xx response), the overlay
8
+ // unmounts, and the rest of the app keeps loading.
9
+ //
10
+ // Reuses .offline-overlay / .offline-card classes so styling matches
11
+ // the existing HealthOverlay's blocking modal aesthetic.
12
+
13
+ import { html } from '../html.js';
14
+ import { useEffect } from 'preact/hooks';
15
+ import { api, pendingDevice, loadConfig, refreshAll } from '../api.js';
16
+ import { BrandMark } from '../icons.js';
17
+
18
+ const POLL_MS = 3000;
19
+
20
+ export function PendingApprovalOverlay() {
21
+ const p = pendingDevice.value;
22
+
23
+ useEffect(() => {
24
+ if (!p) return;
25
+ let stopped = false;
26
+ const tick = async () => {
27
+ if (stopped) return;
28
+ try {
29
+ // /api/devices/me is gate-exempt: returns 200 with the current
30
+ // device record regardless of approval state. We inspect the
31
+ // body ourselves to decide whether to dismiss the overlay.
32
+ const d = await api('GET', '/api/devices/me');
33
+ if (d && d.status === 'approved') {
34
+ pendingDevice.value = null;
35
+ // First load failed because we weren't approved yet — main.js'
36
+ // boot tried /api/config and got 401, no auto-retry. Now that
37
+ // we're through the gate, kick a one-shot load so config +
38
+ // sessions/folders/workspaces hydrate without waiting for the
39
+ // 5s tick (and config without that ever happening, since the
40
+ // periodic loop doesn't include loadConfig).
41
+ loadConfig().catch(() => {});
42
+ refreshAll().catch(() => {});
43
+ } else if (d) {
44
+ pendingDevice.value = {
45
+ pending: d.status === 'pending',
46
+ rejected: d.status === 'rejected',
47
+ deviceId: d.id,
48
+ firstSeen: d.firstSeen,
49
+ at: Date.now(),
50
+ };
51
+ }
52
+ } catch { /* network blip — try again next tick */ }
53
+ };
54
+ const id = setInterval(tick, POLL_MS);
55
+ tick();
56
+ return () => { stopped = true; clearInterval(id); };
57
+ }, [!!p]);
58
+
59
+ if (!p) return null;
60
+
61
+ const rejected = !!p.rejected;
62
+ const firstSeen = p.firstSeen ? new Date(p.firstSeen).toLocaleTimeString() : null;
63
+
64
+ return html`
65
+ <div class="offline-overlay" role="dialog" aria-modal="true" aria-live="polite">
66
+ <div class="offline-card">
67
+ <div class="offline-brand"><${BrandMark} /></div>
68
+ ${rejected ? html`
69
+ <h1 class="offline-title">Access declined</h1>
70
+ <p class="offline-copy">
71
+ The host machine rejected this device. If you think this was a
72
+ mistake, ask the operator to re-approve from the Remote page.
73
+ </p>
74
+ ` : html`
75
+ <h1 class="offline-title">Waiting for host approval</h1>
76
+ <p class="offline-copy">
77
+ The host machine got your request${firstSeen ? ` at ${firstSeen}` : ''}.
78
+ Approve this device from the Remote page over there to continue.
79
+ </p>
80
+ <p class="offline-copy" style="margin-top:6px;font-size:12px;color:var(--ink-muted)">
81
+ We'll auto-unlock the moment the host clicks Approve.
82
+ </p>
83
+ `}
84
+ </div>
85
+ </div>`;
86
+ }
@@ -1,18 +1,19 @@
1
1
  import { html } from '../html.js';
2
2
  import { signal } from '@preact/signals';
3
3
  import {
4
- activeTab, sidebarCollapsed, sidebarForcedCollapsed, configDirty, capabilities,
4
+ activeTab, sidebarCollapsed, sidebarForcedCollapsed, isMobile, configDirty, capabilities,
5
5
  sessions, folders, sessionsByFolder, foldersCollapsed, activeSessionId,
6
6
  selectTab, selectSession, toggleSidebar, toggleFolder, setSidebarWidth,
7
7
  } from '../state.js';
8
8
  import { createFolder, renameFolder, deleteFolder, reorderFolders, setSessionFolder, reorderSessions, deleteSession, resumeSession, setSessionTitle } from '../api.js';
9
+ import { isRemoteAccess } from '../backend.js';
9
10
  import { ccsmPrompt, ccsmConfirm } from '../dialog.js';
10
11
  import { setToast } from '../toast.js';
11
12
  import { fmtAgo } from '../util.js';
12
13
  import { clockTick } from '../state.js';
13
14
  import { useDragSort } from './useDragSort.js';
14
15
  import {
15
- IconLaunch, IconConfigure,
16
+ IconLaunch, IconConfigure, IconRemote,
16
17
  IconSidebarToggle, IconPencil, IconClose, IconFolder, IconFolderOpen, IconPlus, BrandMark,
17
18
  } from '../icons.js';
18
19
 
@@ -309,8 +310,13 @@ function SessionTree() {
309
310
  }
310
311
 
311
312
  export function Sidebar() {
312
- const collapsed = sidebarCollapsed.value || sidebarForcedCollapsed.value;
313
- const forced = sidebarForcedCollapsed.value;
313
+ // On phones the sidebar is rendered inside a full-screen drawer
314
+ // (App applies .is-mobile + .drawer-open classes). It should always
315
+ // appear in EXPANDED form there — full labels + sessions tree.
316
+ // Desktop/tablet keeps the original collapse behaviour.
317
+ const mobile = isMobile.value;
318
+ const collapsed = !mobile && (sidebarCollapsed.value || sidebarForcedCollapsed.value);
319
+ const forced = !mobile && sidebarForcedCollapsed.value;
314
320
 
315
321
  const onResizeStart = (ev) => {
316
322
  if (collapsed) return;
@@ -346,6 +352,9 @@ export function Sidebar() {
346
352
  <nav class="sidebar-nav compact" role="tablist" aria-label="Sections">
347
353
  <${NavItem} tab="launch" icon=${html`<${IconLaunch} />`} label="New Session" />
348
354
  <${NavItem} tab="configure" icon=${html`<${IconConfigure} />`} label="Settings" dirty=${configDirty.value} />
355
+ ${!isRemoteAccess() ? html`
356
+ <${NavItem} tab="remote" icon=${html`<${IconRemote} />`} label="Remote" />
357
+ ` : null}
349
358
  </nav>
350
359
 
351
360
  ${!collapsed ? html`<${SessionTree} />` : null}
@@ -9,7 +9,7 @@ import { FitAddon } from '@xterm/addon-fit';
9
9
  import { WebLinksAddon } from '@xterm/addon-web-links';
10
10
  import { ClipboardAddon } from '@xterm/addon-clipboard';
11
11
  import { WebglAddon } from '@xterm/addon-webgl';
12
- import { wsBase } from '../backend.js';
12
+ import { wsBase, getToken, getDeviceId } from '../backend.js';
13
13
 
14
14
  // Dark xterm theme. We give the terminal a near-black ink background to
15
15
  // match what claude code's TUI assumes (it paints its own input box +
@@ -40,9 +40,15 @@ export function TerminalView({ terminalId }) {
40
40
  useEffect(() => {
41
41
  if (!terminalId || !hostRef.current) return;
42
42
 
43
+ // Mobile viewports (≤ 640px) get a smaller default font so claude's
44
+ // UI fits ~50 cols instead of the ~26 that 13px would buy at 390px.
45
+ // Desktop stays at 13. We re-evaluate on every mount, so a viewport
46
+ // rotation that crosses the breakpoint picks up the new size on
47
+ // next mount (rare; users typically don't rotate mid-session).
48
+ const baseFontSize = window.matchMedia('(max-width: 640px)').matches ? 11 : 13;
43
49
  const term = new Terminal({
44
50
  fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
45
- fontSize: 13,
51
+ fontSize: baseFontSize,
46
52
  lineHeight: 1.2,
47
53
  cursorBlink: true,
48
54
  cursorStyle: 'bar',
@@ -108,7 +114,17 @@ export function TerminalView({ terminalId }) {
108
114
  requestAnimationFrame(() => { try { fit.fit(); } catch {} });
109
115
  termRef.current = term;
110
116
 
111
- const ws = new WebSocket(`${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}`);
117
+ // Browser WS API can't set Authorization headers — token + device
118
+ // ride as query string when we have them (Remote-mode access).
119
+ // Server's upgrade handler reads both when Host is non-loopback.
120
+ const tok = getToken();
121
+ const dev = getDeviceId();
122
+ const params = new URLSearchParams();
123
+ if (tok) params.set('token', tok);
124
+ if (dev) params.set('device', dev);
125
+ const qs = params.toString();
126
+ const wsUrl = `${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}${qs ? `?${qs}` : ''}`;
127
+ const ws = new WebSocket(wsUrl);
112
128
  ws.binaryType = 'arraybuffer';
113
129
  wsRef.current = ws;
114
130
 
@@ -131,6 +131,30 @@ export const IconMoreVert = ic('0 0 24 24', html`
131
131
  <circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
132
132
  `, 16);
133
133
 
134
+ // Broadcast / remote — radiating arcs over a centre dot. Used on the
135
+ // Remote nav tab; reads as "this machine is broadcasting" / "remote
136
+ // access available".
137
+ 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"/>
143
+ `, 18);
144
+
145
+ // Copy — two stacked rectangles. For "copy to clipboard" affordance
146
+ // next to URLs / tokens in the Remote page.
147
+ export const IconCopy = ic('0 0 24 24', html`
148
+ <rect x="9" y="9" width="11" height="11" rx="2"/>
149
+ <path d="M5 15V6a2 2 0 0 1 2-2h9"/>
150
+ `, 13);
151
+
152
+ // Recycle / regenerate — for "regenerate token" button.
153
+ export const IconRecycle = ic('0 0 24 24', html`
154
+ <polyline points="23 4 23 10 17 10"/>
155
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
156
+ `, 14);
157
+
134
158
  // Brand-colored CLI marks. These use external SVG assets (full color),
135
159
  // rendered as <img> so the gradients / fills in the file are preserved.
136
160
  export const IconClaudeColor = () => html`
package/public/js/main.js CHANGED
@@ -5,13 +5,34 @@
5
5
  import { render } from 'preact';
6
6
  import { effect } from '@preact/signals';
7
7
  import { html } from './html.js';
8
- import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, activeTab, activeSessionId, sessions, TAB_HEADINGS } from './state.js';
9
- import { httpBase } from './backend.js';
10
- import { loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth } from './api.js';
8
+ import { loadPersisted, clockTick, lastRefreshAt, installPrompt, isInstalledPwa, sidebarForcedCollapsed, isMobile, mobileDrawerOpen, activeTab, activeSessionId, sessions, TAB_HEADINGS } from './state.js';
9
+ import { httpBase, setToken, getDeviceId, isRemoteAccess } from './backend.js';
10
+ import { api, loadConfig, refreshAll, loadSessions, loadFolders, loadWorkspaces, pollHealth, pendingDevice } from './api.js';
11
11
  import { setToast } from './toast.js';
12
12
  import { App } from './components/App.js';
13
13
  import { installGlobalKeybindings } from './keybindings.js';
14
14
 
15
+ // First thing we do on boot: if the URL carries `?token=…` it's a fresh
16
+ // share link from the Remote page on the host machine. Stash it in
17
+ // localStorage so api.js / TerminalView pick it up, then strip the query
18
+ // string from the URL via history.replaceState — keeps the secret out
19
+ // of the address bar / browser history / clipboard sharing later.
20
+ // Also ensure a device id exists in localStorage right away — getDeviceId
21
+ // is a side-effecting getter (creates + persists on first call). Calling
22
+ // it here means api.js sees a stable id from the very first fetch.
23
+ (() => {
24
+ try {
25
+ const u = new URL(location.href);
26
+ const t = u.searchParams.get('token');
27
+ if (t) {
28
+ setToken(t);
29
+ u.searchParams.delete('token');
30
+ history.replaceState(null, '', u.pathname + (u.search ? `?${u.searchParams.toString()}` : '') + u.hash);
31
+ }
32
+ getDeviceId();
33
+ } catch {}
34
+ })();
35
+
15
36
  loadPersisted();
16
37
  installGlobalKeybindings();
17
38
  // Window/tab title — reactive. In standalone PWA mode we hide our own
@@ -84,13 +105,24 @@ matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass
84
105
  matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
85
106
  matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
86
107
 
87
- // Force-collapse the sidebar on narrow viewports. Mirrors the responsive
88
- // CSS so JS state (toggle visibility, tree-render gating) agrees with the
89
- // rendered layout.
90
- const narrowMq = matchMedia('(max-width: 900px)');
91
- function applyNarrow() { sidebarForcedCollapsed.value = narrowMq.matches; }
92
- applyNarrow();
93
- narrowMq.addEventListener('change', applyNarrow);
108
+ // The old 640–900px "force-collapse" mode is gone narrow desktops
109
+ // keep the full sidebar, phone viewports get the FAB drawer below.
110
+ // `sidebarForcedCollapsed` is left at its default `false` so any
111
+ // remaining readers (Sidebar resize handle gate, etc.) behave like
112
+ // desktop. Removing the signal entirely would mean touching every
113
+ // consumer; leaving it inert is a smaller blast radius.
114
+
115
+ // Phone-sized viewports get a different nav model: sidebar hidden,
116
+ // floating bottom-left button opens a full-screen drawer.
117
+ const mobileMq = matchMedia('(max-width: 640px)');
118
+ function applyMobile() {
119
+ isMobile.value = mobileMq.matches;
120
+ // Always close the drawer on a breakpoint flip so the user doesn't
121
+ // resize from desktop into mobile with a phantom open drawer.
122
+ if (mobileDrawerOpen.value) mobileDrawerOpen.value = false;
123
+ }
124
+ applyMobile();
125
+ mobileMq.addEventListener('change', applyMobile);
94
126
 
95
127
  // Counter-zoom for the page-title-bar. Browser page zoom (Ctrl+wheel) scales every CSS px including our header heights;
96
128
  // without this, the header gets visually taller at 150%+ which the user
@@ -105,6 +137,25 @@ function syncAntiZoom() {
105
137
  syncAntiZoom();
106
138
  window.addEventListener('resize', syncAntiZoom);
107
139
 
140
+ // WCO title-bar height — read the actual OS strip height via
141
+ // navigator.windowControlsOverlay.getTitlebarAreaRect() and publish it
142
+ // as --titlebar-h. CSS env(titlebar-area-height) is the analogous value
143
+ // but Chromium occasionally lies (under-reports by a couple px on Edge),
144
+ // and we don't get a JS handle to drive other measurements from. The
145
+ // JS API is the source of truth here; the rect's height is exactly the
146
+ // strip the OS leaves us. Fires on geometrychange so window-move-across-
147
+ // monitors / DPI-flip / restore-from-maximize re-sync.
148
+ function syncTitlebarHeight() {
149
+ try {
150
+ const r = navigator.windowControlsOverlay?.getTitlebarAreaRect?.();
151
+ if (r && r.height > 0) {
152
+ document.documentElement.style.setProperty('--titlebar-h', `${r.height}px`);
153
+ }
154
+ } catch { /* unsupported · CSS falls back to env() then 32px */ }
155
+ }
156
+ syncTitlebarHeight();
157
+ navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitlebarHeight);
158
+
108
159
  (async () => {
109
160
  // Version-mismatch guard runs FIRST. If the user's backend has been
110
161
  // upgraded since this per-version frontend was loaded, bounce back to
@@ -113,6 +164,28 @@ window.addEventListener('resize', syncAntiZoom);
113
164
  // the build-time <meta>).
114
165
  await bootVersionGuard();
115
166
 
167
+ // On a remote browser we MUST register at /api/devices/me before any
168
+ // other /api/* call — the device gate 401s with "unknown device"
169
+ // otherwise. The /me handler accepts the token from the share URL,
170
+ // creates a pending record, and (post-approval) keeps returning the
171
+ // existing record without a token. Setting pendingDevice from the
172
+ // response wakes PendingApprovalOverlay; on approval the signal
173
+ // clears in there.
174
+ if (isRemoteAccess()) {
175
+ try {
176
+ const me = await api('GET', '/api/devices/me');
177
+ if (me && me.status !== 'approved') {
178
+ pendingDevice.value = {
179
+ pending: me.status === 'pending',
180
+ rejected: me.status === 'rejected',
181
+ deviceId: me.id,
182
+ firstSeen: me.firstSeen,
183
+ at: Date.now(),
184
+ };
185
+ }
186
+ } catch (e) { /* token bad / network blip — surfaces via other calls */ }
187
+ }
188
+
116
189
  try {
117
190
  await loadConfig();
118
191
  await refreshAll();
@@ -143,7 +216,17 @@ window.addEventListener('resize', syncAntiZoom);
143
216
  // 10s cadence is short enough that any tab open for one full cycle gets
144
217
  // caught by the post-close decision in server.js; long enough not to be
145
218
  // chatty.
146
- const ping = () => fetch(httpBase() + '/api/heartbeat', { method: 'POST', keepalive: true }).catch(() => {});
219
+ const ping = () => {
220
+ const headers = {};
221
+ // Heartbeat doesn't go through api.js' wrapper but still needs the
222
+ // bearer token + device id when called via tunnel (the middleware
223
+ // blocks it otherwise and the server thinks the session went idle).
224
+ const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
225
+ if (t) headers['Authorization'] = `Bearer ${t}`;
226
+ const d = getDeviceId();
227
+ if (d) headers['X-Device-Id'] = d;
228
+ return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
229
+ };
147
230
  ping();
148
231
  setInterval(ping, 10_000);
149
232
  document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });