@bakapiano/ccsm 0.17.10 → 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.
- package/lib/devices.js +215 -0
- package/lib/tunnel.js +253 -0
- package/package.json +1 -1
- package/public/css/layout.css +13 -3
- package/public/css/responsive.css +123 -3
- package/public/css/terminals.css +137 -32
- package/public/css/wco.css +68 -19
- package/public/css/widgets.css +276 -2
- package/public/js/api.js +43 -2
- package/public/js/backend.js +66 -10
- package/public/js/components/App.js +38 -2
- package/public/js/components/HealthOverlay.js +12 -0
- package/public/js/components/MobileNavFab.js +29 -0
- package/public/js/components/PendingApprovalOverlay.js +86 -0
- package/public/js/components/Sidebar.js +13 -4
- package/public/js/components/TerminalView.js +19 -3
- package/public/js/icons.js +29 -0
- package/public/js/main.js +95 -13
- package/public/js/pages/RemotePage.js +446 -0
- package/public/js/pages/SessionsPage.js +75 -28
- package/public/js/state.js +10 -0
- package/scripts/dev.js +11 -0
- package/server.js +214 -8
package/public/js/backend.js
CHANGED
|
@@ -1,28 +1,84 @@
|
|
|
1
|
-
// One source of truth for "where is the ccsm backend reachable"
|
|
1
|
+
// One source of truth for "where is the ccsm backend reachable"
|
|
2
|
+
// and "what auth token (if any) do we attach to every request".
|
|
2
3
|
//
|
|
3
|
-
// localhost / 127.0.0.1
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// localhost / 127.0.0.1 same-origin (page IS the backend)
|
|
5
|
+
// bakapiano.github.io http://localhost:7777 (the hosted
|
|
6
|
+
// frontend talks to the user's local
|
|
7
|
+
// backend via CORS)
|
|
8
|
+
// anything else (tunnel domain) same-origin (the local backend is
|
|
9
|
+
// serving this frontend over the
|
|
10
|
+
// tunnel; API calls go to the same
|
|
11
|
+
// tunnel URL automatically)
|
|
6
12
|
//
|
|
7
13
|
// httpBase is used by fetch(); wsBase is used by WebSocket constructions.
|
|
8
14
|
// Keep both as functions rather than constants so the values reflect
|
|
9
15
|
// `location.*` at call time (matters for tests / route changes).
|
|
10
16
|
|
|
17
|
+
const HOSTED_HOST = 'bakapiano.github.io';
|
|
18
|
+
|
|
11
19
|
function isLocal() {
|
|
12
20
|
return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
13
21
|
}
|
|
22
|
+
function isHosted() {
|
|
23
|
+
return location.hostname === HOSTED_HOST;
|
|
24
|
+
}
|
|
14
25
|
|
|
15
26
|
export function httpBase() {
|
|
16
|
-
|
|
27
|
+
if (isHosted()) return 'http://localhost:7777';
|
|
28
|
+
// Local OR tunnel-served — both same-origin.
|
|
29
|
+
return '';
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
export function wsBase() {
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
return 'ws://localhost:7777';
|
|
33
|
+
if (isHosted()) return 'ws://localhost:7777';
|
|
34
|
+
return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
export function isHostedFrontend() {
|
|
27
|
-
return
|
|
38
|
+
return isHosted();
|
|
39
|
+
}
|
|
40
|
+
// True when the page is being served via a remote tunnel — neither the
|
|
41
|
+
// host machine itself (localhost) nor the GH-Pages router. Used to gate
|
|
42
|
+
// off "wake backend" affordances that only work locally.
|
|
43
|
+
export function isRemoteAccess() {
|
|
44
|
+
return !isLocal() && !isHosted();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Remote-access bearer token ────────────────────────────────────
|
|
48
|
+
// Persisted in localStorage so it survives reloads on whatever device
|
|
49
|
+
// loaded the share URL. main.js captures a fresh token from `?token=`
|
|
50
|
+
// on first arrival and stashes it via setToken(), then strips the
|
|
51
|
+
// query string from the URL so the secret doesn't sit in the address
|
|
52
|
+
// bar / browser history.
|
|
53
|
+
const LS_KEY = 'ccsm.token';
|
|
54
|
+
|
|
55
|
+
export function getToken() {
|
|
56
|
+
try { return localStorage.getItem(LS_KEY) || null; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
export function setToken(t) {
|
|
59
|
+
try {
|
|
60
|
+
if (t) localStorage.setItem(LS_KEY, t);
|
|
61
|
+
else localStorage.removeItem(LS_KEY);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Device id ─────────────────────────────────────────────────────
|
|
66
|
+
// Per-browser-profile UUID that identifies this device to the host
|
|
67
|
+
// machine for the approval flow. Generated once, persisted in
|
|
68
|
+
// localStorage, sent on every API call as X-Device-Id. The host pairs
|
|
69
|
+
// the id with the User-Agent the server records on first sight, so
|
|
70
|
+
// the approval UI can show "iPhone · Safari" instead of a raw uuid.
|
|
71
|
+
const LS_DEVICE = 'ccsm.deviceId';
|
|
72
|
+
|
|
73
|
+
export function getDeviceId() {
|
|
74
|
+
try {
|
|
75
|
+
let id = localStorage.getItem(LS_DEVICE);
|
|
76
|
+
if (!id) {
|
|
77
|
+
id = (crypto.randomUUID && crypto.randomUUID()) || (Math.random().toString(36).slice(2) + Date.now().toString(36));
|
|
78
|
+
localStorage.setItem(LS_DEVICE, id);
|
|
79
|
+
}
|
|
80
|
+
return id;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
28
84
|
}
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
|
-
import { activeTab } from '../state.js';
|
|
2
|
+
import { activeTab, selectTab } from '../state.js';
|
|
3
|
+
import { useEffect } from 'preact/hooks';
|
|
4
|
+
import { isRemoteAccess } from '../backend.js';
|
|
5
|
+
import { PageTitleBar } from './PageTitleBar.js';
|
|
3
6
|
import { Sidebar } from './Sidebar.js';
|
|
4
7
|
import { Toast } from './Toast.js';
|
|
5
8
|
import { DialogHost } from './DialogHost.js';
|
|
6
9
|
import { HealthOverlay } from './HealthOverlay.js';
|
|
10
|
+
import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
|
|
11
|
+
import { MobileNavFab } from './MobileNavFab.js';
|
|
12
|
+
import { isMobile, mobileDrawerOpen } from '../state.js';
|
|
7
13
|
import { SessionsPage } from '../pages/SessionsPage.js';
|
|
8
14
|
import { LaunchPage } from '../pages/LaunchPage.js';
|
|
9
15
|
import { ConfigurePage } from '../pages/ConfigurePage.js';
|
|
16
|
+
import { RemotePage } from '../pages/RemotePage.js';
|
|
10
17
|
import { AboutPage } from '../pages/AboutPage.js';
|
|
11
18
|
|
|
12
19
|
function Panel({ name, children }) {
|
|
@@ -14,22 +21,51 @@ function Panel({ name, children }) {
|
|
|
14
21
|
return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
|
|
15
22
|
}
|
|
16
23
|
|
|
24
|
+
// Static placeholder for #remote on tunnel-served pages. Remote / device
|
|
25
|
+
// / tunnel management is loopback-only — the server returns 403 on
|
|
26
|
+
// every relevant endpoint — so even if a user navigates here via URL
|
|
27
|
+
// hash we render a clear "host machine only" message instead of a
|
|
28
|
+
// broken RemotePage spamming the console.
|
|
29
|
+
function RemoteHostOnlyPanel() {
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
// Bounce back to whatever tab they were on before, after a brief
|
|
32
|
+
// moment so the message is readable.
|
|
33
|
+
const t = setTimeout(() => selectTab('sessions'), 2500);
|
|
34
|
+
return () => clearTimeout(t);
|
|
35
|
+
}, []);
|
|
36
|
+
return html`
|
|
37
|
+
<${PageTitleBar} title="Remote" />
|
|
38
|
+
<div class="settings-scroll">
|
|
39
|
+
<p class="remote-empty" style="margin-top:var(--s-6)">
|
|
40
|
+
Remote management is only available on the host machine.
|
|
41
|
+
Bouncing back to Sessions…
|
|
42
|
+
</p>
|
|
43
|
+
</div>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
17
46
|
export function App() {
|
|
18
47
|
const tab = activeTab.value;
|
|
48
|
+
const remoteLocked = tab === 'remote' && isRemoteAccess();
|
|
49
|
+
const mobile = isMobile.value;
|
|
50
|
+
const drawer = mobileDrawerOpen.value;
|
|
19
51
|
|
|
20
52
|
return html`
|
|
21
|
-
<div class
|
|
53
|
+
<div class=${`app${mobile ? ' is-mobile' : ''}${mobile && drawer ? ' drawer-open' : ''}`}>
|
|
22
54
|
<${Sidebar} />
|
|
23
55
|
<main class="main">
|
|
24
56
|
<div class="content">
|
|
25
57
|
${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
|
|
26
58
|
${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
|
|
27
59
|
${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
|
|
60
|
+
${tab === 'remote' && !remoteLocked ? html`<${Panel} name="remote"><${RemotePage} /></${Panel}>` : null}
|
|
61
|
+
${remoteLocked ? html`<${Panel} name="remote"><${RemoteHostOnlyPanel} /></${Panel}>` : null}
|
|
28
62
|
${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
|
|
29
63
|
</div>
|
|
30
64
|
</main>
|
|
31
65
|
<${Toast} />
|
|
32
66
|
<${DialogHost} />
|
|
33
67
|
<${HealthOverlay} />
|
|
68
|
+
<${PendingApprovalOverlay} />
|
|
69
|
+
<${MobileNavFab} />
|
|
34
70
|
</div>`;
|
|
35
71
|
}
|
|
@@ -21,6 +21,7 @@ import { useEffect } from 'preact/hooks';
|
|
|
21
21
|
import { serverHealth, hasBootedOnline } from '../state.js';
|
|
22
22
|
import { pollHealth, refreshAll } from '../api.js';
|
|
23
23
|
import { BrandMark } from '../icons.js';
|
|
24
|
+
import { isRemoteAccess } from '../backend.js';
|
|
24
25
|
|
|
25
26
|
const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
|
|
26
27
|
const FAST_POLL_MS = 1500;
|
|
@@ -66,6 +67,17 @@ export function HealthOverlay() {
|
|
|
66
67
|
<p class="offline-copy">
|
|
67
68
|
${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
|
|
68
69
|
</p>
|
|
70
|
+
` : isRemoteAccess() ? html`
|
|
71
|
+
<h1 class="offline-title">Host machine offline</h1>
|
|
72
|
+
<p class="offline-copy">
|
|
73
|
+
The ccsm backend you connected to over the tunnel isn't reachable.
|
|
74
|
+
Only the operator at the host machine can restart it — the tunnel
|
|
75
|
+
URL is dead until ccsm is running there again.
|
|
76
|
+
</p>
|
|
77
|
+
<p class="offline-copy" style="margin-top:8px;font-size:12px;color:var(--ink-muted)">
|
|
78
|
+
We'll keep polling and reconnect automatically as soon as the
|
|
79
|
+
backend comes back.
|
|
80
|
+
</p>
|
|
69
81
|
` : html`
|
|
70
82
|
<h1 class="offline-title">Backend not running</h1>
|
|
71
83
|
<p class="offline-copy">
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Phone-only navigation affordance.
|
|
2
|
+
//
|
|
3
|
+
// On viewports ≤ 640px the sidebar is hidden via CSS (.is-mobile body
|
|
4
|
+
// class). Instead, a circular floating button sits bottom-left; tapping
|
|
5
|
+
// it sets mobileDrawerOpen, which the sidebar reads to flip into a
|
|
6
|
+
// full-screen overlay. A backdrop captures taps outside the sidebar
|
|
7
|
+
// and dismisses.
|
|
8
|
+
//
|
|
9
|
+
// Visible only when isMobile signal is true — saves a render branch
|
|
10
|
+
// elsewhere.
|
|
11
|
+
|
|
12
|
+
import { html } from '../html.js';
|
|
13
|
+
import { isMobile, mobileDrawerOpen } from '../state.js';
|
|
14
|
+
import { IconSidebarToggle, IconClose } from '../icons.js';
|
|
15
|
+
|
|
16
|
+
export function MobileNavFab() {
|
|
17
|
+
if (!isMobile.value) return null;
|
|
18
|
+
const open = mobileDrawerOpen.value;
|
|
19
|
+
return html`
|
|
20
|
+
${open ? html`
|
|
21
|
+
<div class="mobile-nav-backdrop"
|
|
22
|
+
onClick=${() => { mobileDrawerOpen.value = false; }} />
|
|
23
|
+
` : null}
|
|
24
|
+
<button class=${`mobile-nav-fab${open ? ' is-open' : ''}`}
|
|
25
|
+
aria-label=${open ? 'close navigation' : 'open navigation'}
|
|
26
|
+
onClick=${() => { mobileDrawerOpen.value = !open; }}>
|
|
27
|
+
${open ? html`<${IconClose} />` : html`<${IconSidebarToggle} />`}
|
|
28
|
+
</button>`;
|
|
29
|
+
}
|
|
@@ -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
|
-
|
|
313
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/public/js/icons.js
CHANGED
|
@@ -125,6 +125,35 @@ export const IconBranch = ic('0 0 24 24', html`
|
|
|
125
125
|
<circle cx="6" cy="18" r="3"/>
|
|
126
126
|
<path d="M18 9a9 9 0 0 1-9 9"/>
|
|
127
127
|
`, 18);
|
|
128
|
+
export const IconMoreVert = ic('0 0 24 24', html`
|
|
129
|
+
<circle cx="12" cy="5" r="1.6" fill="currentColor" stroke="none"/>
|
|
130
|
+
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/>
|
|
131
|
+
<circle cx="12" cy="19" r="1.6" fill="currentColor" stroke="none"/>
|
|
132
|
+
`, 16);
|
|
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);
|
|
128
157
|
|
|
129
158
|
// Brand-colored CLI marks. These use external SVG assets (full color),
|
|
130
159
|
// rendered as <img> so the gradients / fills in the file are preserved.
|
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,16 +105,26 @@ 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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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.
|
|
94
114
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
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;
|
|
97
128
|
// without this, the header gets visually taller at 150%+ which the user
|
|
98
129
|
// usually doesn't want. We detect zoom via outerWidth/innerWidth and write
|
|
99
130
|
// 1/zoom into --anti-zoom so the CSS can `calc(40px * var(--anti-zoom))`
|
|
@@ -106,6 +137,25 @@ function syncAntiZoom() {
|
|
|
106
137
|
syncAntiZoom();
|
|
107
138
|
window.addEventListener('resize', syncAntiZoom);
|
|
108
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
|
+
|
|
109
159
|
(async () => {
|
|
110
160
|
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
111
161
|
// upgraded since this per-version frontend was loaded, bounce back to
|
|
@@ -114,6 +164,28 @@ window.addEventListener('resize', syncAntiZoom);
|
|
|
114
164
|
// the build-time <meta>).
|
|
115
165
|
await bootVersionGuard();
|
|
116
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
|
+
|
|
117
189
|
try {
|
|
118
190
|
await loadConfig();
|
|
119
191
|
await refreshAll();
|
|
@@ -144,7 +216,17 @@ window.addEventListener('resize', syncAntiZoom);
|
|
|
144
216
|
// 10s cadence is short enough that any tab open for one full cycle gets
|
|
145
217
|
// caught by the post-close decision in server.js; long enough not to be
|
|
146
218
|
// chatty.
|
|
147
|
-
const ping = () =>
|
|
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
|
+
};
|
|
148
230
|
ping();
|
|
149
231
|
setInterval(ping, 10_000);
|
|
150
232
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) ping(); });
|