termbeam 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,7 +54,8 @@ termbeam --no-password # disable password protection
54
54
  - **Side panel** (mobile) — slide-out session list with output previews for quick switching
55
55
  - **Create sessions anywhere** — new session modal available from both the hub page and the terminal page
56
56
  - **Touch scrolling** — swipe to scroll through terminal history
57
- - **Share button** — share the TermBeam URL via Web Share API, clipboard, or legacy copy fallback (works over HTTP)
57
+ - **Share button** — share the TermBeam URL via Web Share API, clipboard, or legacy copy fallback (works over HTTP); each share gets a fresh auto-login link with a 5-minute share token
58
+ - **QR code auto-login** — scan the QR code to log in automatically without typing the password (share token, 5-minute expiry)
58
59
  - **Refresh button** — clear PWA/service worker cache and reload to get the latest version
59
60
  - **iPhone PWA safe area** — full support for `viewport-fit=cover` and safe area insets on notched devices
60
61
  - **Password auth** with token-based cookies and rate-limited login
@@ -95,17 +96,17 @@ termbeam --port 8080 # custom port (default: 3456)
95
96
  termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
96
97
  ```
97
98
 
98
- | Flag | Description | Default |
99
- | --------------------- | ---------------------------------------- | ---------------- |
99
+ | Flag | Description | Default |
100
+ | --------------------- | ---------------------------------------------------- | -------------- |
100
101
  | `--password <pw>` | Set access password (also accepts `--password=<pw>`) | Auto-generated |
101
- | `--no-password` | Disable password | — |
102
- | `--generate-password` | Auto-generate a secure password | On |
103
- | `--tunnel` | Create an ephemeral devtunnel URL | On |
104
- | `--no-tunnel` | Disable tunnel (LAN-only) | — |
105
- | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
106
- | `--port <port>` | Server port | `3456` |
107
- | `--host <addr>` | Bind address | `0.0.0.0` |
108
- | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
102
+ | `--no-password` | Disable password | — |
103
+ | `--generate-password` | Auto-generate a secure password | On |
104
+ | `--tunnel` | Create an ephemeral devtunnel URL | On |
105
+ | `--no-tunnel` | Disable tunnel (LAN-only) | — |
106
+ | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
107
+ | `--port <port>` | Server port | `3456` |
108
+ | `--host <addr>` | Bind address | `0.0.0.0` |
109
+ | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
109
110
 
110
111
  Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LOG_LEVEL`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
111
112
 
@@ -113,7 +114,7 @@ Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LO
113
114
 
114
115
  TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. Be aware that the tunnel exposes your terminal to the internet — use `--no-tunnel` for LAN-only access, or `--host 127.0.0.1` to restrict to your machine only.
115
116
 
116
- Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header. See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for more.
117
+ Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. The QR code on startup embeds a share token for password-free login — the token is reusable within its 5-minute validity window, which handles tunnel proxy retries and link preview services. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header. See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for more.
117
118
 
118
119
  ## Contributing
119
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -9,7 +9,10 @@
9
9
  <meta name="apple-mobile-web-app-capable" content="yes" />
10
10
  <meta name="mobile-web-app-capable" content="yes" />
11
11
  <meta name="theme-color" content="#1e1e1e" />
12
- <meta name="description" content="TermBeam — beam your terminal to any device. Mobile-optimized web terminal with multi-session support, touch controls, and QR code connection. No SSH needed." />
12
+ <meta
13
+ name="description"
14
+ content="TermBeam — beam your terminal to any device. Mobile-optimized web terminal with multi-session support, touch controls, and QR code connection. No SSH needed."
15
+ />
13
16
  <link rel="manifest" href="/manifest.json" />
14
17
  <link rel="apple-touch-icon" href="/icons/icon-192.png" />
15
18
  <title>TermBeam — Beam Your Terminal to Any Device</title>
@@ -30,10 +33,10 @@
30
33
  --danger-hover: #d73a3a;
31
34
  --success: #89d185;
32
35
  --info: #b0b0b0;
33
- --shadow: rgba(0,0,0,0.15);
34
- --overlay-bg: rgba(0,0,0,0.7);
36
+ --shadow: rgba(0, 0, 0, 0.15);
37
+ --overlay-bg: rgba(0, 0, 0, 0.7);
35
38
  }
36
- [data-theme="light"] {
39
+ [data-theme='light'] {
37
40
  --bg: #ffffff;
38
41
  --surface: #f3f3f3;
39
42
  --border: #e0e0e0;
@@ -49,8 +52,8 @@
49
52
  --danger-hover: #c20000;
50
53
  --success: #16825d;
51
54
  --info: #616161;
52
- --shadow: rgba(0,0,0,0.06);
53
- --overlay-bg: rgba(0,0,0,0.4);
55
+ --shadow: rgba(0, 0, 0, 0.06);
56
+ --overlay-bg: rgba(0, 0, 0, 0.4);
54
57
  }
55
58
  * {
56
59
  margin: 0;
@@ -64,7 +67,9 @@
64
67
  background: var(--bg);
65
68
  color: var(--text);
66
69
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
67
- transition: background 0.3s, color 0.3s;
70
+ transition:
71
+ background 0.3s,
72
+ color 0.3s;
68
73
  }
69
74
 
70
75
  .header {
@@ -100,7 +105,10 @@
100
105
  align-items: center;
101
106
  justify-content: center;
102
107
  font-size: 16px;
103
- transition: color 0.15s, border-color 0.15s, background 0.15s;
108
+ transition:
109
+ color 0.15s,
110
+ border-color 0.15s,
111
+ background 0.15s;
104
112
  -webkit-tap-highlight-color: transparent;
105
113
  }
106
114
  .header-btn:hover {
@@ -123,7 +131,10 @@
123
131
  align-items: center;
124
132
  justify-content: center;
125
133
  font-size: 16px;
126
- transition: color 0.15s, border-color 0.15s, background 0.15s;
134
+ transition:
135
+ color 0.15s,
136
+ border-color 0.15s,
137
+ background 0.15s;
127
138
  -webkit-tap-highlight-color: transparent;
128
139
  }
129
140
  .theme-toggle:hover {
@@ -150,7 +161,10 @@
150
161
  gap: 8px;
151
162
  text-decoration: none;
152
163
  color: inherit;
153
- transition: transform 0.2s ease, border-color 0.15s, background 0.3s;
164
+ transition:
165
+ transform 0.2s ease,
166
+ border-color 0.15s,
167
+ background 0.3s;
154
168
  cursor: pointer;
155
169
  -webkit-tap-highlight-color: transparent;
156
170
  position: relative;
@@ -222,7 +236,9 @@
222
236
  background: var(--bg);
223
237
  padding: 2px 8px;
224
238
  border-radius: 4px;
225
- transition: background 0.3s, color 0.3s;
239
+ transition:
240
+ background 0.3s,
241
+ color 0.3s;
226
242
  }
227
243
  .session-card .details {
228
244
  display: flex;
@@ -246,7 +262,9 @@
246
262
  font-size: 14px;
247
263
  font-weight: 600;
248
264
  cursor: pointer;
249
- transition: background 0.15s, transform 0.1s;
265
+ transition:
266
+ background 0.15s,
267
+ transform 0.1s;
250
268
  }
251
269
  .session-card .connect-btn:hover {
252
270
  background: var(--accent-hover);
@@ -271,7 +289,10 @@
271
289
  cursor: pointer;
272
290
  text-align: center;
273
291
  z-index: 50;
274
- transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
292
+ transition:
293
+ background 0.15s,
294
+ transform 0.1s,
295
+ box-shadow 0.15s;
275
296
  box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
276
297
  }
277
298
  .new-session:hover {
@@ -338,7 +359,10 @@
338
359
  outline: none;
339
360
  -webkit-appearance: none;
340
361
  appearance: none;
341
- transition: background 0.3s, border-color 0.15s, color 0.3s;
362
+ transition:
363
+ background 0.3s,
364
+ border-color 0.15s,
365
+ color 0.3s;
342
366
  }
343
367
  .modal select {
344
368
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
@@ -364,7 +388,9 @@
364
388
  font-size: 15px;
365
389
  font-weight: 600;
366
390
  cursor: pointer;
367
- transition: background 0.15s, transform 0.1s;
391
+ transition:
392
+ background 0.15s,
393
+ transform 0.1s;
368
394
  }
369
395
  .modal-actions button:active {
370
396
  transform: scale(0.95);
@@ -403,7 +429,9 @@
403
429
  flex-shrink: 0;
404
430
  display: flex;
405
431
  align-items: center;
406
- transition: background 0.15s, border-color 0.15s;
432
+ transition:
433
+ background 0.15s,
434
+ border-color 0.15s;
407
435
  }
408
436
  .cwd-browse-btn:hover {
409
437
  border-color: var(--accent);
@@ -488,7 +516,9 @@
488
516
  padding: 4px 6px;
489
517
  border-radius: 4px;
490
518
  flex-shrink: 0;
491
- transition: background 0.15s, color 0.15s;
519
+ transition:
520
+ background 0.15s,
521
+ color 0.15s;
492
522
  }
493
523
  .crumb:active,
494
524
  .crumb:hover {
@@ -576,7 +606,9 @@
576
606
  font-size: 16px;
577
607
  font-weight: 600;
578
608
  cursor: pointer;
579
- transition: background 0.15s, transform 0.1s;
609
+ transition:
610
+ background 0.15s,
611
+ transform 0.1s;
580
612
  }
581
613
  .browser-select-btn:hover {
582
614
  background: var(--accent-hover);
@@ -598,7 +630,9 @@
598
630
  border-radius: 50%;
599
631
  border: 3px solid transparent;
600
632
  cursor: pointer;
601
- transition: border-color 0.15s, transform 0.1s;
633
+ transition:
634
+ border-color 0.15s,
635
+ transform 0.1s;
602
636
  -webkit-tap-highlight-color: transparent;
603
637
  padding: 0;
604
638
  outline: none;
@@ -615,10 +649,70 @@
615
649
  <body>
616
650
  <div class="header">
617
651
  <h1>📡 Term<span>Beam</span></h1>
618
- <p>Beam your terminal to any device · <span id="version" style="color: var(--accent)"></span></p>
619
- <button class="header-btn" id="share-btn" style="right: 96px;top:16px" title="Share link"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg></button>
620
- <button class="header-btn" id="refresh-btn" style="right: 56px;top:16px" title="Refresh app"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>
621
- <button class="theme-toggle" id="theme-toggle" title="Toggle theme"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button>
652
+ <p>
653
+ Beam your terminal to any device · <span id="version" style="color: var(--accent)"></span>
654
+ </p>
655
+ <button class="header-btn" id="share-btn" style="right: 96px; top: 16px" title="Share link">
656
+ <svg
657
+ width="16"
658
+ height="16"
659
+ viewBox="0 0 24 24"
660
+ fill="none"
661
+ stroke="currentColor"
662
+ stroke-width="2"
663
+ stroke-linecap="round"
664
+ stroke-linejoin="round"
665
+ >
666
+ <circle cx="18" cy="5" r="3" />
667
+ <circle cx="6" cy="12" r="3" />
668
+ <circle cx="18" cy="19" r="3" />
669
+ <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
670
+ <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
671
+ </svg>
672
+ </button>
673
+ <button
674
+ class="header-btn"
675
+ id="refresh-btn"
676
+ style="right: 56px; top: 16px"
677
+ title="Refresh app"
678
+ >
679
+ <svg
680
+ width="16"
681
+ height="16"
682
+ viewBox="0 0 24 24"
683
+ fill="none"
684
+ stroke="currentColor"
685
+ stroke-width="2"
686
+ stroke-linecap="round"
687
+ stroke-linejoin="round"
688
+ >
689
+ <polyline points="23 4 23 10 17 10" />
690
+ <polyline points="1 20 1 14 7 14" />
691
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
692
+ </svg>
693
+ </button>
694
+ <button class="theme-toggle" id="theme-toggle" title="Toggle theme">
695
+ <svg
696
+ width="16"
697
+ height="16"
698
+ viewBox="0 0 24 24"
699
+ fill="none"
700
+ stroke="currentColor"
701
+ stroke-width="2"
702
+ stroke-linecap="round"
703
+ stroke-linejoin="round"
704
+ >
705
+ <circle cx="12" cy="12" r="5" />
706
+ <line x1="12" y1="1" x2="12" y2="3" />
707
+ <line x1="12" y1="21" x2="12" y2="23" />
708
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
709
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
710
+ <line x1="1" y1="12" x2="3" y2="12" />
711
+ <line x1="21" y1="12" x2="23" y2="12" />
712
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
713
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
714
+ </svg>
715
+ </button>
622
716
  </div>
623
717
 
624
718
  <div class="sessions-list" id="sessions-list"></div>
@@ -633,18 +727,69 @@
633
727
  <select id="sess-shell">
634
728
  <option value="">Loading shells…</option>
635
729
  </select>
636
- <label for="sess-cmd">Initial Command <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
730
+ <label for="sess-cmd"
731
+ >Initial Command
732
+ <span style="color: var(--text-muted); font-weight: normal">(optional)</span></label
733
+ >
637
734
  <input type="text" id="sess-cmd" placeholder="e.g. copilot, htop, vim" />
638
735
  <label>Color</label>
639
736
  <div class="color-picker" id="color-picker">
640
- <button type="button" class="color-swatch selected" data-color="#4a9eff" style="background:#4a9eff" title="Blue"></button>
641
- <button type="button" class="color-swatch" data-color="#4ade80" style="background:#4ade80" title="Green"></button>
642
- <button type="button" class="color-swatch" data-color="#fbbf24" style="background:#fbbf24" title="Amber"></button>
643
- <button type="button" class="color-swatch" data-color="#c084fc" style="background:#c084fc" title="Purple"></button>
644
- <button type="button" class="color-swatch" data-color="#f87171" style="background:#f87171" title="Red"></button>
645
- <button type="button" class="color-swatch" data-color="#22d3ee" style="background:#22d3ee" title="Cyan"></button>
646
- <button type="button" class="color-swatch" data-color="#fb923c" style="background:#fb923c" title="Orange"></button>
647
- <button type="button" class="color-swatch" data-color="#f472b6" style="background:#f472b6" title="Pink"></button>
737
+ <button
738
+ type="button"
739
+ class="color-swatch selected"
740
+ data-color="#4a9eff"
741
+ style="background: #4a9eff"
742
+ title="Blue"
743
+ ></button>
744
+ <button
745
+ type="button"
746
+ class="color-swatch"
747
+ data-color="#4ade80"
748
+ style="background: #4ade80"
749
+ title="Green"
750
+ ></button>
751
+ <button
752
+ type="button"
753
+ class="color-swatch"
754
+ data-color="#fbbf24"
755
+ style="background: #fbbf24"
756
+ title="Amber"
757
+ ></button>
758
+ <button
759
+ type="button"
760
+ class="color-swatch"
761
+ data-color="#c084fc"
762
+ style="background: #c084fc"
763
+ title="Purple"
764
+ ></button>
765
+ <button
766
+ type="button"
767
+ class="color-swatch"
768
+ data-color="#f87171"
769
+ style="background: #f87171"
770
+ title="Red"
771
+ ></button>
772
+ <button
773
+ type="button"
774
+ class="color-swatch"
775
+ data-color="#22d3ee"
776
+ style="background: #22d3ee"
777
+ title="Cyan"
778
+ ></button>
779
+ <button
780
+ type="button"
781
+ class="color-swatch"
782
+ data-color="#fb923c"
783
+ style="background: #fb923c"
784
+ title="Orange"
785
+ ></button>
786
+ <button
787
+ type="button"
788
+ class="color-swatch"
789
+ data-color="#f472b6"
790
+ style="background: #f472b6"
791
+ title="Pink"
792
+ ></button>
648
793
  </div>
649
794
  <label for="sess-cwd">Working Directory</label>
650
795
  <div class="cwd-picker">
@@ -707,15 +852,19 @@
707
852
 
708
853
  <script>
709
854
  // Theme
710
- function getTheme() { return localStorage.getItem('termbeam-theme') || 'dark'; }
855
+ function getTheme() {
856
+ return localStorage.getItem('termbeam-theme') || 'dark';
857
+ }
711
858
  function applyTheme(theme) {
712
859
  document.documentElement.setAttribute('data-theme', theme);
713
860
  document.querySelector('meta[name="theme-color"]').content =
714
861
  theme === 'light' ? '#f3f3f3' : '#1e1e1e';
715
862
  const btn = document.getElementById('theme-toggle');
716
- if (btn) btn.innerHTML = theme === 'light'
717
- ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
718
- : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
863
+ if (btn)
864
+ btn.innerHTML =
865
+ theme === 'light'
866
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
867
+ : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
719
868
  localStorage.setItem('termbeam-theme', theme);
720
869
  }
721
870
  applyTheme(getTheme());
@@ -771,13 +920,15 @@
771
920
 
772
921
  // Attach swipe handlers and click handlers after rendering
773
922
  listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
774
- listEl.querySelectorAll('[data-delete-id]').forEach(btn => {
923
+ listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
775
924
  btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
776
925
  });
777
- listEl.querySelectorAll('[data-nav-id]').forEach(card => {
778
- card.addEventListener('click', () => { location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId); });
926
+ listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
927
+ card.addEventListener('click', () => {
928
+ location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
929
+ });
779
930
  });
780
- listEl.querySelectorAll('.dot[data-color]').forEach(dot => {
931
+ listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
781
932
  dot.style.background = dot.dataset.color || 'var(--success)';
782
933
  });
783
934
  }
@@ -803,7 +954,9 @@
803
954
  document.getElementById('color-picker').addEventListener('click', (e) => {
804
955
  const swatch = e.target.closest('.color-swatch');
805
956
  if (!swatch) return;
806
- document.querySelectorAll('#color-picker .color-swatch').forEach(s => s.classList.remove('selected'));
957
+ document
958
+ .querySelectorAll('#color-picker .color-swatch')
959
+ .forEach((s) => s.classList.remove('selected'));
807
960
  swatch.classList.add('selected');
808
961
  });
809
962
 
@@ -962,7 +1115,7 @@
962
1115
  document.getElementById('browse-btn').addEventListener('click', async () => {
963
1116
  if (hubServerCwd === '/') {
964
1117
  try {
965
- const data = await fetch('/api/shells').then(r => r.json());
1118
+ const data = await fetch('/api/shells').then((r) => r.json());
966
1119
  if (data.cwd) hubServerCwd = data.cwd;
967
1120
  } catch {}
968
1121
  }
@@ -994,7 +1147,9 @@
994
1147
  const data = await res.json();
995
1148
  let items = '';
996
1149
  // Add parent (..) entry unless at root
997
- const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
1150
+ const parent =
1151
+ dir.replace(/[/\\][^/\\]+$/, '') ||
1152
+ (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
998
1153
  if (parent && parent !== dir) {
999
1154
  items += `<div class="folder-item" data-path="${esc(parent)}">
1000
1155
  <span class="folder-icon">📁</span>
@@ -1061,33 +1216,91 @@
1061
1216
  ta.value = text;
1062
1217
  ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1063
1218
  document.body.appendChild(ta);
1219
+ ta.focus();
1064
1220
  ta.select();
1065
- try { document.execCommand('copy'); } catch {}
1221
+ let ok = false;
1222
+ try {
1223
+ ok = document.execCommand('copy');
1224
+ } catch {}
1066
1225
  document.body.removeChild(ta);
1226
+ return ok;
1067
1227
  }
1068
1228
 
1069
- function showShareToast(msg) {
1229
+ function showShareToast(msg, duration) {
1070
1230
  const toast = document.createElement('div');
1071
1231
  toast.textContent = msg;
1072
- toast.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);background:var(--surface);color:var(--text);border:1px solid var(--border);padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:200;';
1232
+ toast.style.cssText =
1233
+ 'position:fixed;top:16px;left:50%;transform:translateX(-50%);background:var(--surface);color:var(--text);border:1px solid var(--border);padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:200;';
1073
1234
  document.body.appendChild(toast);
1074
- setTimeout(() => toast.remove(), 1500);
1235
+ setTimeout(() => toast.remove(), duration || 1500);
1236
+ }
1237
+
1238
+ function showShareUrlPrompt(url) {
1239
+ const overlay = document.createElement('div');
1240
+ overlay.style.cssText =
1241
+ 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;display:flex;align-items:center;justify-content:center;';
1242
+ const box = document.createElement('div');
1243
+ box.style.cssText =
1244
+ 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:90vw;width:360px;text-align:center;';
1245
+ box.innerHTML =
1246
+ '<div style="font-size:14px;font-weight:600;color:var(--text);margin-bottom:12px;">Copy this link</div>';
1247
+ const input = document.createElement('input');
1248
+ input.type = 'text';
1249
+ input.readOnly = true;
1250
+ input.value = url;
1251
+ input.style.cssText =
1252
+ 'width:100%;box-sizing:border-box;padding:8px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;margin-bottom:12px;';
1253
+ box.appendChild(input);
1254
+ const btn = document.createElement('button');
1255
+ btn.textContent = 'Close';
1256
+ btn.style.cssText =
1257
+ 'padding:6px 20px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;';
1258
+ btn.onclick = () => overlay.remove();
1259
+ box.appendChild(btn);
1260
+ overlay.appendChild(box);
1261
+ overlay.addEventListener('click', (e) => {
1262
+ if (e.target === overlay) overlay.remove();
1263
+ });
1264
+ document.body.appendChild(overlay);
1265
+ input.focus();
1266
+ input.select();
1075
1267
  }
1076
1268
 
1077
1269
  document.getElementById('share-btn').addEventListener('click', async () => {
1078
- const url = location.href;
1270
+ const urlPromise = fetch('/api/share-token')
1271
+ .then((r) => (r.ok ? r.json() : null))
1272
+ .then((data) => (data && data.url) || location.href)
1273
+ .catch(() => location.href);
1274
+ // ClipboardItem with a promise preserves user activation across the fetch
1275
+ if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
1276
+ try {
1277
+ const blobPromise = urlPromise.then((u) => new Blob([u], { type: 'text/plain' }));
1278
+ await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blobPromise })]);
1279
+ showShareToast('Link copied!');
1280
+ return;
1281
+ } catch {}
1282
+ }
1283
+ // Fallback: resolve URL first, then try legacy methods
1284
+ const url = await urlPromise;
1079
1285
  if (navigator.clipboard && navigator.clipboard.writeText) {
1080
- try { await navigator.clipboard.writeText(url); showShareToast('Link copied!'); return; } catch {}
1286
+ try {
1287
+ await navigator.clipboard.writeText(url);
1288
+ showShareToast('Link copied!');
1289
+ return;
1290
+ } catch {}
1291
+ }
1292
+ if (copyToClipboardFallback(url)) {
1293
+ showShareToast('Link copied!');
1294
+ } else {
1295
+ showShareUrlPrompt(url);
1081
1296
  }
1082
- copyToClipboardFallback(url);
1083
- showShareToast('Link copied!');
1084
1297
  });
1085
1298
 
1086
1299
  // Refresh button: clear SW cache and reload
1087
1300
  document.getElementById('refresh-btn').addEventListener('click', async () => {
1088
1301
  if ('caches' in window) {
1089
1302
  const keys = await caches.keys();
1090
- await Promise.all(keys.map(k => caches.delete(k)));
1303
+ await Promise.all(keys.map((k) => caches.delete(k)));
1091
1304
  }
1092
1305
  if (navigator.serviceWorker) {
1093
1306
  const reg = await navigator.serviceWorker.getRegistration();