@bakapiano/ccsm 0.22.3 → 0.22.4
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 +592 -592
- 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 +148 -22
- package/public/js/components/TerminalResizeDebouncer.js +126 -0
- package/public/js/components/XtermTerminal.js +62 -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 +100 -100
- 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
|
@@ -1,743 +1,743 @@
|
|
|
1
|
-
// Remote · expose this backend over a public tunnel URL so the same
|
|
2
|
-
// frontend can be loaded from a phone / another laptop / wherever.
|
|
3
|
-
// All API + WS calls are gated by a token the user sets here; the
|
|
4
|
-
// share URL embeds it (?token=…) so the remote browser captures it
|
|
5
|
-
// on first arrival and stashes it in localStorage.
|
|
6
|
-
//
|
|
7
|
-
// Layout mirrors ConfigurePage: .settings-scroll wrapper → Section →
|
|
8
|
-
// .config-grid → .field rows with label + content. No bespoke cards.
|
|
9
|
-
|
|
10
|
-
import { html } from '../html.js';
|
|
11
|
-
import { useState, useEffect, useRef } from 'preact/hooks';
|
|
12
|
-
import { api } from '../api.js';
|
|
13
|
-
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
14
|
-
import { setToast } from '../toast.js';
|
|
15
|
-
import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
|
|
16
|
-
import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose, IconCloudflareColor, IconMicrosoftColor } from '../icons.js';
|
|
17
|
-
import { fmtAgo } from '../util.js';
|
|
18
|
-
import { clockTick } from '../state.js';
|
|
19
|
-
|
|
20
|
-
function genToken() {
|
|
21
|
-
const a = new Uint8Array(18);
|
|
22
|
-
crypto.getRandomValues(a);
|
|
23
|
-
let s = '';
|
|
24
|
-
for (const b of a) s += String.fromCharCode(b);
|
|
25
|
-
return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function copy(text) {
|
|
29
|
-
try {
|
|
30
|
-
await navigator.clipboard.writeText(text);
|
|
31
|
-
setToast('Copied', 'ok');
|
|
32
|
-
} catch {
|
|
33
|
-
setToast('Copy failed · select + Ctrl+C', 'error');
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function shareUrl(tunnelUrl, token) {
|
|
38
|
-
if (!tunnelUrl || !token) return '';
|
|
39
|
-
try {
|
|
40
|
-
const u = new URL(tunnelUrl);
|
|
41
|
-
u.searchParams.set('token', token);
|
|
42
|
-
return u.toString();
|
|
43
|
-
} catch { return ''; }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function Section({ title, meta, children }) {
|
|
47
|
-
return html`
|
|
48
|
-
<section class="settings-section">
|
|
49
|
-
<header class="settings-section-head">
|
|
50
|
-
<h2 class="settings-section-title">${title}</h2>
|
|
51
|
-
${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
|
|
52
|
-
</header>
|
|
53
|
-
<div class="settings-section-body">${children}</div>
|
|
54
|
-
</section>`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete }) {
|
|
58
|
-
const lastSeen = d.lastSeen ? fmtAgo(d.lastSeen) : '—';
|
|
59
|
-
const ipShort = d.ip ? d.ip.split(',')[0].trim() : null;
|
|
60
|
-
return html`
|
|
61
|
-
<div class=${`remote-device is-${kind}`}>
|
|
62
|
-
<div class="remote-device-main">
|
|
63
|
-
<div class="remote-device-label">
|
|
64
|
-
${d.code ? html`<code class="remote-device-code" title="Match this with the code shown on the requesting device">${d.code}</code>` : null}
|
|
65
|
-
<span class="remote-device-name">${d.label || 'Unknown device'}</span>
|
|
66
|
-
${kind === 'approved' ? html`
|
|
67
|
-
<button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
|
|
68
|
-
` : null}
|
|
69
|
-
</div>
|
|
70
|
-
<div class="remote-device-meta">
|
|
71
|
-
${ipShort ? html`<span class="mono">${ipShort}</span> · ` : null}
|
|
72
|
-
<span>seen ${lastSeen}</span>
|
|
73
|
-
${d.userAgent ? html` · <span class="remote-device-ua" title=${d.userAgent}>${d.userAgent.slice(0, 60)}${d.userAgent.length > 60 ? '…' : ''}</span>` : null}
|
|
74
|
-
</div>
|
|
75
|
-
</div>
|
|
76
|
-
<div class="remote-device-actions">
|
|
77
|
-
${kind === 'pending' ? html`
|
|
78
|
-
<button class="action primary small" onClick=${onApprove}>Approve</button>
|
|
79
|
-
<button class="action subtle small" onClick=${onReject}>Reject</button>
|
|
80
|
-
` : null}
|
|
81
|
-
${kind === 'approved' ? html`
|
|
82
|
-
<button class="action subtle danger small" onClick=${onRevoke}><${IconClose} /> Revoke</button>
|
|
83
|
-
` : null}
|
|
84
|
-
${kind === 'rejected' ? html`
|
|
85
|
-
<button class="action subtle small" onClick=${onApprove}>Re-approve</button>
|
|
86
|
-
<button class="action subtle danger small" onClick=${onDelete}><${IconClose} /> Delete</button>
|
|
87
|
-
` : null}
|
|
88
|
-
</div>
|
|
89
|
-
</div>`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
|
|
93
|
-
return html`
|
|
94
|
-
<button type="button"
|
|
95
|
-
class=${`provider-tile${selected ? ' is-selected' : ''}${disabled ? ' is-disabled' : ''}`}
|
|
96
|
-
aria-pressed=${selected ? 'true' : 'false'}
|
|
97
|
-
disabled=${disabled}
|
|
98
|
-
onClick=${() => !disabled && onSelect(id)}>
|
|
99
|
-
<span class="provider-tile-icon">${icon}</span>
|
|
100
|
-
<span class="provider-tile-body">
|
|
101
|
-
<span class="provider-tile-label">${label}</span>
|
|
102
|
-
${hint ? html`<span class="provider-tile-hint">${hint}</span>` : null}
|
|
103
|
-
</span>
|
|
104
|
-
</button>`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Tiny inline row shown under the signed-in Microsoft Dev Tunnel
|
|
108
|
-
// status. Displays the persisted (named) tunnel id ccsm reuses across
|
|
109
|
-
// restarts so the public URL stays stable — and lets the user rotate
|
|
110
|
-
// it on demand. Reset requires the tunnel to be stopped first; the
|
|
111
|
-
// server-side route also enforces this.
|
|
112
|
-
function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
|
|
113
|
-
if (!tunnelId) {
|
|
114
|
-
return html`
|
|
115
|
-
<div class="tunnel-id-row is-empty">
|
|
116
|
-
<span class="tunnel-id-label">Tunnel id</span>
|
|
117
|
-
<span class="tunnel-id-value-empty">none yet · minted on next Start</span>
|
|
118
|
-
</div>`;
|
|
119
|
-
}
|
|
120
|
-
return html`
|
|
121
|
-
<div class="tunnel-id-row">
|
|
122
|
-
<span class="tunnel-id-label">Tunnel id</span>
|
|
123
|
-
<code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
|
|
124
|
-
<button type="button" class="action subtle small tunnel-id-reset"
|
|
125
|
-
disabled=${running}
|
|
126
|
-
title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
|
|
127
|
-
onClick=${onReset}>
|
|
128
|
-
<${IconRecycle} /> Reset
|
|
129
|
-
</button>
|
|
130
|
-
</div>`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
|
|
134
|
-
if (!info) return html`<span class="provider-status-muted">probing…</span>`;
|
|
135
|
-
if (!info.installed) {
|
|
136
|
-
return html`
|
|
137
|
-
<div class="provider-status">
|
|
138
|
-
<span class="provider-status-state is-warn">
|
|
139
|
-
<span class="provider-status-dot is-warn"></span> Not installed
|
|
140
|
-
</span>
|
|
141
|
-
<button type="button" class="action small" onClick=${onInstall}>
|
|
142
|
-
Install via winget
|
|
143
|
-
</button>
|
|
144
|
-
</div>`;
|
|
145
|
-
}
|
|
146
|
-
if (id !== 'devtunnel') {
|
|
147
|
-
// Cloudflare quick tunnel · no account state, just version.
|
|
148
|
-
return html`
|
|
149
|
-
<div class="provider-status">
|
|
150
|
-
<span class="provider-status-state is-ok">
|
|
151
|
-
<span class="provider-status-dot is-ok"></span> Ready · anonymous
|
|
152
|
-
</span>
|
|
153
|
-
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
154
|
-
</div>`;
|
|
155
|
-
}
|
|
156
|
-
// devtunnel · signed-in / signed-out states each get their own row.
|
|
157
|
-
if (!info.loggedIn) {
|
|
158
|
-
// While a sign-in flow is in flight the signin-card below this
|
|
159
|
-
// row carries its own header + spinner + cancel button. Showing
|
|
160
|
-
// a second "Signing in…" CTA here is just noise — collapse the
|
|
161
|
-
// whole signed-out block down to a thin status line until the
|
|
162
|
-
// card resolves one way or the other.
|
|
163
|
-
if (loggingIn) {
|
|
164
|
-
return html`
|
|
165
|
-
<div class="provider-status">
|
|
166
|
-
<span class="provider-status-state">
|
|
167
|
-
<span class="provider-status-dot"></span> Signing in…
|
|
168
|
-
</span>
|
|
169
|
-
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
170
|
-
</div>`;
|
|
171
|
-
}
|
|
172
|
-
return html`
|
|
173
|
-
<div class="provider-status">
|
|
174
|
-
<span class="provider-status-state is-warn">
|
|
175
|
-
<span class="provider-status-dot is-warn"></span> Not signed in
|
|
176
|
-
</span>
|
|
177
|
-
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
178
|
-
<button type="button" class="btn-signin-microsoft provider-status-signin" onClick=${onLogin}>
|
|
179
|
-
<${IconMicrosoftColor} size=${18} />
|
|
180
|
-
<span>Sign in with Microsoft</span>
|
|
181
|
-
</button>
|
|
182
|
-
</div>`;
|
|
183
|
-
}
|
|
184
|
-
return html`
|
|
185
|
-
<div class="provider-status">
|
|
186
|
-
<span class="provider-status-state is-ok">
|
|
187
|
-
<span class="provider-status-dot is-ok"></span> Signed in
|
|
188
|
-
</span>
|
|
189
|
-
<span class="provider-status-user">${info.user}</span>
|
|
190
|
-
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
191
|
-
<button type="button" class="action subtle small provider-status-switch" onClick=${onLogin}>
|
|
192
|
-
Switch
|
|
193
|
-
</button>
|
|
194
|
-
</div>`;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Device-code login panel. Shown when a `devtunnel user login -d` flow
|
|
198
|
-
// is in flight or just finished. The user clicks Open, signs in on
|
|
199
|
-
// microsoft.com, and we flip to "Signed in" automatically when the
|
|
200
|
-
// child exits 0 (the probe cache gets invalidated on exit).
|
|
201
|
-
function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
|
|
202
|
-
if (!login) return null;
|
|
203
|
-
const { status, url, code, error, user, lines } = login;
|
|
204
|
-
const running = status === 'running';
|
|
205
|
-
const done = status === 'done';
|
|
206
|
-
const failed = status === 'error';
|
|
207
|
-
const canceled = status === 'canceled';
|
|
208
|
-
const host = (() => { try { return new URL(url).host; } catch { return url || ''; } })();
|
|
209
|
-
return html`
|
|
210
|
-
<div class=${`signin-card is-${status}`}>
|
|
211
|
-
${running ? html`
|
|
212
|
-
<div class="signin-card-header">
|
|
213
|
-
<span class="signin-card-spinner" aria-hidden="true"></span>
|
|
214
|
-
<span class="signin-card-eyebrow">Signing in to Microsoft</span>
|
|
215
|
-
<button type="button" class="signin-card-cancel" onClick=${onCancel} title="Cancel sign-in">
|
|
216
|
-
<${IconClose} /> Cancel
|
|
217
|
-
</button>
|
|
218
|
-
</div>
|
|
219
|
-
<div class="signin-card-code-block">
|
|
220
|
-
<span class="signin-card-code-label">Device code</span>
|
|
221
|
-
<div class="signin-card-code-row">
|
|
222
|
-
${code ? html`
|
|
223
|
-
<code class="signin-card-code">${code}</code>
|
|
224
|
-
<button type="button" class="action subtle small signin-card-code-copy"
|
|
225
|
-
title="Copy code" onClick=${() => copy(code)}>
|
|
226
|
-
<${IconCopy} />
|
|
227
|
-
</button>
|
|
228
|
-
` : html`<span class="signin-card-code-pending">generating…</span>`}
|
|
229
|
-
</div>
|
|
230
|
-
</div>
|
|
231
|
-
<ol class="signin-card-steps">
|
|
232
|
-
<li>
|
|
233
|
-
${url ? html`
|
|
234
|
-
<a class="signin-card-open" href=${url} target="_blank" rel="noreferrer noopener">
|
|
235
|
-
<${IconExternal} /> Open <span class="signin-card-host">${host}</span>
|
|
236
|
-
</a>
|
|
237
|
-
` : html`<span class="signin-card-step-muted">Waiting for sign-in URL…</span>`}
|
|
238
|
-
</li>
|
|
239
|
-
<li>Paste the device code shown above.</li>
|
|
240
|
-
<li>Pick an account and approve — this page flips automatically.</li>
|
|
241
|
-
</ol>
|
|
242
|
-
` : null}
|
|
243
|
-
${done ? html`
|
|
244
|
-
<div class="signin-card-result is-ok">
|
|
245
|
-
<span class="signin-card-result-icon" aria-hidden="true">✓</span>
|
|
246
|
-
<div class="signin-card-result-body">
|
|
247
|
-
<div class="signin-card-result-title">Signed in</div>
|
|
248
|
-
<div class="signin-card-result-meta">
|
|
249
|
-
${user ? html`as <code>${user}</code> · ` : ''}you can start the tunnel now.
|
|
250
|
-
</div>
|
|
251
|
-
</div>
|
|
252
|
-
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
253
|
-
</div>
|
|
254
|
-
` : null}
|
|
255
|
-
${failed ? html`
|
|
256
|
-
<div class="signin-card-result is-error">
|
|
257
|
-
<span class="signin-card-result-icon" aria-hidden="true">!</span>
|
|
258
|
-
<div class="signin-card-result-body">
|
|
259
|
-
<div class="signin-card-result-title">Sign-in failed</div>
|
|
260
|
-
<div class="signin-card-result-meta">${error || 'devtunnel exited with an error.'}</div>
|
|
261
|
-
</div>
|
|
262
|
-
<div class="signin-card-result-actions">
|
|
263
|
-
<button type="button" class="action small" onClick=${onRetry}>Try again</button>
|
|
264
|
-
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
265
|
-
</div>
|
|
266
|
-
</div>
|
|
267
|
-
` : null}
|
|
268
|
-
${canceled ? html`
|
|
269
|
-
<div class="signin-card-result is-muted">
|
|
270
|
-
<div class="signin-card-result-body">
|
|
271
|
-
<div class="signin-card-result-title">Sign-in canceled</div>
|
|
272
|
-
</div>
|
|
273
|
-
<div class="signin-card-result-actions">
|
|
274
|
-
<button type="button" class="action small" onClick=${onRetry}>Try again</button>
|
|
275
|
-
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
276
|
-
</div>
|
|
277
|
-
</div>
|
|
278
|
-
` : null}
|
|
279
|
-
${lines && lines.length ? html`
|
|
280
|
-
<details class="signin-card-log">
|
|
281
|
-
<summary>CLI output · ${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</summary>
|
|
282
|
-
<pre>${lines.join('\n')}</pre>
|
|
283
|
-
</details>
|
|
284
|
-
` : null}
|
|
285
|
-
</div>`;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function RemotePage() {
|
|
289
|
-
clockTick.value; // re-tick fmtAgo "last seen" labels
|
|
290
|
-
// Hydrate from a localStorage cache so the page renders the same
|
|
291
|
-
// shape it had at the end of the previous visit — provider tiles,
|
|
292
|
-
// signed-in state, tunnel id, share URL — instead of empty / placeholder
|
|
293
|
-
// chrome that fills in after the slow /api/tunnel/status round-trip
|
|
294
|
-
// (700ms+ on a cold probe). The cached snapshot is overwritten by
|
|
295
|
-
// refresh() the moment the live response lands.
|
|
296
|
-
const cachedStatus = (() => {
|
|
297
|
-
try {
|
|
298
|
-
const raw = localStorage.getItem('ccsm.remote-status-cache');
|
|
299
|
-
return raw ? JSON.parse(raw) : null;
|
|
300
|
-
} catch { return null; }
|
|
301
|
-
})();
|
|
302
|
-
const [status, setStatus] = useState(cachedStatus);
|
|
303
|
-
const [provider, setProvider] = useState(() => {
|
|
304
|
-
if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
|
|
305
|
-
if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
|
|
306
|
-
if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
|
|
307
|
-
return 'devtunnel';
|
|
308
|
-
});
|
|
309
|
-
const [token, setTokenLocal] = useState(cachedStatus?.token || '');
|
|
310
|
-
const [busy, setBusy] = useState(false);
|
|
311
|
-
const [deviceList, setDeviceList] = useState([]);
|
|
312
|
-
const pollRef = useRef(null);
|
|
313
|
-
|
|
314
|
-
// Tunnel status and the device list are fetched INDEPENDENTLY, not as a
|
|
315
|
-
// bundled Promise.all. /api/tunnel/status can lag behind a cold provider
|
|
316
|
-
// probe; the device list is cheap. Coupling them made the (fast) device
|
|
317
|
-
// list wait on the (slow) status round-trip, so the whole page appeared
|
|
318
|
-
// to refresh in one delayed lump. Now each updates its own state the
|
|
319
|
-
// moment its own fetch lands.
|
|
320
|
-
async function refreshStatus() {
|
|
321
|
-
try {
|
|
322
|
-
const s = await api('GET', '/api/tunnel/status');
|
|
323
|
-
setStatus(s);
|
|
324
|
-
setTokenLocal((cur) => cur || s.token || '');
|
|
325
|
-
setProvider((cur) => {
|
|
326
|
-
if (s.running && s.provider) return s.provider;
|
|
327
|
-
if (cur) return cur;
|
|
328
|
-
if (s.providers?.devtunnel?.installed) return 'devtunnel';
|
|
329
|
-
if (s.providers?.cloudflared?.installed) return 'cloudflared';
|
|
330
|
-
return cur || 'devtunnel';
|
|
331
|
-
});
|
|
332
|
-
// Snapshot for the next mount. Skip the per-call `log` so the
|
|
333
|
-
// cache stays small.
|
|
334
|
-
try {
|
|
335
|
-
localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
|
|
336
|
-
...s, log: undefined,
|
|
337
|
-
}));
|
|
338
|
-
} catch {}
|
|
339
|
-
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
340
|
-
}
|
|
341
|
-
async function refreshDevices() {
|
|
342
|
-
try {
|
|
343
|
-
const devs = await api('GET', '/api/devices');
|
|
344
|
-
setDeviceList(devs.devices || []);
|
|
345
|
-
} catch { /* non-critical — keep the last good list on a transient error */ }
|
|
346
|
-
}
|
|
347
|
-
function refresh() { refreshStatus(); refreshDevices(); }
|
|
348
|
-
|
|
349
|
-
useEffect(() => {
|
|
350
|
-
refresh();
|
|
351
|
-
pollRef.current = setInterval(refresh, 2500);
|
|
352
|
-
return () => clearInterval(pollRef.current);
|
|
353
|
-
}, []);
|
|
354
|
-
|
|
355
|
-
async function onApproveDevice(id) {
|
|
356
|
-
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
|
|
357
|
-
catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
|
|
358
|
-
}
|
|
359
|
-
async function onRejectDevice(id) {
|
|
360
|
-
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
|
|
361
|
-
catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
|
|
362
|
-
}
|
|
363
|
-
async function onDeleteDevice(d) {
|
|
364
|
-
const ok = await ccsmConfirm(
|
|
365
|
-
`Forget "${d.label || d.id}"? The device disappears from this list. If it ever tries again it'll come back as a fresh pending request.`,
|
|
366
|
-
{ title: 'Delete device record', okLabel: 'Delete', danger: true },
|
|
367
|
-
);
|
|
368
|
-
if (!ok) return;
|
|
369
|
-
try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
|
|
370
|
-
catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
|
|
371
|
-
}
|
|
372
|
-
async function onRevokeDevice(d) {
|
|
373
|
-
const ok = await ccsmConfirm(`Revoke access for "${d.label || d.id}"? Any open tabs lose access immediately.`, {
|
|
374
|
-
title: 'Revoke device', okLabel: 'Revoke', danger: true,
|
|
375
|
-
});
|
|
376
|
-
if (!ok) return;
|
|
377
|
-
try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
|
|
378
|
-
catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
|
|
379
|
-
}
|
|
380
|
-
async function onRenameDevice(d) {
|
|
381
|
-
const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
|
|
382
|
-
if (next === null) return;
|
|
383
|
-
try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
|
|
384
|
-
catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async function onStart() {
|
|
388
|
-
setBusy(true);
|
|
389
|
-
try {
|
|
390
|
-
// Auto-mint a token if the user hasn't generated one yet — the
|
|
391
|
-
// registration token is now an implementation detail of starting
|
|
392
|
-
// a tunnel rather than a separate setup step.
|
|
393
|
-
let tok = token;
|
|
394
|
-
if (!tok || tok.length < 8) {
|
|
395
|
-
tok = genToken();
|
|
396
|
-
setTokenLocal(tok);
|
|
397
|
-
try { await api('POST', '/api/tunnel/token', { token: tok }); }
|
|
398
|
-
catch (e) { /* the start call below will fail too — surface that */ }
|
|
399
|
-
}
|
|
400
|
-
const s = await api('POST', '/api/tunnel/start', { provider, token: tok });
|
|
401
|
-
setStatus(s);
|
|
402
|
-
setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
|
|
403
|
-
} catch (e) {
|
|
404
|
-
setToast(`start failed · ${e.message}`, 'error');
|
|
405
|
-
} finally { setBusy(false); }
|
|
406
|
-
}
|
|
407
|
-
async function onStop() {
|
|
408
|
-
setBusy(true);
|
|
409
|
-
try {
|
|
410
|
-
const s = await api('POST', '/api/tunnel/stop');
|
|
411
|
-
setStatus(s);
|
|
412
|
-
setToast('Tunnel stopped', 'ok');
|
|
413
|
-
} catch (e) { setToast(`stop failed · ${e.message}`, 'error'); }
|
|
414
|
-
finally { setBusy(false); }
|
|
415
|
-
}
|
|
416
|
-
// Generate is the only path that mutates the token now — local React
|
|
417
|
-
// state and the server's stored token stay in lockstep, so the Share
|
|
418
|
-
// URL preview always embeds a token the server will accept. (The
|
|
419
|
-
// previous design had a separate Save step; users would Generate +
|
|
420
|
-
// copy the URL without saving, then the remote would 401 because
|
|
421
|
-
// its embedded token didn't match what the server still had.)
|
|
422
|
-
async function onGenerateToken() {
|
|
423
|
-
const fresh = genToken();
|
|
424
|
-
setTokenLocal(fresh);
|
|
425
|
-
try {
|
|
426
|
-
// When auto-start is on the token must be PERSISTED, else the
|
|
427
|
-
// rotated token is lost on the next backend restart and every
|
|
428
|
-
// share URL built from it 401s. Route through the persisting
|
|
429
|
-
// endpoint in that case; otherwise the in-memory-only token
|
|
430
|
-
// endpoint is enough.
|
|
431
|
-
const s = status?.autoStart
|
|
432
|
-
? await api('POST', '/api/tunnel/autostart', { autoStart: true, provider, token: fresh })
|
|
433
|
-
: await api('POST', '/api/tunnel/token', { token: fresh });
|
|
434
|
-
setStatus(s);
|
|
435
|
-
setToast('New token in effect', 'ok');
|
|
436
|
-
} catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
|
|
437
|
-
}
|
|
438
|
-
// Persist (or clear) the auto-start preference. On enable with no
|
|
439
|
-
// token yet, mint one first so the backend has something to reuse on
|
|
440
|
-
// its next startup. Approved devices keep working regardless of the
|
|
441
|
-
// token — it only gates NEW device registration.
|
|
442
|
-
async function onToggleAutoStart(next) {
|
|
443
|
-
setBusy(true);
|
|
444
|
-
try {
|
|
445
|
-
let tok = token;
|
|
446
|
-
if (next && (!tok || tok.length < 8)) { tok = genToken(); setTokenLocal(tok); }
|
|
447
|
-
const s = await api('POST', '/api/tunnel/autostart',
|
|
448
|
-
next ? { autoStart: true, provider, token: tok } : { autoStart: false });
|
|
449
|
-
setStatus(s);
|
|
450
|
-
setToast(next ? 'Auto-start on · tunnel comes up when ccsm starts' : 'Auto-start off', 'ok');
|
|
451
|
-
} catch (e) {
|
|
452
|
-
setToast(`auto-start ${next ? 'enable' : 'disable'} failed · ${e.message}`, 'error');
|
|
453
|
-
} finally { setBusy(false); }
|
|
454
|
-
}
|
|
455
|
-
async function onInstall(p) {
|
|
456
|
-
const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
|
|
457
|
-
title: 'Install tunnel provider', okLabel: 'Install',
|
|
458
|
-
});
|
|
459
|
-
if (!ok) return;
|
|
460
|
-
try {
|
|
461
|
-
await api('POST', '/api/tunnel/install', { provider: p });
|
|
462
|
-
setToast(`${p} install running in background · check back in a minute`, 'ok');
|
|
463
|
-
} catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
|
|
464
|
-
}
|
|
465
|
-
function onLogin(p) {
|
|
466
|
-
if (p !== 'devtunnel') return;
|
|
467
|
-
// Kick off `devtunnel user login -d` on the host and let the
|
|
468
|
-
// panel below render the device code + URL. /status polling
|
|
469
|
-
// (every 2.5s) picks up the eventual outcome.
|
|
470
|
-
(async () => {
|
|
471
|
-
try {
|
|
472
|
-
const r = await api('POST', '/api/tunnel/devtunnel/login', { mode: 'microsoft' });
|
|
473
|
-
if (r?.login) setStatus((cur) => cur ? { ...cur, login: r.login } : cur);
|
|
474
|
-
refresh();
|
|
475
|
-
} catch (e) { setToast(`sign-in failed · ${e.message}`, 'error'); }
|
|
476
|
-
})();
|
|
477
|
-
}
|
|
478
|
-
async function onLoginCancel() {
|
|
479
|
-
try { await api('POST', '/api/tunnel/devtunnel/login/cancel'); refresh(); }
|
|
480
|
-
catch (e) { setToast(`cancel failed · ${e.message}`, 'error'); }
|
|
481
|
-
}
|
|
482
|
-
async function onLoginDismiss() {
|
|
483
|
-
try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
|
|
484
|
-
catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
|
|
485
|
-
}
|
|
486
|
-
async function onResetDevtunnelId() {
|
|
487
|
-
const ok = await ccsmConfirm(
|
|
488
|
-
`Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
|
|
489
|
-
{ title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
|
|
490
|
-
);
|
|
491
|
-
if (!ok) return;
|
|
492
|
-
try {
|
|
493
|
-
await api('POST', '/api/tunnel/devtunnel/reset');
|
|
494
|
-
refresh();
|
|
495
|
-
setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
|
|
496
|
-
} catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
const running = status?.running;
|
|
500
|
-
const url = status?.url;
|
|
501
|
-
const share = shareUrl(url, token);
|
|
502
|
-
const log = status?.log || [];
|
|
503
|
-
const cf = status?.providers?.cloudflared;
|
|
504
|
-
const dt = status?.providers?.devtunnel;
|
|
505
|
-
const dtLogin = status?.login || null;
|
|
506
|
-
const dtLoggingIn = dtLogin?.status === 'running';
|
|
507
|
-
// First /api/tunnel/status round-trip is the slow one — even with
|
|
508
|
-
// the 30s server-side cache + parallel probe, a cold call shells
|
|
509
|
-
// out and adds ~700ms. We render the full page immediately and let
|
|
510
|
-
// individual fields show their own "probing…" state instead of
|
|
511
|
-
// gating the whole panel behind a centered spinner.
|
|
512
|
-
|
|
513
|
-
return html`
|
|
514
|
-
<${PageTitleBar} title="Remote" />
|
|
515
|
-
<div class="settings-scroll">
|
|
516
|
-
|
|
517
|
-
<${Section}
|
|
518
|
-
title="Connection"
|
|
519
|
-
meta=${html`Pick which CLI ccsm spawns for the tunnel.`}>
|
|
520
|
-
<div class="config-grid">
|
|
521
|
-
<div class="field">
|
|
522
|
-
<span class="label">Provider</span>
|
|
523
|
-
<div class="provider-tile-row">
|
|
524
|
-
<${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
|
|
525
|
-
hint="Requires sign-in"
|
|
526
|
-
icon=${html`<${IconMicrosoftColor} size=${32} />`}
|
|
527
|
-
selected=${provider === 'devtunnel'}
|
|
528
|
-
disabled=${running}
|
|
529
|
-
onSelect=${setProvider} />
|
|
530
|
-
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
531
|
-
hint="Anonymous · no login"
|
|
532
|
-
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
533
|
-
selected=${provider === 'cloudflared'}
|
|
534
|
-
disabled=${running}
|
|
535
|
-
onSelect=${setProvider} />
|
|
536
|
-
</div>
|
|
537
|
-
${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
|
|
538
|
-
</div>
|
|
539
|
-
${provider === 'devtunnel' ? html`
|
|
540
|
-
<div class="field">
|
|
541
|
-
<span class="label">Microsoft Dev Tunnel</span>
|
|
542
|
-
<div class="remote-status-line">
|
|
543
|
-
<${ProviderStatus} id="devtunnel" info=${dt}
|
|
544
|
-
onInstall=${() => onInstall('devtunnel')}
|
|
545
|
-
onLogin=${() => onLogin('devtunnel')}
|
|
546
|
-
loggingIn=${dtLoggingIn} />
|
|
547
|
-
</div>
|
|
548
|
-
${dtLogin ? html`
|
|
549
|
-
<${DevtunnelLoginPanel}
|
|
550
|
-
login=${dtLogin}
|
|
551
|
-
onCancel=${onLoginCancel}
|
|
552
|
-
onDismiss=${onLoginDismiss}
|
|
553
|
-
onRetry=${() => onLogin('devtunnel')} />
|
|
554
|
-
` : null}
|
|
555
|
-
${dt?.loggedIn ? html`
|
|
556
|
-
<${DevtunnelTunnelIdRow}
|
|
557
|
-
tunnelId=${status?.tunnelId}
|
|
558
|
-
running=${running && status?.provider === 'devtunnel'}
|
|
559
|
-
onReset=${onResetDevtunnelId} />
|
|
560
|
-
` : null}
|
|
561
|
-
</div>
|
|
562
|
-
` : null}
|
|
563
|
-
${provider === 'cloudflared' ? html`
|
|
564
|
-
<div class="field">
|
|
565
|
-
<span class="label">Cloudflare Tunnel</span>
|
|
566
|
-
<div class="remote-status-line">
|
|
567
|
-
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
568
|
-
onInstall=${() => onInstall('cloudflared')} />
|
|
569
|
-
</div>
|
|
570
|
-
</div>
|
|
571
|
-
` : null}
|
|
572
|
-
</div>
|
|
573
|
-
</${Section}>
|
|
574
|
-
|
|
575
|
-
<${Section}
|
|
576
|
-
title="Tunnel"
|
|
577
|
-
meta=${running
|
|
578
|
-
? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
|
|
579
|
-
: html`Not running.`}>
|
|
580
|
-
<div class="tunnel-autostart">
|
|
581
|
-
<label class="tunnel-autostart-row">
|
|
582
|
-
<input type="checkbox" checked=${!!status?.autoStart} disabled=${busy}
|
|
583
|
-
onChange=${(e) => onToggleAutoStart(e.target.checked)} />
|
|
584
|
-
<span class="tunnel-autostart-label">Start this tunnel automatically when ccsm starts</span>
|
|
585
|
-
</label>
|
|
586
|
-
${status?.autoStart && provider === 'cloudflared' ? html`
|
|
587
|
-
<span class="hint tunnel-autostart-hint">
|
|
588
|
-
Cloudflare quick tunnels get a new URL each launch — the share URL will change on
|
|
589
|
-
restart and approved devices must re-register. Use Microsoft Dev Tunnel for a stable URL.
|
|
590
|
-
</span>` : null}
|
|
591
|
-
</div>
|
|
592
|
-
${!running ? html`
|
|
593
|
-
<div class="tunnel-hero">
|
|
594
|
-
<div class="tunnel-hero-body">
|
|
595
|
-
<div class="tunnel-hero-title">Bring this backend online</div>
|
|
596
|
-
<div class="tunnel-hero-meta">
|
|
597
|
-
ccsm will spawn
|
|
598
|
-
<code>${provider === 'devtunnel' ? 'devtunnel' : 'cloudflared'}</code>
|
|
599
|
-
and wait for it to print a public URL.
|
|
600
|
-
</div>
|
|
601
|
-
</div>
|
|
602
|
-
<button type="button" class="action tunnel-hero-cta"
|
|
603
|
-
disabled=${busy}
|
|
604
|
-
onClick=${onStart}>
|
|
605
|
-
<${IconExternal} /> ${busy ? 'Starting…' : 'Start tunnel'}
|
|
606
|
-
</button>
|
|
607
|
-
</div>
|
|
608
|
-
` : html`
|
|
609
|
-
<div class="tunnel-live">
|
|
610
|
-
<div class="tunnel-live-head">
|
|
611
|
-
<span class="tunnel-live-state">
|
|
612
|
-
<span class="tunnel-live-dot"></span>
|
|
613
|
-
Live
|
|
614
|
-
</span>
|
|
615
|
-
<span class="tunnel-live-divider">·</span>
|
|
616
|
-
<span class="tunnel-live-provider">${status?.provider === 'devtunnel' ? 'Microsoft Dev Tunnel' : 'Cloudflare Tunnel'}</span>
|
|
617
|
-
<span class="tunnel-live-divider">·</span>
|
|
618
|
-
<span class="tunnel-live-meta">since ${new Date(status.startedAt).toLocaleTimeString()}</span>
|
|
619
|
-
<button type="button" class="tunnel-stop-link"
|
|
620
|
-
disabled=${busy}
|
|
621
|
-
onClick=${onStop}>
|
|
622
|
-
<${IconClose} /> ${busy ? 'Stopping…' : 'Stop tunnel'}
|
|
623
|
-
</button>
|
|
624
|
-
</div>
|
|
625
|
-
${url ? html`
|
|
626
|
-
<div class="tunnel-share">
|
|
627
|
-
<div class="tunnel-share-label">Share URL</div>
|
|
628
|
-
<div class="tunnel-share-url">
|
|
629
|
-
<code class="tunnel-share-value">${share}</code>
|
|
630
|
-
<div class="tunnel-share-actions">
|
|
631
|
-
<button type="button" class="action small" onClick=${() => copy(share)}>
|
|
632
|
-
<${IconCopy} /> Copy
|
|
633
|
-
</button>
|
|
634
|
-
<a class="action small" href=${share} target="_blank" rel="noreferrer noopener">
|
|
635
|
-
<${IconExternal} /> Open
|
|
636
|
-
</a>
|
|
637
|
-
</div>
|
|
638
|
-
</div>
|
|
639
|
-
<div class="tunnel-share-hint">
|
|
640
|
-
Send this to the remote device · token embedded, stripped from the URL on first arrival.
|
|
641
|
-
</div>
|
|
642
|
-
</div>
|
|
643
|
-
` : html`
|
|
644
|
-
<div class="tunnel-share is-waiting">
|
|
645
|
-
<div class="signin-card-spinner" aria-hidden="true"></div>
|
|
646
|
-
<span>Waiting for the CLI to print a public URL…</span>
|
|
647
|
-
</div>
|
|
648
|
-
`}
|
|
649
|
-
${log.length ? html`
|
|
650
|
-
<details class="remote-log tunnel-log">
|
|
651
|
-
<summary>CLI log · ${log.length} lines</summary>
|
|
652
|
-
<pre>${log.join('\n')}</pre>
|
|
653
|
-
</details>
|
|
654
|
-
` : null}
|
|
655
|
-
</div>
|
|
656
|
-
`}
|
|
657
|
-
</${Section}>
|
|
658
|
-
|
|
659
|
-
<${Section}
|
|
660
|
-
title="Registration token"
|
|
661
|
-
meta=${html`Auto-generated. Only used to register new devices — approved devices keep working after a rotate.`}>
|
|
662
|
-
<div class="config-grid">
|
|
663
|
-
<div class="field">
|
|
664
|
-
<span class="label">Token</span>
|
|
665
|
-
<div class="remote-token-row">
|
|
666
|
-
<input type="text" class="input remote-token-input"
|
|
667
|
-
readonly
|
|
668
|
-
placeholder="auto-generated on first Start tunnel"
|
|
669
|
-
value=${token} />
|
|
670
|
-
<button type="button" class="action" title="Mint a fresh token (invalidates outstanding share URLs)"
|
|
671
|
-
onClick=${onGenerateToken}>
|
|
672
|
-
<${IconRecycle} /> ${token ? 'Rotate' : 'Generate'}
|
|
673
|
-
</button>
|
|
674
|
-
<button type="button" class="action"
|
|
675
|
-
disabled=${!token}
|
|
676
|
-
onClick=${() => copy(token)}>
|
|
677
|
-
<${IconCopy} /> Copy
|
|
678
|
-
</button>
|
|
679
|
-
</div>
|
|
680
|
-
<span class="hint">
|
|
681
|
-
${(!status?.token && !token)
|
|
682
|
-
? html`No token yet — one is minted automatically the first time you start a tunnel.`
|
|
683
|
-
: html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
|
|
684
|
-
</span>
|
|
685
|
-
</div>
|
|
686
|
-
</div>
|
|
687
|
-
</${Section}>
|
|
688
|
-
|
|
689
|
-
<${Section}
|
|
690
|
-
title="Devices"
|
|
691
|
-
meta=${html`Approve each new device once.`}>
|
|
692
|
-
${(() => {
|
|
693
|
-
const pending = deviceList.filter((d) => d.status === 'pending');
|
|
694
|
-
const approved = deviceList.filter((d) => d.status === 'approved');
|
|
695
|
-
const rejected = deviceList.filter((d) => d.status === 'rejected');
|
|
696
|
-
if (!deviceList.length) {
|
|
697
|
-
return html`<p class="remote-empty">No devices yet. Send the share URL to a phone or another laptop to add the first one.</p>`;
|
|
698
|
-
}
|
|
699
|
-
return html`
|
|
700
|
-
<div class="remote-devices">
|
|
701
|
-
${pending.length ? html`
|
|
702
|
-
<div class="remote-devices-group">
|
|
703
|
-
<div class="remote-devices-group-head">
|
|
704
|
-
<span class="remote-devices-group-title">Pending approval</span>
|
|
705
|
-
<span class="remote-devices-group-count">${pending.length}</span>
|
|
706
|
-
</div>
|
|
707
|
-
${pending.map((d) => html`<${DeviceRow}
|
|
708
|
-
key=${d.id} d=${d} kind="pending"
|
|
709
|
-
onApprove=${() => onApproveDevice(d.id)}
|
|
710
|
-
onReject=${() => onRejectDevice(d.id)} />`)}
|
|
711
|
-
</div>
|
|
712
|
-
` : null}
|
|
713
|
-
${approved.length ? html`
|
|
714
|
-
<div class="remote-devices-group">
|
|
715
|
-
<div class="remote-devices-group-head">
|
|
716
|
-
<span class="remote-devices-group-title">Approved</span>
|
|
717
|
-
<span class="remote-devices-group-count">${approved.length}</span>
|
|
718
|
-
</div>
|
|
719
|
-
${approved.map((d) => html`<${DeviceRow}
|
|
720
|
-
key=${d.id} d=${d} kind="approved"
|
|
721
|
-
onRename=${() => onRenameDevice(d)}
|
|
722
|
-
onRevoke=${() => onRevokeDevice(d)} />`)}
|
|
723
|
-
</div>
|
|
724
|
-
` : null}
|
|
725
|
-
${rejected.length ? html`
|
|
726
|
-
<div class="remote-devices-group">
|
|
727
|
-
<div class="remote-devices-group-head">
|
|
728
|
-
<span class="remote-devices-group-title">Rejected</span>
|
|
729
|
-
<span class="remote-devices-group-count">${rejected.length}</span>
|
|
730
|
-
<span class="remote-devices-group-hint">auto-clears 1h after rejection</span>
|
|
731
|
-
</div>
|
|
732
|
-
${rejected.map((d) => html`<${DeviceRow}
|
|
733
|
-
key=${d.id} d=${d} kind="rejected"
|
|
734
|
-
onApprove=${() => onApproveDevice(d.id)}
|
|
735
|
-
onDelete=${() => onDeleteDevice(d)} />`)}
|
|
736
|
-
</div>
|
|
737
|
-
` : null}
|
|
738
|
-
</div>`;
|
|
739
|
-
})()}
|
|
740
|
-
</${Section}>
|
|
741
|
-
|
|
742
|
-
</div>`;
|
|
743
|
-
}
|
|
1
|
+
// Remote · expose this backend over a public tunnel URL so the same
|
|
2
|
+
// frontend can be loaded from a phone / another laptop / wherever.
|
|
3
|
+
// All API + WS calls are gated by a token the user sets here; the
|
|
4
|
+
// share URL embeds it (?token=…) so the remote browser captures it
|
|
5
|
+
// on first arrival and stashes it in localStorage.
|
|
6
|
+
//
|
|
7
|
+
// Layout mirrors ConfigurePage: .settings-scroll wrapper → Section →
|
|
8
|
+
// .config-grid → .field rows with label + content. No bespoke cards.
|
|
9
|
+
|
|
10
|
+
import { html } from '../html.js';
|
|
11
|
+
import { useState, useEffect, useRef } from 'preact/hooks';
|
|
12
|
+
import { api } from '../api.js';
|
|
13
|
+
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
14
|
+
import { setToast } from '../toast.js';
|
|
15
|
+
import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
|
|
16
|
+
import { IconCopy, IconRecycle, IconExternal, IconInfo, IconPencil, IconClose, IconCloudflareColor, IconMicrosoftColor } from '../icons.js';
|
|
17
|
+
import { fmtAgo } from '../util.js';
|
|
18
|
+
import { clockTick } from '../state.js';
|
|
19
|
+
|
|
20
|
+
function genToken() {
|
|
21
|
+
const a = new Uint8Array(18);
|
|
22
|
+
crypto.getRandomValues(a);
|
|
23
|
+
let s = '';
|
|
24
|
+
for (const b of a) s += String.fromCharCode(b);
|
|
25
|
+
return btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function copy(text) {
|
|
29
|
+
try {
|
|
30
|
+
await navigator.clipboard.writeText(text);
|
|
31
|
+
setToast('Copied', 'ok');
|
|
32
|
+
} catch {
|
|
33
|
+
setToast('Copy failed · select + Ctrl+C', 'error');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shareUrl(tunnelUrl, token) {
|
|
38
|
+
if (!tunnelUrl || !token) return '';
|
|
39
|
+
try {
|
|
40
|
+
const u = new URL(tunnelUrl);
|
|
41
|
+
u.searchParams.set('token', token);
|
|
42
|
+
return u.toString();
|
|
43
|
+
} catch { return ''; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function Section({ title, meta, children }) {
|
|
47
|
+
return html`
|
|
48
|
+
<section class="settings-section">
|
|
49
|
+
<header class="settings-section-head">
|
|
50
|
+
<h2 class="settings-section-title">${title}</h2>
|
|
51
|
+
${meta ? html`<p class="settings-section-meta">${meta}</p>` : null}
|
|
52
|
+
</header>
|
|
53
|
+
<div class="settings-section-body">${children}</div>
|
|
54
|
+
</section>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function DeviceRow({ d, kind, onApprove, onReject, onRevoke, onRename, onDelete }) {
|
|
58
|
+
const lastSeen = d.lastSeen ? fmtAgo(d.lastSeen) : '—';
|
|
59
|
+
const ipShort = d.ip ? d.ip.split(',')[0].trim() : null;
|
|
60
|
+
return html`
|
|
61
|
+
<div class=${`remote-device is-${kind}`}>
|
|
62
|
+
<div class="remote-device-main">
|
|
63
|
+
<div class="remote-device-label">
|
|
64
|
+
${d.code ? html`<code class="remote-device-code" title="Match this with the code shown on the requesting device">${d.code}</code>` : null}
|
|
65
|
+
<span class="remote-device-name">${d.label || 'Unknown device'}</span>
|
|
66
|
+
${kind === 'approved' ? html`
|
|
67
|
+
<button class="icon-btn" title="Rename" onClick=${onRename}><${IconPencil} /></button>
|
|
68
|
+
` : null}
|
|
69
|
+
</div>
|
|
70
|
+
<div class="remote-device-meta">
|
|
71
|
+
${ipShort ? html`<span class="mono">${ipShort}</span> · ` : null}
|
|
72
|
+
<span>seen ${lastSeen}</span>
|
|
73
|
+
${d.userAgent ? html` · <span class="remote-device-ua" title=${d.userAgent}>${d.userAgent.slice(0, 60)}${d.userAgent.length > 60 ? '…' : ''}</span>` : null}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div class="remote-device-actions">
|
|
77
|
+
${kind === 'pending' ? html`
|
|
78
|
+
<button class="action primary small" onClick=${onApprove}>Approve</button>
|
|
79
|
+
<button class="action subtle small" onClick=${onReject}>Reject</button>
|
|
80
|
+
` : null}
|
|
81
|
+
${kind === 'approved' ? html`
|
|
82
|
+
<button class="action subtle danger small" onClick=${onRevoke}><${IconClose} /> Revoke</button>
|
|
83
|
+
` : null}
|
|
84
|
+
${kind === 'rejected' ? html`
|
|
85
|
+
<button class="action subtle small" onClick=${onApprove}>Re-approve</button>
|
|
86
|
+
<button class="action subtle danger small" onClick=${onDelete}><${IconClose} /> Delete</button>
|
|
87
|
+
` : null}
|
|
88
|
+
</div>
|
|
89
|
+
</div>`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ProviderTile({ id, label, hint, icon, selected, disabled, onSelect }) {
|
|
93
|
+
return html`
|
|
94
|
+
<button type="button"
|
|
95
|
+
class=${`provider-tile${selected ? ' is-selected' : ''}${disabled ? ' is-disabled' : ''}`}
|
|
96
|
+
aria-pressed=${selected ? 'true' : 'false'}
|
|
97
|
+
disabled=${disabled}
|
|
98
|
+
onClick=${() => !disabled && onSelect(id)}>
|
|
99
|
+
<span class="provider-tile-icon">${icon}</span>
|
|
100
|
+
<span class="provider-tile-body">
|
|
101
|
+
<span class="provider-tile-label">${label}</span>
|
|
102
|
+
${hint ? html`<span class="provider-tile-hint">${hint}</span>` : null}
|
|
103
|
+
</span>
|
|
104
|
+
</button>`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Tiny inline row shown under the signed-in Microsoft Dev Tunnel
|
|
108
|
+
// status. Displays the persisted (named) tunnel id ccsm reuses across
|
|
109
|
+
// restarts so the public URL stays stable — and lets the user rotate
|
|
110
|
+
// it on demand. Reset requires the tunnel to be stopped first; the
|
|
111
|
+
// server-side route also enforces this.
|
|
112
|
+
function DevtunnelTunnelIdRow({ tunnelId, running, onReset }) {
|
|
113
|
+
if (!tunnelId) {
|
|
114
|
+
return html`
|
|
115
|
+
<div class="tunnel-id-row is-empty">
|
|
116
|
+
<span class="tunnel-id-label">Tunnel id</span>
|
|
117
|
+
<span class="tunnel-id-value-empty">none yet · minted on next Start</span>
|
|
118
|
+
</div>`;
|
|
119
|
+
}
|
|
120
|
+
return html`
|
|
121
|
+
<div class="tunnel-id-row">
|
|
122
|
+
<span class="tunnel-id-label">Tunnel id</span>
|
|
123
|
+
<code class="tunnel-id-value" title="Stable public URL identifier · reused across restarts">${tunnelId}</code>
|
|
124
|
+
<button type="button" class="action subtle small tunnel-id-reset"
|
|
125
|
+
disabled=${running}
|
|
126
|
+
title=${running ? 'Stop the tunnel first' : 'Mint a fresh tunnel id (public URL will change)'}
|
|
127
|
+
onClick=${onReset}>
|
|
128
|
+
<${IconRecycle} /> Reset
|
|
129
|
+
</button>
|
|
130
|
+
</div>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ProviderStatus({ id, info, onInstall, onLogin, loggingIn }) {
|
|
134
|
+
if (!info) return html`<span class="provider-status-muted">probing…</span>`;
|
|
135
|
+
if (!info.installed) {
|
|
136
|
+
return html`
|
|
137
|
+
<div class="provider-status">
|
|
138
|
+
<span class="provider-status-state is-warn">
|
|
139
|
+
<span class="provider-status-dot is-warn"></span> Not installed
|
|
140
|
+
</span>
|
|
141
|
+
<button type="button" class="action small" onClick=${onInstall}>
|
|
142
|
+
Install via winget
|
|
143
|
+
</button>
|
|
144
|
+
</div>`;
|
|
145
|
+
}
|
|
146
|
+
if (id !== 'devtunnel') {
|
|
147
|
+
// Cloudflare quick tunnel · no account state, just version.
|
|
148
|
+
return html`
|
|
149
|
+
<div class="provider-status">
|
|
150
|
+
<span class="provider-status-state is-ok">
|
|
151
|
+
<span class="provider-status-dot is-ok"></span> Ready · anonymous
|
|
152
|
+
</span>
|
|
153
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
154
|
+
</div>`;
|
|
155
|
+
}
|
|
156
|
+
// devtunnel · signed-in / signed-out states each get their own row.
|
|
157
|
+
if (!info.loggedIn) {
|
|
158
|
+
// While a sign-in flow is in flight the signin-card below this
|
|
159
|
+
// row carries its own header + spinner + cancel button. Showing
|
|
160
|
+
// a second "Signing in…" CTA here is just noise — collapse the
|
|
161
|
+
// whole signed-out block down to a thin status line until the
|
|
162
|
+
// card resolves one way or the other.
|
|
163
|
+
if (loggingIn) {
|
|
164
|
+
return html`
|
|
165
|
+
<div class="provider-status">
|
|
166
|
+
<span class="provider-status-state">
|
|
167
|
+
<span class="provider-status-dot"></span> Signing in…
|
|
168
|
+
</span>
|
|
169
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
170
|
+
</div>`;
|
|
171
|
+
}
|
|
172
|
+
return html`
|
|
173
|
+
<div class="provider-status">
|
|
174
|
+
<span class="provider-status-state is-warn">
|
|
175
|
+
<span class="provider-status-dot is-warn"></span> Not signed in
|
|
176
|
+
</span>
|
|
177
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
178
|
+
<button type="button" class="btn-signin-microsoft provider-status-signin" onClick=${onLogin}>
|
|
179
|
+
<${IconMicrosoftColor} size=${18} />
|
|
180
|
+
<span>Sign in with Microsoft</span>
|
|
181
|
+
</button>
|
|
182
|
+
</div>`;
|
|
183
|
+
}
|
|
184
|
+
return html`
|
|
185
|
+
<div class="provider-status">
|
|
186
|
+
<span class="provider-status-state is-ok">
|
|
187
|
+
<span class="provider-status-dot is-ok"></span> Signed in
|
|
188
|
+
</span>
|
|
189
|
+
<span class="provider-status-user">${info.user}</span>
|
|
190
|
+
${info.version ? html`<span class="provider-status-version">${info.version}</span>` : null}
|
|
191
|
+
<button type="button" class="action subtle small provider-status-switch" onClick=${onLogin}>
|
|
192
|
+
Switch
|
|
193
|
+
</button>
|
|
194
|
+
</div>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Device-code login panel. Shown when a `devtunnel user login -d` flow
|
|
198
|
+
// is in flight or just finished. The user clicks Open, signs in on
|
|
199
|
+
// microsoft.com, and we flip to "Signed in" automatically when the
|
|
200
|
+
// child exits 0 (the probe cache gets invalidated on exit).
|
|
201
|
+
function DevtunnelLoginPanel({ login, onCancel, onDismiss, onRetry }) {
|
|
202
|
+
if (!login) return null;
|
|
203
|
+
const { status, url, code, error, user, lines } = login;
|
|
204
|
+
const running = status === 'running';
|
|
205
|
+
const done = status === 'done';
|
|
206
|
+
const failed = status === 'error';
|
|
207
|
+
const canceled = status === 'canceled';
|
|
208
|
+
const host = (() => { try { return new URL(url).host; } catch { return url || ''; } })();
|
|
209
|
+
return html`
|
|
210
|
+
<div class=${`signin-card is-${status}`}>
|
|
211
|
+
${running ? html`
|
|
212
|
+
<div class="signin-card-header">
|
|
213
|
+
<span class="signin-card-spinner" aria-hidden="true"></span>
|
|
214
|
+
<span class="signin-card-eyebrow">Signing in to Microsoft</span>
|
|
215
|
+
<button type="button" class="signin-card-cancel" onClick=${onCancel} title="Cancel sign-in">
|
|
216
|
+
<${IconClose} /> Cancel
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="signin-card-code-block">
|
|
220
|
+
<span class="signin-card-code-label">Device code</span>
|
|
221
|
+
<div class="signin-card-code-row">
|
|
222
|
+
${code ? html`
|
|
223
|
+
<code class="signin-card-code">${code}</code>
|
|
224
|
+
<button type="button" class="action subtle small signin-card-code-copy"
|
|
225
|
+
title="Copy code" onClick=${() => copy(code)}>
|
|
226
|
+
<${IconCopy} />
|
|
227
|
+
</button>
|
|
228
|
+
` : html`<span class="signin-card-code-pending">generating…</span>`}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
<ol class="signin-card-steps">
|
|
232
|
+
<li>
|
|
233
|
+
${url ? html`
|
|
234
|
+
<a class="signin-card-open" href=${url} target="_blank" rel="noreferrer noopener">
|
|
235
|
+
<${IconExternal} /> Open <span class="signin-card-host">${host}</span>
|
|
236
|
+
</a>
|
|
237
|
+
` : html`<span class="signin-card-step-muted">Waiting for sign-in URL…</span>`}
|
|
238
|
+
</li>
|
|
239
|
+
<li>Paste the device code shown above.</li>
|
|
240
|
+
<li>Pick an account and approve — this page flips automatically.</li>
|
|
241
|
+
</ol>
|
|
242
|
+
` : null}
|
|
243
|
+
${done ? html`
|
|
244
|
+
<div class="signin-card-result is-ok">
|
|
245
|
+
<span class="signin-card-result-icon" aria-hidden="true">✓</span>
|
|
246
|
+
<div class="signin-card-result-body">
|
|
247
|
+
<div class="signin-card-result-title">Signed in</div>
|
|
248
|
+
<div class="signin-card-result-meta">
|
|
249
|
+
${user ? html`as <code>${user}</code> · ` : ''}you can start the tunnel now.
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
253
|
+
</div>
|
|
254
|
+
` : null}
|
|
255
|
+
${failed ? html`
|
|
256
|
+
<div class="signin-card-result is-error">
|
|
257
|
+
<span class="signin-card-result-icon" aria-hidden="true">!</span>
|
|
258
|
+
<div class="signin-card-result-body">
|
|
259
|
+
<div class="signin-card-result-title">Sign-in failed</div>
|
|
260
|
+
<div class="signin-card-result-meta">${error || 'devtunnel exited with an error.'}</div>
|
|
261
|
+
</div>
|
|
262
|
+
<div class="signin-card-result-actions">
|
|
263
|
+
<button type="button" class="action small" onClick=${onRetry}>Try again</button>
|
|
264
|
+
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
` : null}
|
|
268
|
+
${canceled ? html`
|
|
269
|
+
<div class="signin-card-result is-muted">
|
|
270
|
+
<div class="signin-card-result-body">
|
|
271
|
+
<div class="signin-card-result-title">Sign-in canceled</div>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="signin-card-result-actions">
|
|
274
|
+
<button type="button" class="action small" onClick=${onRetry}>Try again</button>
|
|
275
|
+
<button type="button" class="action subtle small" onClick=${onDismiss}>Dismiss</button>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
` : null}
|
|
279
|
+
${lines && lines.length ? html`
|
|
280
|
+
<details class="signin-card-log">
|
|
281
|
+
<summary>CLI output · ${lines.length} ${lines.length === 1 ? 'line' : 'lines'}</summary>
|
|
282
|
+
<pre>${lines.join('\n')}</pre>
|
|
283
|
+
</details>
|
|
284
|
+
` : null}
|
|
285
|
+
</div>`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function RemotePage() {
|
|
289
|
+
clockTick.value; // re-tick fmtAgo "last seen" labels
|
|
290
|
+
// Hydrate from a localStorage cache so the page renders the same
|
|
291
|
+
// shape it had at the end of the previous visit — provider tiles,
|
|
292
|
+
// signed-in state, tunnel id, share URL — instead of empty / placeholder
|
|
293
|
+
// chrome that fills in after the slow /api/tunnel/status round-trip
|
|
294
|
+
// (700ms+ on a cold probe). The cached snapshot is overwritten by
|
|
295
|
+
// refresh() the moment the live response lands.
|
|
296
|
+
const cachedStatus = (() => {
|
|
297
|
+
try {
|
|
298
|
+
const raw = localStorage.getItem('ccsm.remote-status-cache');
|
|
299
|
+
return raw ? JSON.parse(raw) : null;
|
|
300
|
+
} catch { return null; }
|
|
301
|
+
})();
|
|
302
|
+
const [status, setStatus] = useState(cachedStatus);
|
|
303
|
+
const [provider, setProvider] = useState(() => {
|
|
304
|
+
if (cachedStatus?.running && cachedStatus?.provider) return cachedStatus.provider;
|
|
305
|
+
if (cachedStatus?.providers?.devtunnel?.installed) return 'devtunnel';
|
|
306
|
+
if (cachedStatus?.providers?.cloudflared?.installed) return 'cloudflared';
|
|
307
|
+
return 'devtunnel';
|
|
308
|
+
});
|
|
309
|
+
const [token, setTokenLocal] = useState(cachedStatus?.token || '');
|
|
310
|
+
const [busy, setBusy] = useState(false);
|
|
311
|
+
const [deviceList, setDeviceList] = useState([]);
|
|
312
|
+
const pollRef = useRef(null);
|
|
313
|
+
|
|
314
|
+
// Tunnel status and the device list are fetched INDEPENDENTLY, not as a
|
|
315
|
+
// bundled Promise.all. /api/tunnel/status can lag behind a cold provider
|
|
316
|
+
// probe; the device list is cheap. Coupling them made the (fast) device
|
|
317
|
+
// list wait on the (slow) status round-trip, so the whole page appeared
|
|
318
|
+
// to refresh in one delayed lump. Now each updates its own state the
|
|
319
|
+
// moment its own fetch lands.
|
|
320
|
+
async function refreshStatus() {
|
|
321
|
+
try {
|
|
322
|
+
const s = await api('GET', '/api/tunnel/status');
|
|
323
|
+
setStatus(s);
|
|
324
|
+
setTokenLocal((cur) => cur || s.token || '');
|
|
325
|
+
setProvider((cur) => {
|
|
326
|
+
if (s.running && s.provider) return s.provider;
|
|
327
|
+
if (cur) return cur;
|
|
328
|
+
if (s.providers?.devtunnel?.installed) return 'devtunnel';
|
|
329
|
+
if (s.providers?.cloudflared?.installed) return 'cloudflared';
|
|
330
|
+
return cur || 'devtunnel';
|
|
331
|
+
});
|
|
332
|
+
// Snapshot for the next mount. Skip the per-call `log` so the
|
|
333
|
+
// cache stays small.
|
|
334
|
+
try {
|
|
335
|
+
localStorage.setItem('ccsm.remote-status-cache', JSON.stringify({
|
|
336
|
+
...s, log: undefined,
|
|
337
|
+
}));
|
|
338
|
+
} catch {}
|
|
339
|
+
} catch (e) { setToast(`status load failed · ${e.message}`, 'error'); }
|
|
340
|
+
}
|
|
341
|
+
async function refreshDevices() {
|
|
342
|
+
try {
|
|
343
|
+
const devs = await api('GET', '/api/devices');
|
|
344
|
+
setDeviceList(devs.devices || []);
|
|
345
|
+
} catch { /* non-critical — keep the last good list on a transient error */ }
|
|
346
|
+
}
|
|
347
|
+
function refresh() { refreshStatus(); refreshDevices(); }
|
|
348
|
+
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
refresh();
|
|
351
|
+
pollRef.current = setInterval(refresh, 2500);
|
|
352
|
+
return () => clearInterval(pollRef.current);
|
|
353
|
+
}, []);
|
|
354
|
+
|
|
355
|
+
async function onApproveDevice(id) {
|
|
356
|
+
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/approve`); refreshDevices(); setToast('Device approved', 'ok'); }
|
|
357
|
+
catch (e) { setToast(`approve failed · ${e.message}`, 'error'); }
|
|
358
|
+
}
|
|
359
|
+
async function onRejectDevice(id) {
|
|
360
|
+
try { await api('POST', `/api/devices/${encodeURIComponent(id)}/reject`); refreshDevices(); setToast('Device rejected', 'ok'); }
|
|
361
|
+
catch (e) { setToast(`reject failed · ${e.message}`, 'error'); }
|
|
362
|
+
}
|
|
363
|
+
async function onDeleteDevice(d) {
|
|
364
|
+
const ok = await ccsmConfirm(
|
|
365
|
+
`Forget "${d.label || d.id}"? The device disappears from this list. If it ever tries again it'll come back as a fresh pending request.`,
|
|
366
|
+
{ title: 'Delete device record', okLabel: 'Delete', danger: true },
|
|
367
|
+
);
|
|
368
|
+
if (!ok) return;
|
|
369
|
+
try { await api('DELETE', `/api/devices/${encodeURIComponent(d.id)}`); refreshDevices(); setToast('Device deleted', 'ok'); }
|
|
370
|
+
catch (e) { setToast(`delete failed · ${e.message}`, 'error'); }
|
|
371
|
+
}
|
|
372
|
+
async function onRevokeDevice(d) {
|
|
373
|
+
const ok = await ccsmConfirm(`Revoke access for "${d.label || d.id}"? Any open tabs lose access immediately.`, {
|
|
374
|
+
title: 'Revoke device', okLabel: 'Revoke', danger: true,
|
|
375
|
+
});
|
|
376
|
+
if (!ok) return;
|
|
377
|
+
try { await api('POST', `/api/devices/${encodeURIComponent(d.id)}/revoke`); refreshDevices(); setToast('Access revoked', 'ok'); }
|
|
378
|
+
catch (e) { setToast(`revoke failed · ${e.message}`, 'error'); }
|
|
379
|
+
}
|
|
380
|
+
async function onRenameDevice(d) {
|
|
381
|
+
const next = await ccsmPrompt('Rename device', d.label || '', { okLabel: 'Save' });
|
|
382
|
+
if (next === null) return;
|
|
383
|
+
try { await api('PUT', `/api/devices/${encodeURIComponent(d.id)}`, { label: next.trim() }); refreshDevices(); }
|
|
384
|
+
catch (e) { setToast(`rename failed · ${e.message}`, 'error'); }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function onStart() {
|
|
388
|
+
setBusy(true);
|
|
389
|
+
try {
|
|
390
|
+
// Auto-mint a token if the user hasn't generated one yet — the
|
|
391
|
+
// registration token is now an implementation detail of starting
|
|
392
|
+
// a tunnel rather than a separate setup step.
|
|
393
|
+
let tok = token;
|
|
394
|
+
if (!tok || tok.length < 8) {
|
|
395
|
+
tok = genToken();
|
|
396
|
+
setTokenLocal(tok);
|
|
397
|
+
try { await api('POST', '/api/tunnel/token', { token: tok }); }
|
|
398
|
+
catch (e) { /* the start call below will fail too — surface that */ }
|
|
399
|
+
}
|
|
400
|
+
const s = await api('POST', '/api/tunnel/start', { provider, token: tok });
|
|
401
|
+
setStatus(s);
|
|
402
|
+
setToast(s.url ? 'Tunnel up' : 'Tunnel starting · URL appearing shortly', 'ok');
|
|
403
|
+
} catch (e) {
|
|
404
|
+
setToast(`start failed · ${e.message}`, 'error');
|
|
405
|
+
} finally { setBusy(false); }
|
|
406
|
+
}
|
|
407
|
+
async function onStop() {
|
|
408
|
+
setBusy(true);
|
|
409
|
+
try {
|
|
410
|
+
const s = await api('POST', '/api/tunnel/stop');
|
|
411
|
+
setStatus(s);
|
|
412
|
+
setToast('Tunnel stopped', 'ok');
|
|
413
|
+
} catch (e) { setToast(`stop failed · ${e.message}`, 'error'); }
|
|
414
|
+
finally { setBusy(false); }
|
|
415
|
+
}
|
|
416
|
+
// Generate is the only path that mutates the token now — local React
|
|
417
|
+
// state and the server's stored token stay in lockstep, so the Share
|
|
418
|
+
// URL preview always embeds a token the server will accept. (The
|
|
419
|
+
// previous design had a separate Save step; users would Generate +
|
|
420
|
+
// copy the URL without saving, then the remote would 401 because
|
|
421
|
+
// its embedded token didn't match what the server still had.)
|
|
422
|
+
async function onGenerateToken() {
|
|
423
|
+
const fresh = genToken();
|
|
424
|
+
setTokenLocal(fresh);
|
|
425
|
+
try {
|
|
426
|
+
// When auto-start is on the token must be PERSISTED, else the
|
|
427
|
+
// rotated token is lost on the next backend restart and every
|
|
428
|
+
// share URL built from it 401s. Route through the persisting
|
|
429
|
+
// endpoint in that case; otherwise the in-memory-only token
|
|
430
|
+
// endpoint is enough.
|
|
431
|
+
const s = status?.autoStart
|
|
432
|
+
? await api('POST', '/api/tunnel/autostart', { autoStart: true, provider, token: fresh })
|
|
433
|
+
: await api('POST', '/api/tunnel/token', { token: fresh });
|
|
434
|
+
setStatus(s);
|
|
435
|
+
setToast('New token in effect', 'ok');
|
|
436
|
+
} catch (e) { setToast(`token save failed · ${e.message}`, 'error'); }
|
|
437
|
+
}
|
|
438
|
+
// Persist (or clear) the auto-start preference. On enable with no
|
|
439
|
+
// token yet, mint one first so the backend has something to reuse on
|
|
440
|
+
// its next startup. Approved devices keep working regardless of the
|
|
441
|
+
// token — it only gates NEW device registration.
|
|
442
|
+
async function onToggleAutoStart(next) {
|
|
443
|
+
setBusy(true);
|
|
444
|
+
try {
|
|
445
|
+
let tok = token;
|
|
446
|
+
if (next && (!tok || tok.length < 8)) { tok = genToken(); setTokenLocal(tok); }
|
|
447
|
+
const s = await api('POST', '/api/tunnel/autostart',
|
|
448
|
+
next ? { autoStart: true, provider, token: tok } : { autoStart: false });
|
|
449
|
+
setStatus(s);
|
|
450
|
+
setToast(next ? 'Auto-start on · tunnel comes up when ccsm starts' : 'Auto-start off', 'ok');
|
|
451
|
+
} catch (e) {
|
|
452
|
+
setToast(`auto-start ${next ? 'enable' : 'disable'} failed · ${e.message}`, 'error');
|
|
453
|
+
} finally { setBusy(false); }
|
|
454
|
+
}
|
|
455
|
+
async function onInstall(p) {
|
|
456
|
+
const ok = await ccsmConfirm(`Install ${p} via winget? Runs in the background — refresh after ~30s.`, {
|
|
457
|
+
title: 'Install tunnel provider', okLabel: 'Install',
|
|
458
|
+
});
|
|
459
|
+
if (!ok) return;
|
|
460
|
+
try {
|
|
461
|
+
await api('POST', '/api/tunnel/install', { provider: p });
|
|
462
|
+
setToast(`${p} install running in background · check back in a minute`, 'ok');
|
|
463
|
+
} catch (e) { setToast(`install failed · ${e.message}`, 'error'); }
|
|
464
|
+
}
|
|
465
|
+
function onLogin(p) {
|
|
466
|
+
if (p !== 'devtunnel') return;
|
|
467
|
+
// Kick off `devtunnel user login -d` on the host and let the
|
|
468
|
+
// panel below render the device code + URL. /status polling
|
|
469
|
+
// (every 2.5s) picks up the eventual outcome.
|
|
470
|
+
(async () => {
|
|
471
|
+
try {
|
|
472
|
+
const r = await api('POST', '/api/tunnel/devtunnel/login', { mode: 'microsoft' });
|
|
473
|
+
if (r?.login) setStatus((cur) => cur ? { ...cur, login: r.login } : cur);
|
|
474
|
+
refresh();
|
|
475
|
+
} catch (e) { setToast(`sign-in failed · ${e.message}`, 'error'); }
|
|
476
|
+
})();
|
|
477
|
+
}
|
|
478
|
+
async function onLoginCancel() {
|
|
479
|
+
try { await api('POST', '/api/tunnel/devtunnel/login/cancel'); refresh(); }
|
|
480
|
+
catch (e) { setToast(`cancel failed · ${e.message}`, 'error'); }
|
|
481
|
+
}
|
|
482
|
+
async function onLoginDismiss() {
|
|
483
|
+
try { await api('POST', '/api/tunnel/devtunnel/login/dismiss'); refresh(); }
|
|
484
|
+
catch (e) { setToast(`dismiss failed · ${e.message}`, 'error'); }
|
|
485
|
+
}
|
|
486
|
+
async function onResetDevtunnelId() {
|
|
487
|
+
const ok = await ccsmConfirm(
|
|
488
|
+
`Mint a fresh tunnel id? The public URL changes — every approved remote device will need to re-register on the new URL. Any existing share links stop working.`,
|
|
489
|
+
{ title: 'Reset Microsoft Dev Tunnel id', okLabel: 'Reset', danger: true },
|
|
490
|
+
);
|
|
491
|
+
if (!ok) return;
|
|
492
|
+
try {
|
|
493
|
+
await api('POST', '/api/tunnel/devtunnel/reset');
|
|
494
|
+
refresh();
|
|
495
|
+
setToast('Tunnel id reset · next Start mints a fresh one', 'ok');
|
|
496
|
+
} catch (e) { setToast(`reset failed · ${e.message}`, 'error'); }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const running = status?.running;
|
|
500
|
+
const url = status?.url;
|
|
501
|
+
const share = shareUrl(url, token);
|
|
502
|
+
const log = status?.log || [];
|
|
503
|
+
const cf = status?.providers?.cloudflared;
|
|
504
|
+
const dt = status?.providers?.devtunnel;
|
|
505
|
+
const dtLogin = status?.login || null;
|
|
506
|
+
const dtLoggingIn = dtLogin?.status === 'running';
|
|
507
|
+
// First /api/tunnel/status round-trip is the slow one — even with
|
|
508
|
+
// the 30s server-side cache + parallel probe, a cold call shells
|
|
509
|
+
// out and adds ~700ms. We render the full page immediately and let
|
|
510
|
+
// individual fields show their own "probing…" state instead of
|
|
511
|
+
// gating the whole panel behind a centered spinner.
|
|
512
|
+
|
|
513
|
+
return html`
|
|
514
|
+
<${PageTitleBar} title="Remote" />
|
|
515
|
+
<div class="settings-scroll">
|
|
516
|
+
|
|
517
|
+
<${Section}
|
|
518
|
+
title="Connection"
|
|
519
|
+
meta=${html`Pick which CLI ccsm spawns for the tunnel.`}>
|
|
520
|
+
<div class="config-grid">
|
|
521
|
+
<div class="field">
|
|
522
|
+
<span class="label">Provider</span>
|
|
523
|
+
<div class="provider-tile-row">
|
|
524
|
+
<${ProviderTile} id="devtunnel" label="Microsoft Dev Tunnel"
|
|
525
|
+
hint="Requires sign-in"
|
|
526
|
+
icon=${html`<${IconMicrosoftColor} size=${32} />`}
|
|
527
|
+
selected=${provider === 'devtunnel'}
|
|
528
|
+
disabled=${running}
|
|
529
|
+
onSelect=${setProvider} />
|
|
530
|
+
<${ProviderTile} id="cloudflared" label="Cloudflare Tunnel"
|
|
531
|
+
hint="Anonymous · no login"
|
|
532
|
+
icon=${html`<${IconCloudflareColor} size=${32} />`}
|
|
533
|
+
selected=${provider === 'cloudflared'}
|
|
534
|
+
disabled=${running}
|
|
535
|
+
onSelect=${setProvider} />
|
|
536
|
+
</div>
|
|
537
|
+
${running ? html`<span class="hint">Stop the tunnel to switch provider.</span>` : null}
|
|
538
|
+
</div>
|
|
539
|
+
${provider === 'devtunnel' ? html`
|
|
540
|
+
<div class="field">
|
|
541
|
+
<span class="label">Microsoft Dev Tunnel</span>
|
|
542
|
+
<div class="remote-status-line">
|
|
543
|
+
<${ProviderStatus} id="devtunnel" info=${dt}
|
|
544
|
+
onInstall=${() => onInstall('devtunnel')}
|
|
545
|
+
onLogin=${() => onLogin('devtunnel')}
|
|
546
|
+
loggingIn=${dtLoggingIn} />
|
|
547
|
+
</div>
|
|
548
|
+
${dtLogin ? html`
|
|
549
|
+
<${DevtunnelLoginPanel}
|
|
550
|
+
login=${dtLogin}
|
|
551
|
+
onCancel=${onLoginCancel}
|
|
552
|
+
onDismiss=${onLoginDismiss}
|
|
553
|
+
onRetry=${() => onLogin('devtunnel')} />
|
|
554
|
+
` : null}
|
|
555
|
+
${dt?.loggedIn ? html`
|
|
556
|
+
<${DevtunnelTunnelIdRow}
|
|
557
|
+
tunnelId=${status?.tunnelId}
|
|
558
|
+
running=${running && status?.provider === 'devtunnel'}
|
|
559
|
+
onReset=${onResetDevtunnelId} />
|
|
560
|
+
` : null}
|
|
561
|
+
</div>
|
|
562
|
+
` : null}
|
|
563
|
+
${provider === 'cloudflared' ? html`
|
|
564
|
+
<div class="field">
|
|
565
|
+
<span class="label">Cloudflare Tunnel</span>
|
|
566
|
+
<div class="remote-status-line">
|
|
567
|
+
<${ProviderStatus} id="cloudflared" info=${cf}
|
|
568
|
+
onInstall=${() => onInstall('cloudflared')} />
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
` : null}
|
|
572
|
+
</div>
|
|
573
|
+
</${Section}>
|
|
574
|
+
|
|
575
|
+
<${Section}
|
|
576
|
+
title="Tunnel"
|
|
577
|
+
meta=${running
|
|
578
|
+
? html`Provider <code>${status?.provider}</code> · started ${new Date(status.startedAt).toLocaleTimeString()}`
|
|
579
|
+
: html`Not running.`}>
|
|
580
|
+
<div class="tunnel-autostart">
|
|
581
|
+
<label class="tunnel-autostart-row">
|
|
582
|
+
<input type="checkbox" checked=${!!status?.autoStart} disabled=${busy}
|
|
583
|
+
onChange=${(e) => onToggleAutoStart(e.target.checked)} />
|
|
584
|
+
<span class="tunnel-autostart-label">Start this tunnel automatically when ccsm starts</span>
|
|
585
|
+
</label>
|
|
586
|
+
${status?.autoStart && provider === 'cloudflared' ? html`
|
|
587
|
+
<span class="hint tunnel-autostart-hint">
|
|
588
|
+
Cloudflare quick tunnels get a new URL each launch — the share URL will change on
|
|
589
|
+
restart and approved devices must re-register. Use Microsoft Dev Tunnel for a stable URL.
|
|
590
|
+
</span>` : null}
|
|
591
|
+
</div>
|
|
592
|
+
${!running ? html`
|
|
593
|
+
<div class="tunnel-hero">
|
|
594
|
+
<div class="tunnel-hero-body">
|
|
595
|
+
<div class="tunnel-hero-title">Bring this backend online</div>
|
|
596
|
+
<div class="tunnel-hero-meta">
|
|
597
|
+
ccsm will spawn
|
|
598
|
+
<code>${provider === 'devtunnel' ? 'devtunnel' : 'cloudflared'}</code>
|
|
599
|
+
and wait for it to print a public URL.
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
<button type="button" class="action tunnel-hero-cta"
|
|
603
|
+
disabled=${busy}
|
|
604
|
+
onClick=${onStart}>
|
|
605
|
+
<${IconExternal} /> ${busy ? 'Starting…' : 'Start tunnel'}
|
|
606
|
+
</button>
|
|
607
|
+
</div>
|
|
608
|
+
` : html`
|
|
609
|
+
<div class="tunnel-live">
|
|
610
|
+
<div class="tunnel-live-head">
|
|
611
|
+
<span class="tunnel-live-state">
|
|
612
|
+
<span class="tunnel-live-dot"></span>
|
|
613
|
+
Live
|
|
614
|
+
</span>
|
|
615
|
+
<span class="tunnel-live-divider">·</span>
|
|
616
|
+
<span class="tunnel-live-provider">${status?.provider === 'devtunnel' ? 'Microsoft Dev Tunnel' : 'Cloudflare Tunnel'}</span>
|
|
617
|
+
<span class="tunnel-live-divider">·</span>
|
|
618
|
+
<span class="tunnel-live-meta">since ${new Date(status.startedAt).toLocaleTimeString()}</span>
|
|
619
|
+
<button type="button" class="tunnel-stop-link"
|
|
620
|
+
disabled=${busy}
|
|
621
|
+
onClick=${onStop}>
|
|
622
|
+
<${IconClose} /> ${busy ? 'Stopping…' : 'Stop tunnel'}
|
|
623
|
+
</button>
|
|
624
|
+
</div>
|
|
625
|
+
${url ? html`
|
|
626
|
+
<div class="tunnel-share">
|
|
627
|
+
<div class="tunnel-share-label">Share URL</div>
|
|
628
|
+
<div class="tunnel-share-url">
|
|
629
|
+
<code class="tunnel-share-value">${share}</code>
|
|
630
|
+
<div class="tunnel-share-actions">
|
|
631
|
+
<button type="button" class="action small" onClick=${() => copy(share)}>
|
|
632
|
+
<${IconCopy} /> Copy
|
|
633
|
+
</button>
|
|
634
|
+
<a class="action small" href=${share} target="_blank" rel="noreferrer noopener">
|
|
635
|
+
<${IconExternal} /> Open
|
|
636
|
+
</a>
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
<div class="tunnel-share-hint">
|
|
640
|
+
Send this to the remote device · token embedded, stripped from the URL on first arrival.
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
` : html`
|
|
644
|
+
<div class="tunnel-share is-waiting">
|
|
645
|
+
<div class="signin-card-spinner" aria-hidden="true"></div>
|
|
646
|
+
<span>Waiting for the CLI to print a public URL…</span>
|
|
647
|
+
</div>
|
|
648
|
+
`}
|
|
649
|
+
${log.length ? html`
|
|
650
|
+
<details class="remote-log tunnel-log">
|
|
651
|
+
<summary>CLI log · ${log.length} lines</summary>
|
|
652
|
+
<pre>${log.join('\n')}</pre>
|
|
653
|
+
</details>
|
|
654
|
+
` : null}
|
|
655
|
+
</div>
|
|
656
|
+
`}
|
|
657
|
+
</${Section}>
|
|
658
|
+
|
|
659
|
+
<${Section}
|
|
660
|
+
title="Registration token"
|
|
661
|
+
meta=${html`Auto-generated. Only used to register new devices — approved devices keep working after a rotate.`}>
|
|
662
|
+
<div class="config-grid">
|
|
663
|
+
<div class="field">
|
|
664
|
+
<span class="label">Token</span>
|
|
665
|
+
<div class="remote-token-row">
|
|
666
|
+
<input type="text" class="input remote-token-input"
|
|
667
|
+
readonly
|
|
668
|
+
placeholder="auto-generated on first Start tunnel"
|
|
669
|
+
value=${token} />
|
|
670
|
+
<button type="button" class="action" title="Mint a fresh token (invalidates outstanding share URLs)"
|
|
671
|
+
onClick=${onGenerateToken}>
|
|
672
|
+
<${IconRecycle} /> ${token ? 'Rotate' : 'Generate'}
|
|
673
|
+
</button>
|
|
674
|
+
<button type="button" class="action"
|
|
675
|
+
disabled=${!token}
|
|
676
|
+
onClick=${() => copy(token)}>
|
|
677
|
+
<${IconCopy} /> Copy
|
|
678
|
+
</button>
|
|
679
|
+
</div>
|
|
680
|
+
<span class="hint">
|
|
681
|
+
${(!status?.token && !token)
|
|
682
|
+
? html`No token yet — one is minted automatically the first time you start a tunnel.`
|
|
683
|
+
: html`Active. Rotating it invalidates outstanding share URLs but doesn't kick out devices you've already approved.`}
|
|
684
|
+
</span>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
</${Section}>
|
|
688
|
+
|
|
689
|
+
<${Section}
|
|
690
|
+
title="Devices"
|
|
691
|
+
meta=${html`Approve each new device once.`}>
|
|
692
|
+
${(() => {
|
|
693
|
+
const pending = deviceList.filter((d) => d.status === 'pending');
|
|
694
|
+
const approved = deviceList.filter((d) => d.status === 'approved');
|
|
695
|
+
const rejected = deviceList.filter((d) => d.status === 'rejected');
|
|
696
|
+
if (!deviceList.length) {
|
|
697
|
+
return html`<p class="remote-empty">No devices yet. Send the share URL to a phone or another laptop to add the first one.</p>`;
|
|
698
|
+
}
|
|
699
|
+
return html`
|
|
700
|
+
<div class="remote-devices">
|
|
701
|
+
${pending.length ? html`
|
|
702
|
+
<div class="remote-devices-group">
|
|
703
|
+
<div class="remote-devices-group-head">
|
|
704
|
+
<span class="remote-devices-group-title">Pending approval</span>
|
|
705
|
+
<span class="remote-devices-group-count">${pending.length}</span>
|
|
706
|
+
</div>
|
|
707
|
+
${pending.map((d) => html`<${DeviceRow}
|
|
708
|
+
key=${d.id} d=${d} kind="pending"
|
|
709
|
+
onApprove=${() => onApproveDevice(d.id)}
|
|
710
|
+
onReject=${() => onRejectDevice(d.id)} />`)}
|
|
711
|
+
</div>
|
|
712
|
+
` : null}
|
|
713
|
+
${approved.length ? html`
|
|
714
|
+
<div class="remote-devices-group">
|
|
715
|
+
<div class="remote-devices-group-head">
|
|
716
|
+
<span class="remote-devices-group-title">Approved</span>
|
|
717
|
+
<span class="remote-devices-group-count">${approved.length}</span>
|
|
718
|
+
</div>
|
|
719
|
+
${approved.map((d) => html`<${DeviceRow}
|
|
720
|
+
key=${d.id} d=${d} kind="approved"
|
|
721
|
+
onRename=${() => onRenameDevice(d)}
|
|
722
|
+
onRevoke=${() => onRevokeDevice(d)} />`)}
|
|
723
|
+
</div>
|
|
724
|
+
` : null}
|
|
725
|
+
${rejected.length ? html`
|
|
726
|
+
<div class="remote-devices-group">
|
|
727
|
+
<div class="remote-devices-group-head">
|
|
728
|
+
<span class="remote-devices-group-title">Rejected</span>
|
|
729
|
+
<span class="remote-devices-group-count">${rejected.length}</span>
|
|
730
|
+
<span class="remote-devices-group-hint">auto-clears 1h after rejection</span>
|
|
731
|
+
</div>
|
|
732
|
+
${rejected.map((d) => html`<${DeviceRow}
|
|
733
|
+
key=${d.id} d=${d} kind="rejected"
|
|
734
|
+
onApprove=${() => onApproveDevice(d.id)}
|
|
735
|
+
onDelete=${() => onDeleteDevice(d)} />`)}
|
|
736
|
+
</div>
|
|
737
|
+
` : null}
|
|
738
|
+
</div>`;
|
|
739
|
+
})()}
|
|
740
|
+
</${Section}>
|
|
741
|
+
|
|
742
|
+
</div>`;
|
|
743
|
+
}
|