@bakapiano/ccsm 0.22.3 → 0.22.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +645 -543
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +159 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/TerminalView.js +15 -2
  44. package/public/js/components/XtermTerminal.js +74 -15
  45. package/public/js/components/useDragSort.js +67 -67
  46. package/public/js/dialog.js +67 -67
  47. package/public/js/icons.js +212 -212
  48. package/public/js/main.js +296 -296
  49. package/public/js/pages/AboutPage.js +90 -90
  50. package/public/js/pages/ConfigurePage.js +713 -713
  51. package/public/js/pages/LaunchPage.js +421 -421
  52. package/public/js/pages/RemotePage.js +743 -743
  53. package/public/js/pages/SessionsPage.js +199 -80
  54. package/public/js/state.js +335 -335
  55. package/public/manifest.webmanifest +25 -0
  56. package/public/setup/index.html +567 -0
  57. package/scripts/dev.js +149 -149
  58. package/scripts/install.js +153 -153
  59. package/scripts/restart-helper.js +96 -96
  60. package/scripts/upgrade-helper.js +687 -687
  61. package/server.js +1807 -1807
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "/?ccsm-dev",
3
+ "name": "CCSM dev",
4
+ "short_name": "CCSM dev",
5
+ "version": "0.0.0-dev",
6
+ "description": "Single pane over every live claude session on this machine.",
7
+ "start_url": "/",
8
+ "scope": "/",
9
+ "display": "standalone",
10
+ "display_override": [
11
+ "window-controls-overlay",
12
+ "standalone"
13
+ ],
14
+ "background_color": "#ffffff",
15
+ "theme_color": "#ffffff",
16
+ "icons": [
17
+ {
18
+ "src": "favicon.svg",
19
+ "type": "image/svg+xml",
20
+ "sizes": "any",
21
+ "purpose": "any"
22
+ }
23
+ ],
24
+ "prefer_related_applications": false
25
+ }
@@ -0,0 +1,567 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ccsm · setup</title>
7
+ <link rel="icon" type="image/svg+xml" href="../favicon.svg" />
8
+ <!-- Sits under the same /ccsm/ scope as the router so an installed PWA
9
+ navigation here doesn't leave scope and pop an address bar. -->
10
+ <link rel="manifest" href="../manifest.webmanifest" />
11
+ <!-- Capture `beforeinstallprompt` as early as possible — Chrome fires
12
+ it the moment install criteria are met, which can be BEFORE the
13
+ bottom-of-body script registers its listener if the page is slow
14
+ to paint. Stash it on `window` so the later script consumes it. -->
15
+ <script>
16
+ window.__deferredPrompt = null;
17
+ window.addEventListener('beforeinstallprompt', (ev) => {
18
+ ev.preventDefault();
19
+ window.__deferredPrompt = ev;
20
+ window.dispatchEvent(new Event('ccsm-bip-ready'));
21
+ });
22
+ </script>
23
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
24
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
25
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" />
26
+ <style>
27
+ :root {
28
+ --bg: #faf9f5;
29
+ --bg-elev: #ffffff;
30
+ --ink: #1a1815;
31
+ --ink-mid: #6b665d;
32
+ --ink-muted: #9a9489;
33
+ --border: #e8e3d5;
34
+ --border-soft: #efeadd;
35
+ --accent: #b3614a;
36
+ --accent-soft: rgba(179, 97, 74, 0.10);
37
+ --green: #4a8a4a;
38
+ --green-soft: rgba(74, 138, 74, 0.10);
39
+ --warn: #c79544;
40
+ }
41
+ * { box-sizing: border-box; }
42
+ html, body { margin: 0; padding: 0; }
43
+ body {
44
+ background: var(--bg);
45
+ color: var(--ink);
46
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
47
+ font-size: 14px;
48
+ line-height: 1.55;
49
+ min-height: 100vh;
50
+ }
51
+ a { color: var(--accent); text-decoration: none; }
52
+ a:hover { text-decoration: underline; }
53
+ code, pre {
54
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
55
+ }
56
+ pre {
57
+ background: var(--ink);
58
+ color: #e8e3d5;
59
+ padding: 12px 16px;
60
+ border-radius: 6px;
61
+ overflow-x: auto;
62
+ font-size: 13px;
63
+ margin: 10px 0;
64
+ }
65
+ pre code { font-size: 13px; }
66
+ :not(pre) > code {
67
+ background: var(--bg-elev);
68
+ border: 1px solid var(--border);
69
+ border-radius: 3px;
70
+ padding: 1px 6px;
71
+ font-size: 12.5px;
72
+ }
73
+ .wrap {
74
+ max-width: 720px;
75
+ margin: 0 auto;
76
+ padding: 56px 24px 96px;
77
+ }
78
+ .brand {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 10px;
82
+ margin-bottom: 24px;
83
+ }
84
+ .brand-mark {
85
+ width: 28px;
86
+ height: 28px;
87
+ display: inline-flex;
88
+ }
89
+ .brand-name {
90
+ font-size: 16px;
91
+ font-weight: 600;
92
+ letter-spacing: -0.01em;
93
+ }
94
+ .brand-dot { color: var(--accent); }
95
+ h1 {
96
+ font-size: 28px;
97
+ font-weight: 600;
98
+ letter-spacing: -0.02em;
99
+ margin: 0 0 8px;
100
+ }
101
+ .subtitle {
102
+ color: var(--ink-mid);
103
+ margin: 0 0 32px;
104
+ font-size: 15px;
105
+ }
106
+ .step {
107
+ background: var(--bg-elev);
108
+ border: 1px solid var(--border);
109
+ border-radius: 12px;
110
+ padding: 22px 26px;
111
+ margin-bottom: 16px;
112
+ transition: border-color .15s, opacity .25s;
113
+ position: relative;
114
+ }
115
+ .step.is-done {
116
+ border-color: rgba(74, 138, 74, 0.30);
117
+ background: var(--green-soft);
118
+ }
119
+ .step-head {
120
+ display: flex;
121
+ gap: 18px;
122
+ align-items: flex-start;
123
+ }
124
+ .step-number {
125
+ flex: 0 0 28px;
126
+ width: 28px;
127
+ height: 28px;
128
+ border-radius: 50%;
129
+ background: var(--bg);
130
+ border: 1px solid var(--border);
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ font-size: 13px;
135
+ font-weight: 500;
136
+ color: var(--ink-mid);
137
+ font-family: 'JetBrains Mono', monospace;
138
+ }
139
+ .step.is-done .step-number {
140
+ background: var(--green);
141
+ color: var(--bg-elev);
142
+ border-color: var(--green);
143
+ }
144
+ .step.is-done .step-number::before {
145
+ content: "✓";
146
+ }
147
+ .step.is-done .step-number span { display: none; }
148
+ .step-content { flex: 1; min-width: 0; }
149
+ .step h2 {
150
+ margin: 0 0 8px;
151
+ font-size: 17px;
152
+ font-weight: 500;
153
+ letter-spacing: -0.01em;
154
+ }
155
+ .step p {
156
+ margin: 0 0 10px;
157
+ color: var(--ink-mid);
158
+ }
159
+ .step p strong { color: var(--ink); font-weight: 500; }
160
+ .step .hint {
161
+ font-size: 12.5px;
162
+ color: var(--ink-muted);
163
+ }
164
+ .btn {
165
+ appearance: none;
166
+ border: 1px solid var(--ink);
167
+ background: var(--ink);
168
+ color: var(--bg-elev);
169
+ padding: 8px 16px;
170
+ border-radius: 6px;
171
+ cursor: pointer;
172
+ font: inherit;
173
+ font-size: 13px;
174
+ font-weight: 500;
175
+ transition: background .12s, color .12s;
176
+ margin: 6px 0 4px;
177
+ }
178
+ .btn:hover { background: #000; }
179
+ .btn.subtle {
180
+ background: var(--bg-elev);
181
+ color: var(--ink);
182
+ }
183
+ .btn.subtle:hover { background: var(--border-soft); }
184
+ .step-status {
185
+ display: inline-block;
186
+ margin-top: 10px;
187
+ padding: 4px 10px;
188
+ border-radius: 999px;
189
+ font-size: 12px;
190
+ background: var(--bg);
191
+ color: var(--ink-muted);
192
+ border: 1px solid var(--border);
193
+ font-family: 'JetBrains Mono', monospace;
194
+ }
195
+ .step-status.is-ok {
196
+ background: var(--green-soft);
197
+ color: var(--green);
198
+ border-color: rgba(74, 138, 74, 0.3);
199
+ }
200
+ .step-status.is-warn {
201
+ background: rgba(199, 149, 68, 0.10);
202
+ color: var(--warn);
203
+ border-color: rgba(199, 149, 68, 0.35);
204
+ }
205
+ .copy-btn {
206
+ margin-left: 8px;
207
+ font-size: 11.5px;
208
+ background: transparent;
209
+ border: 1px solid var(--border-soft);
210
+ color: #e8e3d5;
211
+ border-radius: 4px;
212
+ padding: 2px 8px;
213
+ cursor: pointer;
214
+ }
215
+ .copy-btn:hover { background: rgba(255,255,255,0.08); }
216
+ .install-row {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 4px;
220
+ }
221
+ .install-row pre { flex: 1; margin: 10px 0; }
222
+ .footer {
223
+ margin-top: 36px;
224
+ padding-top: 24px;
225
+ border-top: 1px solid var(--border);
226
+ color: var(--ink-muted);
227
+ font-size: 12.5px;
228
+ text-align: center;
229
+ }
230
+ .footer a { color: var(--ink-mid); }
231
+ /* Mark all-steps-done banner */
232
+ .done-banner {
233
+ margin-top: 20px;
234
+ padding: 18px 22px;
235
+ background: var(--green);
236
+ color: var(--bg-elev);
237
+ border-radius: 10px;
238
+ display: none;
239
+ text-align: center;
240
+ font-weight: 500;
241
+ }
242
+ .done-banner.show { display: block; animation: pop 0.4s ease-out; }
243
+ @keyframes pop {
244
+ from { transform: scale(0.96); opacity: 0; }
245
+ to { transform: scale(1); opacity: 1; }
246
+ }
247
+ </style>
248
+ </head>
249
+ <body>
250
+ <div class="wrap">
251
+ <div class="brand">
252
+ <span class="brand-mark">
253
+ <svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
254
+ <rect width="32" height="32" rx="6" fill="#1a1815"/>
255
+ <text x="16" y="20" text-anchor="middle" fill="#e8e3d5" font-family="JetBrains Mono, monospace" font-size="10" font-weight="600">ccsm</text>
256
+ </svg>
257
+ </span>
258
+ <span class="brand-name">CCSM setup</span>
259
+ </div>
260
+
261
+ <h1>Set up ccsm</h1>
262
+ <p class="subtitle">Three quick steps. We'll auto-detect what's already done — just handle the un-checked ones.</p>
263
+
264
+ <!-- Step 1 · ccsm:// protocol ──────────────────────────────────── -->
265
+ <div class="step" id="step-protocol">
266
+ <div class="step-head">
267
+ <div class="step-number"><span>1</span></div>
268
+ <div class="step-content">
269
+ <h2>Allow the ccsm:// link handler</h2>
270
+ <p>ccsm registers a <code>ccsm://</code> URL protocol so the "Start backend" button can wake it from the browser. The first time it fires, Chrome shows a confirmation:</p>
271
+ <p class="hint">→ <strong>Always allow ccsm.exe to open links of this type</strong> · then click <strong>Open</strong>.</p>
272
+ <p>Click below to trigger the prompt. (If the backend is already running, this is a no-op.)</p>
273
+ <button class="btn" id="test-protocol">Try ccsm://start</button>
274
+ <span class="step-status" id="status-protocol">Not tested yet</span>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <!-- Step 2 · Localhost networking ──────────────────────────────── -->
280
+ <div class="step" id="step-firewall">
281
+ <div class="step-head">
282
+ <div class="step-number"><span>2</span></div>
283
+ <div class="step-content">
284
+ <h2>Allow localhost networking</h2>
285
+ <p>The backend listens on <code>localhost:7777</code> and this page (hosted on GitHub Pages) fetches from it. Two things to check:</p>
286
+ <ul>
287
+ <li><strong>Windows Firewall</strong> — first time <code>node.exe</code> binds the port, Windows pops "Allow this app to communicate". Tick <strong>Private networks</strong> + Allow access.</li>
288
+ <li><strong>Browser</strong> — Chrome 99+ treats <code>localhost</code> as a secure origin from HTTPS pages out of the box, so usually nothing to do here. If you see a "Mixed content" badge in the URL bar, click it → Allow.</li>
289
+ </ul>
290
+ <p>We can verify the browser side by trying a probe:</p>
291
+ <button class="btn subtle" id="test-localhost">Probe localhost:7777</button>
292
+ <span class="step-status" id="status-firewall">Not tested yet</span>
293
+ </div>
294
+ </div>
295
+ </div>
296
+
297
+ <!-- Step 3 · Install as app ────────────────────────────────────── -->
298
+ <div class="step" id="step-pwa">
299
+ <div class="step-head">
300
+ <div class="step-number"><span>3</span></div>
301
+ <div class="step-content">
302
+ <h2>Install as app</h2>
303
+ <p>For a chromeless window with no address bar:</p>
304
+ <ol>
305
+ <li>Open <a href="../" target="_blank" rel="noopener">bakapiano.github.io/ccsm</a> in Chrome.</li>
306
+ <li>Look for the install icon (<code>⊕</code>) on the right side of the URL bar.</li>
307
+ <li>Click <strong>Install</strong>. Chrome creates a Start Menu shortcut and opens ccsm as a standalone app.</li>
308
+ </ol>
309
+ <p class="hint">From then on, the <code>ccsm</code> command launches the installed PWA window (we auto-detect your install) — no address bar, OS title bar only.</p>
310
+ <button class="btn" id="install-pwa-btn">Install now</button>
311
+ <span class="step-status" id="status-pwa">Detecting…</span>
312
+ </div>
313
+ </div>
314
+ </div>
315
+
316
+ <div class="done-banner" id="done-banner">
317
+ All set. Open ccsm and enjoy your sessions.
318
+ <div style="margin-top:8px"><a href="../" style="color:#fff; text-decoration:underline">→ Launch ccsm</a></div>
319
+ </div>
320
+
321
+ <div class="footer">
322
+ <a href="../" target="_blank">ccsm router</a> ·
323
+ <a href="https://github.com/bakapiano/ccsm" target="_blank">GitHub</a> ·
324
+ <a href="https://www.npmjs.com/package/@bakapiano/ccsm" target="_blank">npm</a>
325
+ </div>
326
+ </div>
327
+
328
+ <script>
329
+ (function () {
330
+ const $ = (sel) => document.querySelector(sel);
331
+ const protocolStatus = $('#status-protocol');
332
+ const firewallStatus = $('#status-firewall');
333
+ const pwaStatus = $('#status-pwa');
334
+
335
+ // ── Background health probe — reused by the protocol-test wait
336
+ // loop and the localhost probe button. Doesn't have its own visible
337
+ // step (the page is opened from npm postinstall so we assume the
338
+ // user just installed); the result just feeds into step 2's status.
339
+ async function checkInstalled() {
340
+ try {
341
+ const ctrl = new AbortController();
342
+ const t = setTimeout(() => ctrl.abort(), 2000);
343
+ const r = await fetch('http://localhost:7777/api/health', { signal: ctrl.signal, cache: 'no-store' });
344
+ clearTimeout(t);
345
+ if (!r.ok) throw new Error('http ' + r.status);
346
+ const j = await r.json();
347
+ if (j.name === '@bakapiano/ccsm') {
348
+ // Reached backend → networking is fine, mark step 2 done.
349
+ if (!firewallStatus.classList.contains('is-ok')) {
350
+ firewallStatus.className = 'step-status is-ok';
351
+ firewallStatus.textContent = '✓ Reached localhost:7777';
352
+ $('#step-firewall').classList.add('is-done');
353
+ }
354
+ updateDoneBanner();
355
+ return true;
356
+ }
357
+ return false;
358
+ } catch (e) {
359
+ return false;
360
+ }
361
+ }
362
+ checkInstalled();
363
+ setInterval(checkInstalled, 4000);
364
+
365
+ // ── Step 1 · ccsm:// trigger ────────────────────────────────────
366
+ $('#test-protocol').addEventListener('click', () => {
367
+ protocolStatus.className = 'step-status is-warn';
368
+ protocolStatus.textContent = 'Waiting for Chrome prompt…';
369
+ location.href = 'ccsm://start';
370
+ // We can't directly tell whether the user accepted the protocol
371
+ // prompt — the next /api/health success is the best signal.
372
+ let waited = 0;
373
+ const t = setInterval(async () => {
374
+ waited += 1500;
375
+ const ok = await checkInstalled();
376
+ if (ok) {
377
+ protocolStatus.className = 'step-status is-ok';
378
+ protocolStatus.textContent = '✓ Backend woke via ccsm://';
379
+ clearInterval(t);
380
+ } else if (waited > 15000) {
381
+ protocolStatus.className = 'step-status';
382
+ protocolStatus.textContent = '— Timed out. If a Chrome dialog appeared, click "Open ccsm.cmd" and retry.';
383
+ clearInterval(t);
384
+ }
385
+ }, 1500);
386
+ });
387
+
388
+ // ── Step 2 · manual probe ───────────────────────────────────────
389
+ $('#test-localhost').addEventListener('click', async () => {
390
+ firewallStatus.className = 'step-status is-warn';
391
+ firewallStatus.textContent = 'Probing…';
392
+ const ok = await checkInstalled();
393
+ if (ok) {
394
+ firewallStatus.className = 'step-status is-ok';
395
+ firewallStatus.textContent = '✓ Reached localhost:7777';
396
+ $('#step-firewall').classList.add('is-done');
397
+ } else {
398
+ firewallStatus.className = 'step-status';
399
+ firewallStatus.textContent = '— Backend not running. Start it first (step 1).';
400
+ }
401
+ updateDoneBanner();
402
+ });
403
+
404
+ // ── Step 3 · PWA detection + beforeinstallprompt ────────────────
405
+ // Three signals for "is the PWA installed in this browser":
406
+ // (a) display-mode standalone — we're INSIDE the PWA window right now
407
+ // (b) navigator.getInstalledRelatedApps — returns the manifest if it's
408
+ // been installed at any point (requires `related_applications`
409
+ // self-reference in the manifest, which we just added)
410
+ // (c) `appinstalled` event firing within this session
411
+ // Any of (a) (b) (c) flips the step to done.
412
+ function isStandalone() {
413
+ return window.matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)').matches;
414
+ }
415
+ let installedFlag = false;
416
+ function setInstalled(reason) {
417
+ installedFlag = true;
418
+ pwaStatus.className = 'step-status is-ok';
419
+ pwaStatus.textContent = '✓ Installed' + (reason ? ' · ' + reason : '');
420
+ $('#step-pwa').classList.add('is-done');
421
+ refreshInstallBtn();
422
+ updateDoneBanner();
423
+ }
424
+ async function updatePwaStatus() {
425
+ if (isStandalone()) { setInstalled('running as PWA'); return; }
426
+ if (typeof navigator.getInstalledRelatedApps === 'function') {
427
+ try {
428
+ const apps = await navigator.getInstalledRelatedApps();
429
+ if (apps.some((a) => a.platform === 'webapp')) {
430
+ setInstalled('detected via getInstalledRelatedApps');
431
+ return;
432
+ }
433
+ } catch {}
434
+ }
435
+ if (!installedFlag) {
436
+ pwaStatus.className = 'step-status';
437
+ pwaStatus.textContent = '— Not installed yet';
438
+ }
439
+ updateDoneBanner();
440
+ }
441
+ updatePwaStatus();
442
+ window.matchMedia('(display-mode: standalone)').addEventListener?.('change', updatePwaStatus);
443
+
444
+ // Install button is always visible; its click behavior depends on
445
+ // what state we're in:
446
+ // - deferredPrompt available → fire native Chrome install dialog
447
+ // - already installed (standalone display-mode) → open the router
448
+ // in a new tab, which Chrome opens AS the installed PWA window
449
+ // - no prompt available + not installed → open the router so the
450
+ // user can hit Chrome's URL-bar install icon there. Setup page
451
+ // is at /ccsm/setup/, but Chrome only shows the install icon on
452
+ // the manifest's `start_url` (/ccsm/), which is why we route
453
+ // them there.
454
+ const installBtn = $('#install-pwa-btn');
455
+ function refreshInstallBtn() {
456
+ if (isStandalone() || installedFlag) {
457
+ installBtn.textContent = 'Open installed app';
458
+ } else if (window.__deferredPrompt) {
459
+ installBtn.textContent = 'Install now';
460
+ } else {
461
+ installBtn.textContent = 'Open router to install';
462
+ }
463
+ }
464
+ refreshInstallBtn();
465
+ window.addEventListener('ccsm-bip-ready', refreshInstallBtn);
466
+
467
+ // After a successful install Chrome opens a new PWA standalone
468
+ // window at the install-trigger URL — which for us is /ccsm/setup/,
469
+ // NOT manifest.start_url. So the freshly-popped PWA shows the setup
470
+ // page instead of the actual app. We handle this two ways:
471
+ // • The PWA window's setup page (it's a fresh load) self-detects
472
+ // standalone display-mode at startup and bounces to ../ — see
473
+ // the bouncePwaWindow() block right after this.
474
+ // • THIS tab (the regular browser one that triggered the install)
475
+ // just closes itself so the user isn't left with a stale setup
476
+ // tab next to the PWA window. window.close() is allowed for
477
+ // script-opened tabs; for postinstall-spawned ones (Windows
478
+ // `start` from terminal) Chrome silently refuses and the page
479
+ // stays in the "✓ Installed" state, which is harmless.
480
+ function jumpToApp() {
481
+ setTimeout(() => {
482
+ try { window.close(); } catch {}
483
+ }, 600);
484
+ }
485
+
486
+ // The PWA window's own bounce-to-start_url logic. Triggers when:
487
+ // (a) the page loads INSIDE a standalone PWA window — Chrome
488
+ // just opened it as the post-install window
489
+ // (b) the display-mode transitions to standalone while this page
490
+ // is alive (rare — covers the case where Chrome upgrades a
491
+ // browser tab into a PWA window mid-session)
492
+ // Either path replaces the URL with ../ so the setup page never
493
+ // becomes the PWA's resting state. Uses location.replace so /setup/
494
+ // doesn't sit in the PWA window's history.
495
+ function bouncePwaWindow() {
496
+ if (location.pathname.endsWith('/setup/') || location.pathname.endsWith('/setup/index.html')) {
497
+ location.replace('../');
498
+ }
499
+ }
500
+ if (window.matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)').matches) {
501
+ bouncePwaWindow();
502
+ }
503
+ window.matchMedia('(display-mode: standalone)').addEventListener?.('change', (ev) => {
504
+ if (ev.matches) bouncePwaWindow();
505
+ });
506
+
507
+ installBtn.addEventListener('click', async () => {
508
+ if (window.__deferredPrompt) {
509
+ installBtn.disabled = true;
510
+ try {
511
+ const result = await window.__deferredPrompt.prompt();
512
+ if (result.outcome === 'accepted') {
513
+ pwaStatus.className = 'step-status is-ok';
514
+ pwaStatus.textContent = '✓ Installed · launching…';
515
+ $('#step-pwa').classList.add('is-done');
516
+ updateDoneBanner();
517
+ jumpToApp();
518
+ }
519
+ } finally {
520
+ window.__deferredPrompt = null;
521
+ installBtn.disabled = false;
522
+ refreshInstallBtn();
523
+ }
524
+ return;
525
+ }
526
+ // No native prompt — fall back to opening the router. Chrome
527
+ // detects an installed PWA at that origin and opens it as the
528
+ // standalone window automatically.
529
+ window.open('../', '_blank', 'noopener');
530
+ });
531
+
532
+ // appinstalled fires AFTER the native dialog resolves "accepted".
533
+ // We've usually already started jumpToApp() from the prompt's then
534
+ // branch above, but this is the catch-all in case the user
535
+ // installed via the URL-bar icon (no deferredPrompt path).
536
+ window.addEventListener('appinstalled', () => {
537
+ pwaStatus.className = 'step-status is-ok';
538
+ pwaStatus.textContent = '✓ Installed · launching…';
539
+ $('#step-pwa').classList.add('is-done');
540
+ refreshInstallBtn();
541
+ updateDoneBanner();
542
+ jumpToApp();
543
+ });
544
+
545
+ // ── Done banner when all steps are checked ──────────────────────
546
+ function updateDoneBanner() {
547
+ const steps = document.querySelectorAll('.step');
548
+ const allDone = Array.from(steps).every((s) => s.classList.contains('is-done'));
549
+ $('#done-banner').classList.toggle('show', allDone);
550
+ }
551
+
552
+ // ── Copy buttons ────────────────────────────────────────────────
553
+ document.querySelectorAll('.copy-btn').forEach((btn) => {
554
+ btn.addEventListener('click', () => {
555
+ const target = document.querySelector(btn.getAttribute('data-copy'));
556
+ if (!target) return;
557
+ navigator.clipboard.writeText(target.textContent).then(() => {
558
+ const orig = btn.textContent;
559
+ btn.textContent = 'Copied';
560
+ setTimeout(() => { btn.textContent = orig; }, 1200);
561
+ });
562
+ });
563
+ });
564
+ })();
565
+ </script>
566
+ </body>
567
+ </html>