@bakapiano/ccsm 0.18.5 → 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/lib/devices.js +19 -5
- package/lib/tunnel.js +151 -0
- package/package.json +1 -1
- package/public/css/feedback.css +72 -0
- package/public/css/responsive.css +18 -8
- package/public/css/sidebar.css +11 -5
- package/public/css/widgets.css +714 -1
- package/public/index.html +8 -1
- package/public/js/api.js +23 -7
- package/public/js/backend.js +26 -0
- package/public/js/components/App.js +2 -0
- package/public/js/components/OfflineBanner.js +9 -35
- package/public/js/components/PendingApprovalOverlay.js +44 -2
- package/public/js/components/RestartOverlay.js +36 -0
- package/public/js/components/Sidebar.js +1 -1
- package/public/js/components/TerminalView.js +18 -7
- package/public/js/icons.js +11 -6
- package/public/js/main.js +17 -0
- package/public/js/pages/ConfigurePage.js +43 -25
- package/public/js/pages/RemotePage.js +318 -152
- package/public/js/state.js +6 -0
- package/server.js +24 -2
package/public/index.html
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
|
-
|
|
5
|
+
<!-- maximum-scale=1 stops iOS Safari from auto-zooming the page
|
|
6
|
+
when the user taps a sub-16px input. xterm's helper textarea
|
|
7
|
+
inherits the terminal's own font size (11px on phones); if we
|
|
8
|
+
instead bumped it to 16px via CSS, xterm would measure the
|
|
9
|
+
cell off the inflated textarea and render every glyph ~50%
|
|
10
|
+
oversized. Locking page zoom here lets us leave the textarea
|
|
11
|
+
alone. -->
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
|
6
13
|
<!-- Bleeds the cream surface into the Edge/Chrome --app= title bar
|
|
7
14
|
so it visually disappears against the body. The browser does
|
|
8
15
|
honor this in standalone app windows. -->
|
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
|
-
|
|
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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
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}`);
|
package/public/js/backend.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {
|
|
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
|
-
${
|
|
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}
|
|
@@ -52,7 +52,8 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
52
52
|
// Desktop stays at 13. We re-evaluate on every mount, so a viewport
|
|
53
53
|
// rotation that crosses the breakpoint picks up the new size on
|
|
54
54
|
// next mount (rare; users typically don't rotate mid-session).
|
|
55
|
-
const
|
|
55
|
+
const isMobile = window.matchMedia('(max-width: 640px)').matches;
|
|
56
|
+
const baseFontSize = isMobile ? 11 : 13;
|
|
56
57
|
const term = new Terminal({
|
|
57
58
|
fontFamily: '"Cascadia Mono", "Geist Mono", "JetBrains Mono", Consolas, monospace',
|
|
58
59
|
fontSize: baseFontSize,
|
|
@@ -92,12 +93,22 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
92
93
|
// syntax-highlighted code). WebGL paints onto a canvas, much smoother
|
|
93
94
|
// at thousands-of-cells per frame. Falls back to DOM if WebGL is
|
|
94
95
|
// unavailable (e.g. older GPU, hardware accel disabled).
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
//
|
|
97
|
+
// Skipped on phones: @xterm/addon-webgl@0.18.0 miscalculates the glyph
|
|
98
|
+
// atlas at the fractional DPRs that modern Android handsets report
|
|
99
|
+
// (Pixel 6/7/8 = 2.625, S24 = 2.625, etc.) — every cell ends up
|
|
100
|
+
// rendered ~3× wider than the layout grid says it should, blowing out
|
|
101
|
+
// the terminal. Integer DPRs (1, 2, 3 — desktops, iPhones) and the
|
|
102
|
+
// common Windows 1.5 are fine, so the gate is on the mobile viewport
|
|
103
|
+
// breakpoint, not the raw DPR.
|
|
104
|
+
if (!isMobile) {
|
|
105
|
+
try {
|
|
106
|
+
const webgl = new WebglAddon();
|
|
107
|
+
webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
|
|
108
|
+
term.loadAddon(webgl);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
111
|
+
}
|
|
101
112
|
}
|
|
102
113
|
// Ctrl+C with a selection: by default xterm.js sends \x03 AND the
|
|
103
114
|
// browser's own copy event fires — so the user gets "selection
|
package/public/js/icons.js
CHANGED
|
@@ -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="
|
|
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="
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
<
|
|
142
|
-
<
|
|
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
|
|
504
|
-
<
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
559
|
+
restartInFlight.value = null;
|
|
539
560
|
setToast(e.message, 'error');
|
|
540
561
|
}
|
|
541
562
|
};
|
|
542
563
|
return html`
|
|
543
|
-
<div
|
|
544
|
-
<button class="action"
|
|
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
|
}
|