@bakapiano/ccsm 0.22.3 → 0.22.5
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/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +274 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +176 -176
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +645 -543
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +159 -22
- package/public/js/components/TerminalResizeDebouncer.js +126 -0
- package/public/js/components/TerminalView.js +15 -2
- package/public/js/components/XtermTerminal.js +74 -15
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +713 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +199 -80
- package/public/js/state.js +335 -335
- package/public/manifest.webmanifest +25 -0
- package/public/setup/index.html +567 -0
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1807 -1807
package/public/js/main.js
CHANGED
|
@@ -1,296 +1,296 @@
|
|
|
1
|
-
// Entry. Loads persisted ui state → boots data → mounts App → spins up
|
|
2
|
-
// the 5s auto-refresh + 1s clock tick. No imperative DOM access outside
|
|
3
|
-
// the mount root.
|
|
4
|
-
|
|
5
|
-
import { render } from 'preact';
|
|
6
|
-
import { effect } from '@preact/signals';
|
|
7
|
-
import { html } from './html.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
|
-
import { setToast } from './toast.js';
|
|
12
|
-
import { App } from './components/App.js';
|
|
13
|
-
import { installGlobalKeybindings } from './keybindings.js';
|
|
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
|
-
|
|
36
|
-
loadPersisted();
|
|
37
|
-
installGlobalKeybindings();
|
|
38
|
-
// Window/tab title — reactive. In standalone PWA mode we hide our own
|
|
39
|
-
// .page-title-bar and the browser-drawn OS title bar takes its place,
|
|
40
|
-
// so document.title is what the user actually sees as the header. It
|
|
41
|
-
// mirrors what would have been in our hidden header: session title +
|
|
42
|
-
// cwd on the Sessions tab, the page heading elsewhere.
|
|
43
|
-
// MutationObserver guards against Chromium standalone builds that
|
|
44
|
-
// occasionally try to inject the URL into the title bar.
|
|
45
|
-
let desiredTitle = 'CCSM';
|
|
46
|
-
function lockTitle() { if (document.title !== desiredTitle) document.title = desiredTitle; }
|
|
47
|
-
function computeTitle() {
|
|
48
|
-
const tab = activeTab.value;
|
|
49
|
-
if (tab === 'sessions') {
|
|
50
|
-
const id = activeSessionId.value;
|
|
51
|
-
const s = id ? sessions.value.find((x) => x.id === id) : null;
|
|
52
|
-
if (s) {
|
|
53
|
-
const name = s.title || s.workspace || s.id.slice(0, 12);
|
|
54
|
-
return `${name} · ${s.cwd} · CCSM`;
|
|
55
|
-
}
|
|
56
|
-
return 'Sessions · CCSM';
|
|
57
|
-
}
|
|
58
|
-
return `${TAB_HEADINGS[tab]?.title || 'CCSM'} · CCSM`;
|
|
59
|
-
}
|
|
60
|
-
effect(() => { desiredTitle = computeTitle(); lockTitle(); });
|
|
61
|
-
new MutationObserver(lockTitle).observe(
|
|
62
|
-
document.querySelector('title') || document.head,
|
|
63
|
-
{ childList: true, subtree: true, characterData: true }
|
|
64
|
-
);
|
|
65
|
-
render(html`<${App} />`, document.getElementById('app'));
|
|
66
|
-
|
|
67
|
-
// PWA install affordance — Chromium fires `beforeinstallprompt` when the
|
|
68
|
-
// manifest meets install criteria (served over localhost / https, has icon,
|
|
69
|
-
// not already installed). We stash the event so the About page can offer
|
|
70
|
-
// a one-click install button that triggers it.
|
|
71
|
-
window.addEventListener('beforeinstallprompt', (ev) => {
|
|
72
|
-
ev.preventDefault();
|
|
73
|
-
installPrompt.value = ev;
|
|
74
|
-
});
|
|
75
|
-
window.addEventListener('appinstalled', () => {
|
|
76
|
-
installPrompt.value = null;
|
|
77
|
-
isInstalledPwa.value = true;
|
|
78
|
-
});
|
|
79
|
-
// On boot, detect if we're already running as an installed PWA window
|
|
80
|
-
// (display-mode standalone covers both plain PWA + WCO). When true, the
|
|
81
|
-
// "install" affordance hides itself.
|
|
82
|
-
const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
|
|
83
|
-
isInstalledPwa.value = mq.matches;
|
|
84
|
-
mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
|
|
85
|
-
|
|
86
|
-
// "is-app" body class · everything that isn't a regular browser tab
|
|
87
|
-
// (display-mode: browser) gets it. Used by wco.css to gate user-select
|
|
88
|
-
// on drag regions so chromeless --app= windows can be dragged by
|
|
89
|
-
// clicking the page title, while normal tabs still allow text select.
|
|
90
|
-
//
|
|
91
|
-
// "is-wco" is the stricter case: window-controls-overlay mode where the
|
|
92
|
-
// browser hides its title bar entirely and only floats OS controls in
|
|
93
|
-
// the top-right. In that mode our .page-title-bar IS the title bar and
|
|
94
|
-
// needs the 34px height + padding-right reservation. In plain standalone
|
|
95
|
-
// PWA (browser still paints its own title bar above our content), we
|
|
96
|
-
// don't need any of that — page-title-bar can behave like a regular tab.
|
|
97
|
-
function applyIsAppClass() {
|
|
98
|
-
const isApp = !matchMedia('(display-mode: browser)').matches;
|
|
99
|
-
const isWco = matchMedia('(display-mode: window-controls-overlay)').matches;
|
|
100
|
-
document.body.classList.toggle('is-app', isApp);
|
|
101
|
-
document.body.classList.toggle('is-wco', isWco);
|
|
102
|
-
}
|
|
103
|
-
applyIsAppClass();
|
|
104
|
-
matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
|
|
105
|
-
matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
|
|
106
|
-
matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
|
|
107
|
-
|
|
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);
|
|
126
|
-
|
|
127
|
-
// Counter-zoom for the page-title-bar. Browser page zoom (Ctrl+wheel) scales every CSS px including our header heights;
|
|
128
|
-
// without this, the header gets visually taller at 150%+ which the user
|
|
129
|
-
// usually doesn't want. We detect zoom via outerWidth/innerWidth and write
|
|
130
|
-
// 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
|
|
131
|
-
// each bar back to a constant on-screen height.
|
|
132
|
-
function syncAntiZoom() {
|
|
133
|
-
const z = window.outerWidth / window.innerWidth || 1;
|
|
134
|
-
const inv = Math.max(0.4, Math.min(1, 1 / z)); // clamp: never grow > 100%
|
|
135
|
-
document.documentElement.style.setProperty('--anti-zoom', String(inv));
|
|
136
|
-
}
|
|
137
|
-
syncAntiZoom();
|
|
138
|
-
window.addEventListener('resize', syncAntiZoom);
|
|
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
|
-
|
|
159
|
-
// Mobile soft-keyboard height. The layout viewport (100vh) does NOT shrink
|
|
160
|
-
// when the on-screen keyboard slides up — only `visualViewport` does — so a
|
|
161
|
-
// full-height terminal keeps its bottom rows hidden behind the keyboard. We
|
|
162
|
-
// publish the visible height as --app-vh (used by .app.is-mobile in
|
|
163
|
-
// responsive.css to shrink the whole app to the area above the keyboard)
|
|
164
|
-
// and flag body.kb-open when the keyboard is up (so the terminal can reserve
|
|
165
|
-
// room for the floating key bar). cap at a 120px delta so a browser
|
|
166
|
-
// URL-bar collapse doesn't read as a keyboard.
|
|
167
|
-
function syncViewportHeight() {
|
|
168
|
-
const vv = window.visualViewport;
|
|
169
|
-
if (!vv) return;
|
|
170
|
-
document.documentElement.style.setProperty('--app-vh', `${Math.round(vv.height)}px`);
|
|
171
|
-
const kbUp = (window.innerHeight - vv.height - vv.offsetTop) > 120;
|
|
172
|
-
document.body.classList.toggle('kb-open', kbUp);
|
|
173
|
-
}
|
|
174
|
-
syncViewportHeight();
|
|
175
|
-
window.visualViewport?.addEventListener?.('resize', syncViewportHeight);
|
|
176
|
-
window.visualViewport?.addEventListener?.('scroll', syncViewportHeight);
|
|
177
|
-
window.addEventListener('resize', syncViewportHeight);
|
|
178
|
-
|
|
179
|
-
(async () => {
|
|
180
|
-
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
181
|
-
// upgraded since this per-version frontend was loaded, bounce back to
|
|
182
|
-
// the router immediately — no point loading config from a server that
|
|
183
|
-
// speaks a different API revision. Runs in dev too (it no-ops without
|
|
184
|
-
// the build-time <meta>).
|
|
185
|
-
await bootVersionGuard();
|
|
186
|
-
|
|
187
|
-
// On a remote browser we MUST register at /api/devices/me before any
|
|
188
|
-
// other /api/* call — the device gate 401s with "unknown device"
|
|
189
|
-
// otherwise. The /me handler accepts the token from the share URL,
|
|
190
|
-
// creates a pending record, and (post-approval) keeps returning the
|
|
191
|
-
// existing record without a token. Setting pendingDevice from the
|
|
192
|
-
// response wakes PendingApprovalOverlay; on approval the signal
|
|
193
|
-
// clears in there.
|
|
194
|
-
if (isRemoteAccess()) {
|
|
195
|
-
try {
|
|
196
|
-
const me = await api('GET', '/api/devices/me');
|
|
197
|
-
if (me && me.status !== 'approved') {
|
|
198
|
-
pendingDevice.value = {
|
|
199
|
-
pending: me.status === 'pending',
|
|
200
|
-
rejected: me.status === 'rejected',
|
|
201
|
-
deviceId: me.id,
|
|
202
|
-
firstSeen: me.firstSeen,
|
|
203
|
-
at: Date.now(),
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
} catch (e) { /* token bad / network blip — surfaces via other calls */ }
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
await loadConfig();
|
|
211
|
-
await refreshAll();
|
|
212
|
-
pollHealth();
|
|
213
|
-
} catch (e) {
|
|
214
|
-
setToast('initial load failed · ' + e.message, 'error');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
|
|
218
|
-
// labels naturally track the data refresh; bumping clockTick more
|
|
219
|
-
// frequently would just cause needless re-renders since fmtAgo's
|
|
220
|
-
// resolution is coarse — 5s buckets under a minute, then m/h/d).
|
|
221
|
-
// loadWorkspaces is included because the workspace "in use" flag is
|
|
222
|
-
// derived from live session cwds server-side — without it, sessions
|
|
223
|
-
// move in/out of a workspace silently and the grid stays stale.
|
|
224
|
-
// Skipped while a remote tab is sitting in the pending-approval
|
|
225
|
-
// overlay — every call would 403, fill the console with red, and the
|
|
226
|
-
// user can't see anything anyway. PendingApprovalOverlay handles its
|
|
227
|
-
// own re-hydrate the moment we get approved.
|
|
228
|
-
setInterval(async () => {
|
|
229
|
-
if (pendingDevice.value) {
|
|
230
|
-
// Skip the data fetches (every one would 403) but still poll
|
|
231
|
-
// health so the OfflineBanner can show if the host goes down
|
|
232
|
-
// while we're sitting on the approval screen.
|
|
233
|
-
pollHealth();
|
|
234
|
-
clockTick.value = Date.now();
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
try {
|
|
238
|
-
await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
|
|
239
|
-
lastRefreshAt.value = Date.now();
|
|
240
|
-
} catch { /* swallow — next tick retries */ }
|
|
241
|
-
pollHealth();
|
|
242
|
-
clockTick.value = Date.now();
|
|
243
|
-
}, 5000);
|
|
244
|
-
|
|
245
|
-
// Heartbeat · the server uses this to (a) decide whether to shut down
|
|
246
|
-
// when its own spawned browser closes (multi-client check), and (b) as
|
|
247
|
-
// a 90s watchdog backup if the browser-exit signal is missed entirely.
|
|
248
|
-
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
249
|
-
// caught by the post-close decision in server.js; long enough not to be
|
|
250
|
-
// chatty.
|
|
251
|
-
const ping = () => {
|
|
252
|
-
// While we're stuck on the pending-approval overlay, /api/heartbeat
|
|
253
|
-
// would 403 every 10s. Pointless noise — the host's watchdog is
|
|
254
|
-
// gated on real user activity anyway. Resumes automatically once
|
|
255
|
-
// pendingDevice clears.
|
|
256
|
-
if (pendingDevice.value) return Promise.resolve();
|
|
257
|
-
const headers = {};
|
|
258
|
-
// Heartbeat doesn't go through api.js' wrapper but still needs the
|
|
259
|
-
// bearer token + device id when called via tunnel (the middleware
|
|
260
|
-
// blocks it otherwise and the server thinks the session went idle).
|
|
261
|
-
const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
|
|
262
|
-
if (t) headers['Authorization'] = `Bearer ${t}`;
|
|
263
|
-
const d = getDeviceId();
|
|
264
|
-
if (d) headers['X-Device-Id'] = d;
|
|
265
|
-
return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
|
|
266
|
-
};
|
|
267
|
-
ping();
|
|
268
|
-
setInterval(ping, 10_000);
|
|
269
|
-
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|
|
270
|
-
})();
|
|
271
|
-
|
|
272
|
-
// ─── version routing guard ───────────────────────────────────────────
|
|
273
|
-
// Each deployed frontend is pinned to one backend version. The GH-Pages
|
|
274
|
-
// workflow bakes the version into <meta name="ccsm-frontend-version">
|
|
275
|
-
// so we can detect "backend has been upgraded since this frontend was
|
|
276
|
-
// loaded" and bounce back through the router at /ccsm/ for a fresh
|
|
277
|
-
// match. In dev (no meta tag, same-origin served-by-backend), the check
|
|
278
|
-
// no-ops — we're always running the frontend that ships with this
|
|
279
|
-
// backend by definition.
|
|
280
|
-
async function bootVersionGuard() {
|
|
281
|
-
const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
|
|
282
|
-
if (!meta) return; // dev mode
|
|
283
|
-
const myVer = meta.getAttribute('content');
|
|
284
|
-
if (!myVer) return;
|
|
285
|
-
let backendVer = null;
|
|
286
|
-
try {
|
|
287
|
-
const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
|
|
288
|
-
if (!r.ok) return;
|
|
289
|
-
backendVer = (await r.json()).version;
|
|
290
|
-
} catch { return; } // offline → OfflineBanner takes over
|
|
291
|
-
if (!backendVer || backendVer === myVer) return;
|
|
292
|
-
// Mismatch. Bounce up one level to the router. The router will
|
|
293
|
-
// probe /api/health again and redirect to ./<backendVer>/.
|
|
294
|
-
console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
|
|
295
|
-
location.replace('../');
|
|
296
|
-
}
|
|
1
|
+
// Entry. Loads persisted ui state → boots data → mounts App → spins up
|
|
2
|
+
// the 5s auto-refresh + 1s clock tick. No imperative DOM access outside
|
|
3
|
+
// the mount root.
|
|
4
|
+
|
|
5
|
+
import { render } from 'preact';
|
|
6
|
+
import { effect } from '@preact/signals';
|
|
7
|
+
import { html } from './html.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
|
+
import { setToast } from './toast.js';
|
|
12
|
+
import { App } from './components/App.js';
|
|
13
|
+
import { installGlobalKeybindings } from './keybindings.js';
|
|
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
|
+
|
|
36
|
+
loadPersisted();
|
|
37
|
+
installGlobalKeybindings();
|
|
38
|
+
// Window/tab title — reactive. In standalone PWA mode we hide our own
|
|
39
|
+
// .page-title-bar and the browser-drawn OS title bar takes its place,
|
|
40
|
+
// so document.title is what the user actually sees as the header. It
|
|
41
|
+
// mirrors what would have been in our hidden header: session title +
|
|
42
|
+
// cwd on the Sessions tab, the page heading elsewhere.
|
|
43
|
+
// MutationObserver guards against Chromium standalone builds that
|
|
44
|
+
// occasionally try to inject the URL into the title bar.
|
|
45
|
+
let desiredTitle = 'CCSM';
|
|
46
|
+
function lockTitle() { if (document.title !== desiredTitle) document.title = desiredTitle; }
|
|
47
|
+
function computeTitle() {
|
|
48
|
+
const tab = activeTab.value;
|
|
49
|
+
if (tab === 'sessions') {
|
|
50
|
+
const id = activeSessionId.value;
|
|
51
|
+
const s = id ? sessions.value.find((x) => x.id === id) : null;
|
|
52
|
+
if (s) {
|
|
53
|
+
const name = s.title || s.workspace || s.id.slice(0, 12);
|
|
54
|
+
return `${name} · ${s.cwd} · CCSM`;
|
|
55
|
+
}
|
|
56
|
+
return 'Sessions · CCSM';
|
|
57
|
+
}
|
|
58
|
+
return `${TAB_HEADINGS[tab]?.title || 'CCSM'} · CCSM`;
|
|
59
|
+
}
|
|
60
|
+
effect(() => { desiredTitle = computeTitle(); lockTitle(); });
|
|
61
|
+
new MutationObserver(lockTitle).observe(
|
|
62
|
+
document.querySelector('title') || document.head,
|
|
63
|
+
{ childList: true, subtree: true, characterData: true }
|
|
64
|
+
);
|
|
65
|
+
render(html`<${App} />`, document.getElementById('app'));
|
|
66
|
+
|
|
67
|
+
// PWA install affordance — Chromium fires `beforeinstallprompt` when the
|
|
68
|
+
// manifest meets install criteria (served over localhost / https, has icon,
|
|
69
|
+
// not already installed). We stash the event so the About page can offer
|
|
70
|
+
// a one-click install button that triggers it.
|
|
71
|
+
window.addEventListener('beforeinstallprompt', (ev) => {
|
|
72
|
+
ev.preventDefault();
|
|
73
|
+
installPrompt.value = ev;
|
|
74
|
+
});
|
|
75
|
+
window.addEventListener('appinstalled', () => {
|
|
76
|
+
installPrompt.value = null;
|
|
77
|
+
isInstalledPwa.value = true;
|
|
78
|
+
});
|
|
79
|
+
// On boot, detect if we're already running as an installed PWA window
|
|
80
|
+
// (display-mode standalone covers both plain PWA + WCO). When true, the
|
|
81
|
+
// "install" affordance hides itself.
|
|
82
|
+
const mq = matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)');
|
|
83
|
+
isInstalledPwa.value = mq.matches;
|
|
84
|
+
mq.addEventListener('change', () => { isInstalledPwa.value = mq.matches; });
|
|
85
|
+
|
|
86
|
+
// "is-app" body class · everything that isn't a regular browser tab
|
|
87
|
+
// (display-mode: browser) gets it. Used by wco.css to gate user-select
|
|
88
|
+
// on drag regions so chromeless --app= windows can be dragged by
|
|
89
|
+
// clicking the page title, while normal tabs still allow text select.
|
|
90
|
+
//
|
|
91
|
+
// "is-wco" is the stricter case: window-controls-overlay mode where the
|
|
92
|
+
// browser hides its title bar entirely and only floats OS controls in
|
|
93
|
+
// the top-right. In that mode our .page-title-bar IS the title bar and
|
|
94
|
+
// needs the 34px height + padding-right reservation. In plain standalone
|
|
95
|
+
// PWA (browser still paints its own title bar above our content), we
|
|
96
|
+
// don't need any of that — page-title-bar can behave like a regular tab.
|
|
97
|
+
function applyIsAppClass() {
|
|
98
|
+
const isApp = !matchMedia('(display-mode: browser)').matches;
|
|
99
|
+
const isWco = matchMedia('(display-mode: window-controls-overlay)').matches;
|
|
100
|
+
document.body.classList.toggle('is-app', isApp);
|
|
101
|
+
document.body.classList.toggle('is-wco', isWco);
|
|
102
|
+
}
|
|
103
|
+
applyIsAppClass();
|
|
104
|
+
matchMedia('(display-mode: browser)').addEventListener('change', applyIsAppClass);
|
|
105
|
+
matchMedia('(display-mode: window-controls-overlay)').addEventListener('change', applyIsAppClass);
|
|
106
|
+
matchMedia('(display-mode: standalone)').addEventListener('change', applyIsAppClass);
|
|
107
|
+
|
|
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);
|
|
126
|
+
|
|
127
|
+
// Counter-zoom for the page-title-bar. Browser page zoom (Ctrl+wheel) scales every CSS px including our header heights;
|
|
128
|
+
// without this, the header gets visually taller at 150%+ which the user
|
|
129
|
+
// usually doesn't want. We detect zoom via outerWidth/innerWidth and write
|
|
130
|
+
// 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
|
|
131
|
+
// each bar back to a constant on-screen height.
|
|
132
|
+
function syncAntiZoom() {
|
|
133
|
+
const z = window.outerWidth / window.innerWidth || 1;
|
|
134
|
+
const inv = Math.max(0.4, Math.min(1, 1 / z)); // clamp: never grow > 100%
|
|
135
|
+
document.documentElement.style.setProperty('--anti-zoom', String(inv));
|
|
136
|
+
}
|
|
137
|
+
syncAntiZoom();
|
|
138
|
+
window.addEventListener('resize', syncAntiZoom);
|
|
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
|
+
|
|
159
|
+
// Mobile soft-keyboard height. The layout viewport (100vh) does NOT shrink
|
|
160
|
+
// when the on-screen keyboard slides up — only `visualViewport` does — so a
|
|
161
|
+
// full-height terminal keeps its bottom rows hidden behind the keyboard. We
|
|
162
|
+
// publish the visible height as --app-vh (used by .app.is-mobile in
|
|
163
|
+
// responsive.css to shrink the whole app to the area above the keyboard)
|
|
164
|
+
// and flag body.kb-open when the keyboard is up (so the terminal can reserve
|
|
165
|
+
// room for the floating key bar). cap at a 120px delta so a browser
|
|
166
|
+
// URL-bar collapse doesn't read as a keyboard.
|
|
167
|
+
function syncViewportHeight() {
|
|
168
|
+
const vv = window.visualViewport;
|
|
169
|
+
if (!vv) return;
|
|
170
|
+
document.documentElement.style.setProperty('--app-vh', `${Math.round(vv.height)}px`);
|
|
171
|
+
const kbUp = (window.innerHeight - vv.height - vv.offsetTop) > 120;
|
|
172
|
+
document.body.classList.toggle('kb-open', kbUp);
|
|
173
|
+
}
|
|
174
|
+
syncViewportHeight();
|
|
175
|
+
window.visualViewport?.addEventListener?.('resize', syncViewportHeight);
|
|
176
|
+
window.visualViewport?.addEventListener?.('scroll', syncViewportHeight);
|
|
177
|
+
window.addEventListener('resize', syncViewportHeight);
|
|
178
|
+
|
|
179
|
+
(async () => {
|
|
180
|
+
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
181
|
+
// upgraded since this per-version frontend was loaded, bounce back to
|
|
182
|
+
// the router immediately — no point loading config from a server that
|
|
183
|
+
// speaks a different API revision. Runs in dev too (it no-ops without
|
|
184
|
+
// the build-time <meta>).
|
|
185
|
+
await bootVersionGuard();
|
|
186
|
+
|
|
187
|
+
// On a remote browser we MUST register at /api/devices/me before any
|
|
188
|
+
// other /api/* call — the device gate 401s with "unknown device"
|
|
189
|
+
// otherwise. The /me handler accepts the token from the share URL,
|
|
190
|
+
// creates a pending record, and (post-approval) keeps returning the
|
|
191
|
+
// existing record without a token. Setting pendingDevice from the
|
|
192
|
+
// response wakes PendingApprovalOverlay; on approval the signal
|
|
193
|
+
// clears in there.
|
|
194
|
+
if (isRemoteAccess()) {
|
|
195
|
+
try {
|
|
196
|
+
const me = await api('GET', '/api/devices/me');
|
|
197
|
+
if (me && me.status !== 'approved') {
|
|
198
|
+
pendingDevice.value = {
|
|
199
|
+
pending: me.status === 'pending',
|
|
200
|
+
rejected: me.status === 'rejected',
|
|
201
|
+
deviceId: me.id,
|
|
202
|
+
firstSeen: me.firstSeen,
|
|
203
|
+
at: Date.now(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
} catch (e) { /* token bad / network blip — surfaces via other calls */ }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await loadConfig();
|
|
211
|
+
await refreshAll();
|
|
212
|
+
pollHealth();
|
|
213
|
+
} catch (e) {
|
|
214
|
+
setToast('initial load failed · ' + e.message, 'error');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 5s data refresh + clock tick (same cadence so fmtAgo "Ns ago" relative
|
|
218
|
+
// labels naturally track the data refresh; bumping clockTick more
|
|
219
|
+
// frequently would just cause needless re-renders since fmtAgo's
|
|
220
|
+
// resolution is coarse — 5s buckets under a minute, then m/h/d).
|
|
221
|
+
// loadWorkspaces is included because the workspace "in use" flag is
|
|
222
|
+
// derived from live session cwds server-side — without it, sessions
|
|
223
|
+
// move in/out of a workspace silently and the grid stays stale.
|
|
224
|
+
// Skipped while a remote tab is sitting in the pending-approval
|
|
225
|
+
// overlay — every call would 403, fill the console with red, and the
|
|
226
|
+
// user can't see anything anyway. PendingApprovalOverlay handles its
|
|
227
|
+
// own re-hydrate the moment we get approved.
|
|
228
|
+
setInterval(async () => {
|
|
229
|
+
if (pendingDevice.value) {
|
|
230
|
+
// Skip the data fetches (every one would 403) but still poll
|
|
231
|
+
// health so the OfflineBanner can show if the host goes down
|
|
232
|
+
// while we're sitting on the approval screen.
|
|
233
|
+
pollHealth();
|
|
234
|
+
clockTick.value = Date.now();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
await Promise.all([loadSessions(), loadFolders(), loadWorkspaces()]);
|
|
239
|
+
lastRefreshAt.value = Date.now();
|
|
240
|
+
} catch { /* swallow — next tick retries */ }
|
|
241
|
+
pollHealth();
|
|
242
|
+
clockTick.value = Date.now();
|
|
243
|
+
}, 5000);
|
|
244
|
+
|
|
245
|
+
// Heartbeat · the server uses this to (a) decide whether to shut down
|
|
246
|
+
// when its own spawned browser closes (multi-client check), and (b) as
|
|
247
|
+
// a 90s watchdog backup if the browser-exit signal is missed entirely.
|
|
248
|
+
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
249
|
+
// caught by the post-close decision in server.js; long enough not to be
|
|
250
|
+
// chatty.
|
|
251
|
+
const ping = () => {
|
|
252
|
+
// While we're stuck on the pending-approval overlay, /api/heartbeat
|
|
253
|
+
// would 403 every 10s. Pointless noise — the host's watchdog is
|
|
254
|
+
// gated on real user activity anyway. Resumes automatically once
|
|
255
|
+
// pendingDevice clears.
|
|
256
|
+
if (pendingDevice.value) return Promise.resolve();
|
|
257
|
+
const headers = {};
|
|
258
|
+
// Heartbeat doesn't go through api.js' wrapper but still needs the
|
|
259
|
+
// bearer token + device id when called via tunnel (the middleware
|
|
260
|
+
// blocks it otherwise and the server thinks the session went idle).
|
|
261
|
+
const t = (typeof localStorage !== 'undefined') ? localStorage.getItem('ccsm.token') : null;
|
|
262
|
+
if (t) headers['Authorization'] = `Bearer ${t}`;
|
|
263
|
+
const d = getDeviceId();
|
|
264
|
+
if (d) headers['X-Device-Id'] = d;
|
|
265
|
+
return fetch(httpBase() + '/api/heartbeat', { method: 'POST', headers, keepalive: true }).catch(() => {});
|
|
266
|
+
};
|
|
267
|
+
ping();
|
|
268
|
+
setInterval(ping, 10_000);
|
|
269
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|
|
270
|
+
})();
|
|
271
|
+
|
|
272
|
+
// ─── version routing guard ───────────────────────────────────────────
|
|
273
|
+
// Each deployed frontend is pinned to one backend version. The GH-Pages
|
|
274
|
+
// workflow bakes the version into <meta name="ccsm-frontend-version">
|
|
275
|
+
// so we can detect "backend has been upgraded since this frontend was
|
|
276
|
+
// loaded" and bounce back through the router at /ccsm/ for a fresh
|
|
277
|
+
// match. In dev (no meta tag, same-origin served-by-backend), the check
|
|
278
|
+
// no-ops — we're always running the frontend that ships with this
|
|
279
|
+
// backend by definition.
|
|
280
|
+
async function bootVersionGuard() {
|
|
281
|
+
const meta = document.querySelector('meta[name="ccsm-frontend-version"]');
|
|
282
|
+
if (!meta) return; // dev mode
|
|
283
|
+
const myVer = meta.getAttribute('content');
|
|
284
|
+
if (!myVer) return;
|
|
285
|
+
let backendVer = null;
|
|
286
|
+
try {
|
|
287
|
+
const r = await fetch(httpBase() + '/api/health', { cache: 'no-store' });
|
|
288
|
+
if (!r.ok) return;
|
|
289
|
+
backendVer = (await r.json()).version;
|
|
290
|
+
} catch { return; } // offline → OfflineBanner takes over
|
|
291
|
+
if (!backendVer || backendVer === myVer) return;
|
|
292
|
+
// Mismatch. Bounce up one level to the router. The router will
|
|
293
|
+
// probe /api/health again and redirect to ./<backendVer>/.
|
|
294
|
+
console.warn(`[ccsm] frontend ${myVer} ≠ backend ${backendVer} — re-routing`);
|
|
295
|
+
location.replace('../');
|
|
296
|
+
}
|