termbeam 0.0.9 → 0.1.1

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
@@ -13,7 +13,7 @@ I built this because I kept needing to run quick commands on my dev machine whil
13
13
 
14
14
  [Full documentation](https://dorlugasigal.github.io/TermBeam/)
15
15
 
16
- https://github.com/user-attachments/assets/c91ca15d-0c84-400f-bbfa-3d58d1be07ee
16
+ https://github.com/user-attachments/assets/9dd4f3d7-f017-4314-9b3a-f6a5688e3671
17
17
 
18
18
  ## Quick Start
19
19
 
@@ -30,19 +30,33 @@ termbeam
30
30
 
31
31
  Scan the QR code printed in your terminal, or open the URL on any device.
32
32
 
33
- ### Password protection (recommended)
33
+ ### Secure by default
34
34
 
35
- ```bash
36
- termbeam --generate-password
35
+ TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
37
36
 
38
- # or set your own
39
- termbeam --password mysecret
37
+ ```bash
38
+ termbeam # tunnel + auto-password (default)
39
+ termbeam --password mysecret # use a specific password
40
+ termbeam --no-tunnel # LAN-only (no tunnel)
41
+ termbeam --no-password # disable password protection
40
42
  ```
41
43
 
42
44
  ## Features
43
45
 
44
- - **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Ctrl shortcuts, Esc) and swipe-to-delete session management
45
- - **Multiple sessions** running simultaneously, managed from a single hub page shows connected client count per session
46
+ - **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Enter, Ctrl shortcuts, Esc) and touch-optimized controls
47
+ - **Copy/paste support** Copy button opens text overlay for finger-selectable terminal content; Paste button with clipboard API + fallback modal
48
+ - **Image paste** — paste images from clipboard, uploaded to server
49
+ - **Tabbed multi-session terminal** — open, switch, and manage multiple sessions from a single tab bar with drag-to-reorder
50
+ - **Split view** — view two sessions side-by-side (horizontal on desktop, vertical on mobile)
51
+ - **Session colors** — assign a color to each session for quick identification
52
+ - **Activity indicators** — see how recently each session had output (e.g. "3s ago", "5m ago")
53
+ - **Tab previews** — hover (desktop) or long-press (mobile) a tab to preview the last few lines of output
54
+ - **Side panel** (mobile) — slide-out session list with output previews for quick switching
55
+ - **Create sessions anywhere** — new session modal available from both the hub page and the terminal page
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)
58
+ - **Refresh button** — clear PWA/service worker cache and reload to get the latest version
59
+ - **iPhone PWA safe area** — full support for `viewport-fit=cover` and safe area insets on notched devices
46
60
  - **Password auth** with token-based cookies and rate-limited login
47
61
  - **Folder browser** to pick working directories without typing paths
48
62
  - **Initial command** — optionally launch a session straight into `htop`, `vim`, or any command
@@ -55,11 +69,14 @@ termbeam --password mysecret
55
69
  ## Remote Access
56
70
 
57
71
  ```bash
58
- # One-off tunnel (deleted on shutdown)
59
- termbeam --tunnel --generate-password
72
+ # Tunnel is on by default
73
+ termbeam
60
74
 
61
75
  # Persisted tunnel (stable URL you can bookmark, reused across restarts, 30-day expiry)
62
- termbeam --persisted-tunnel --generate-password
76
+ termbeam --persisted-tunnel
77
+
78
+ # LAN-only (no tunnel)
79
+ termbeam --no-tunnel
63
80
  ```
64
81
 
65
82
  Requires the [Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
@@ -78,20 +95,22 @@ termbeam --port 8080 # custom port (default: 3456)
78
95
  termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
79
96
  ```
80
97
 
81
- | Flag | Description | Default |
82
- | --------------------- | ---------------------------------------- | ----------- |
83
- | `--password <pw>` | Set access password | None |
84
- | `--generate-password` | Auto-generate a secure password | — |
85
- | `--tunnel` | Create an ephemeral devtunnel URL | Off |
86
- | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
87
- | `--port <port>` | Server port | `3456` |
88
- | `--host <addr>` | Bind address | `0.0.0.0` |
98
+ | Flag | Description | Default |
99
+ | --------------------- | ---------------------------------------- | ---------------- |
100
+ | `--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` |
89
108
 
90
- Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD` (see [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/)).
109
+ Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
91
110
 
92
111
  ## Security
93
112
 
94
- TermBeam binds to all interfaces (`0.0.0.0`) by default, so it's accessible on your local network out of the box. **Always set a password** when running on a shared network, or pass `--host 127.0.0.1` to restrict access to your machine only.
113
+ 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.
95
114
 
96
115
  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.
97
116
 
@@ -105,4 +124,4 @@ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
105
124
 
106
125
  ## Acknowledgments
107
126
 
108
- Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
127
+ Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
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
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta
6
6
  name="viewport"
7
- content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
7
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
8
8
  />
9
9
  <meta name="apple-mobile-web-app-capable" content="yes" />
10
10
  <meta name="mobile-web-app-capable" content="yes" />
@@ -85,6 +85,28 @@
85
85
  color: var(--text-secondary);
86
86
  margin-top: 4px;
87
87
  }
88
+ .header-btn {
89
+ position: absolute;
90
+ top: 16px;
91
+ background: none;
92
+ border: 1px solid var(--border);
93
+ color: var(--text-dim);
94
+ width: 32px;
95
+ height: 32px;
96
+ border-radius: 8px;
97
+ cursor: pointer;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ font-size: 16px;
102
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
103
+ -webkit-tap-highlight-color: transparent;
104
+ }
105
+ .header-btn:hover {
106
+ color: var(--text);
107
+ border-color: var(--border-subtle);
108
+ background: var(--border);
109
+ }
88
110
  .theme-toggle {
89
111
  position: absolute;
90
112
  top: 16px;
@@ -111,7 +133,7 @@
111
133
 
112
134
  .sessions-list {
113
135
  padding: 16px;
114
- padding-bottom: 80px;
136
+ padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
115
137
  display: flex;
116
138
  flex-direction: column;
117
139
  gap: 12px;
@@ -235,9 +257,9 @@
235
257
 
236
258
  .new-session {
237
259
  position: fixed;
238
- bottom: 16px;
239
- left: 16px;
240
- right: 16px;
260
+ bottom: calc(16px + env(safe-area-inset-bottom, 0px));
261
+ left: calc(16px + env(safe-area-inset-left, 0px));
262
+ right: calc(16px + env(safe-area-inset-right, 0px));
241
263
  padding: 14px;
242
264
  background: var(--accent);
243
265
  color: #ffffff;
@@ -250,7 +272,6 @@
250
272
  z-index: 50;
251
273
  transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
252
274
  box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
253
- padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
254
275
  }
255
276
  .new-session:hover {
256
277
  background: var(--accent-hover);
@@ -563,12 +584,39 @@
563
584
  background: var(--accent-active);
564
585
  transform: scale(0.98);
565
586
  }
587
+
588
+ .color-picker {
589
+ display: flex;
590
+ gap: 8px;
591
+ padding: 6px 0;
592
+ flex-wrap: wrap;
593
+ }
594
+ .color-swatch {
595
+ width: 32px;
596
+ height: 32px;
597
+ border-radius: 50%;
598
+ border: 3px solid transparent;
599
+ cursor: pointer;
600
+ transition: border-color 0.15s, transform 0.1s;
601
+ -webkit-tap-highlight-color: transparent;
602
+ padding: 0;
603
+ outline: none;
604
+ }
605
+ .color-swatch:hover {
606
+ transform: scale(1.1);
607
+ }
608
+ .color-swatch.selected {
609
+ border-color: var(--text);
610
+ transform: scale(1.15);
611
+ }
566
612
  </style>
567
613
  </head>
568
614
  <body>
569
615
  <div class="header">
570
616
  <h1>📡 Term<span>Beam</span></h1>
571
617
  <p>Beam your terminal to any device · <span id="version" style="color: var(--accent)"></span></p>
618
+ <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>
619
+ <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>
572
620
  <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>
573
621
  </div>
574
622
 
@@ -586,9 +634,20 @@
586
634
  </select>
587
635
  <label for="sess-cmd">Initial Command <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
588
636
  <input type="text" id="sess-cmd" placeholder="e.g. copilot, htop, vim" />
637
+ <label>Color</label>
638
+ <div class="color-picker" id="color-picker">
639
+ <button type="button" class="color-swatch selected" data-color="#4a9eff" style="background:#4a9eff" title="Blue"></button>
640
+ <button type="button" class="color-swatch" data-color="#4ade80" style="background:#4ade80" title="Green"></button>
641
+ <button type="button" class="color-swatch" data-color="#fbbf24" style="background:#fbbf24" title="Amber"></button>
642
+ <button type="button" class="color-swatch" data-color="#c084fc" style="background:#c084fc" title="Purple"></button>
643
+ <button type="button" class="color-swatch" data-color="#f87171" style="background:#f87171" title="Red"></button>
644
+ <button type="button" class="color-swatch" data-color="#22d3ee" style="background:#22d3ee" title="Cyan"></button>
645
+ <button type="button" class="color-swatch" data-color="#fb923c" style="background:#fb923c" title="Orange"></button>
646
+ <button type="button" class="color-swatch" data-color="#f472b6" style="background:#f472b6" title="Pink"></button>
647
+ </div>
589
648
  <label for="sess-cwd">Working Directory</label>
590
649
  <div class="cwd-picker">
591
- <input type="text" id="sess-cwd" placeholder="/Users/dorlugasigal" />
650
+ <input type="text" id="sess-cwd" placeholder="Uses server default" />
592
651
  <button type="button" class="cwd-browse-btn" id="browse-btn" title="Browse folders">
593
652
  <svg
594
653
  width="18"
@@ -666,6 +725,15 @@
666
725
  const listEl = document.getElementById('sessions-list');
667
726
  const modal = document.getElementById('modal');
668
727
 
728
+ function getActivityLabel(ts) {
729
+ if (!ts) return '';
730
+ const diff = (Date.now() - ts) / 1000;
731
+ if (diff < 10) return 'Active now';
732
+ if (diff < 60) return Math.floor(diff) + 's ago';
733
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
734
+ return Math.floor(diff / 3600) + 'h ago';
735
+ }
736
+
669
737
  async function loadSessions() {
670
738
  const res = await fetch('/api/sessions');
671
739
  const sessions = await res.json();
@@ -678,19 +746,20 @@
678
746
  listEl.innerHTML = sessions
679
747
  .map(
680
748
  (s) => `
681
- <div class="swipe-wrap" data-session-id="${s.id}">
749
+ <div class="swipe-wrap" data-session-id="${esc(s.id)}">
682
750
  <div class="swipe-delete">
683
- <button onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
751
+ <button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
684
752
  </div>
685
- <div class="session-card" onclick="location.href='/terminal?id=${s.id}'">
753
+ <div class="session-card" data-nav-id="${esc(s.id)}">
686
754
  <div class="top">
687
- <div class="name"><span class="dot"></span>${esc(s.name)}</div>
755
+ <div class="name"><span class="dot" data-color="${esc(s.color || '')}"></span>${esc(s.name)}</div>
688
756
  <span class="pid">PID ${s.pid}</span>
689
757
  </div>
690
758
  <div class="details">
691
759
  <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> ${esc(s.cwd)}</span>
692
760
  <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> ${esc(s.shell)}</span>
693
761
  <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> ${s.clients} connected</span>
762
+ <span title="Last activity">${getActivityLabel(s.lastActivity)}</span>
694
763
  </div>
695
764
  <button class="connect-btn">Connect →</button>
696
765
  </div>
@@ -699,8 +768,17 @@
699
768
  )
700
769
  .join('');
701
770
 
702
- // Attach swipe handlers after rendering
771
+ // Attach swipe handlers and click handlers after rendering
703
772
  listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
773
+ listEl.querySelectorAll('[data-delete-id]').forEach(btn => {
774
+ btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
775
+ });
776
+ listEl.querySelectorAll('[data-nav-id]').forEach(card => {
777
+ card.addEventListener('click', () => { location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId); });
778
+ });
779
+ listEl.querySelectorAll('.dot[data-color]').forEach(dot => {
780
+ dot.style.background = dot.dataset.color || 'var(--success)';
781
+ });
704
782
  }
705
783
 
706
784
  function esc(str) {
@@ -720,17 +798,28 @@
720
798
  if (e.target === modal) modal.classList.remove('visible');
721
799
  });
722
800
 
801
+ // Color picker
802
+ document.getElementById('color-picker').addEventListener('click', (e) => {
803
+ const swatch = e.target.closest('.color-swatch');
804
+ if (!swatch) return;
805
+ document.querySelectorAll('#color-picker .color-swatch').forEach(s => s.classList.remove('selected'));
806
+ swatch.classList.add('selected');
807
+ });
808
+
723
809
  document.getElementById('modal-create').addEventListener('click', async () => {
724
810
  const name = document.getElementById('sess-name').value.trim();
725
811
  const shell = document.getElementById('sess-shell').value.trim();
726
812
  const cwd = document.getElementById('sess-cwd').value.trim();
727
813
  const initialCommand = document.getElementById('sess-cmd').value.trim();
728
814
 
815
+ const colorEl = document.querySelector('#color-picker .color-swatch.selected');
816
+ const color = colorEl ? colorEl.dataset.color : null;
729
817
  const body = {};
730
818
  if (name) body.name = name;
731
819
  if (shell) body.shell = shell;
732
820
  if (cwd) body.cwd = cwd;
733
821
  if (initialCommand) body.initialCommand = initialCommand;
822
+ if (color) body.color = color;
734
823
 
735
824
  const res = await fetch('/api/sessions', {
736
825
  method: 'POST',
@@ -749,6 +838,10 @@
749
838
  try {
750
839
  const res = await fetch('/api/shells');
751
840
  const data = await res.json();
841
+ if (data.cwd) {
842
+ document.getElementById('sess-cwd').placeholder = data.cwd;
843
+ hubServerCwd = data.cwd;
844
+ }
752
845
  shellSelect.innerHTML = '';
753
846
  for (const s of data.shells) {
754
847
  const opt = document.createElement('option');
@@ -863,9 +956,10 @@
863
956
  const browserBreadcrumb = document.getElementById('browser-breadcrumb');
864
957
  const browserPath = document.getElementById('browser-path');
865
958
  let currentBrowsePath = '/';
959
+ let hubServerCwd = '/';
866
960
 
867
961
  document.getElementById('browse-btn').addEventListener('click', () => {
868
- const initial = cwdInput.value.trim() || '/';
962
+ const initial = cwdInput.value.trim() || hubServerCwd;
869
963
  navigateTo(initial);
870
964
  browserOverlay.classList.add('visible');
871
965
  });
@@ -891,13 +985,19 @@
891
985
  try {
892
986
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
893
987
  const data = await res.json();
894
- if (!data.dirs.length) {
895
- browserList.innerHTML = '<div class="browser-empty">No subfolders</div>';
896
- return;
988
+ let items = '';
989
+ // Add parent (..) entry unless at root
990
+ const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
991
+ if (parent && parent !== dir) {
992
+ items += `<div class="folder-item" data-path="${esc(parent)}">
993
+ <span class="folder-icon">📁</span>
994
+ <span class="folder-name">..</span>
995
+ <span class="folder-arrow">›</span>
996
+ </div>`;
897
997
  }
898
- browserList.innerHTML = data.dirs
998
+ items += data.dirs
899
999
  .map((d) => {
900
- const name = d.split('/').pop();
1000
+ const name = d.split(/[/\\]/).pop();
901
1001
  return `<div class="folder-item" data-path="${esc(d)}">
902
1002
  <span class="folder-icon">📁</span>
903
1003
  <span class="folder-name">${esc(name)}</span>
@@ -906,6 +1006,7 @@
906
1006
  })
907
1007
  .join('');
908
1008
 
1009
+ browserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
909
1010
  browserList.querySelectorAll('.folder-item').forEach((el) => {
910
1011
  el.addEventListener('click', () => navigateTo(el.dataset.path));
911
1012
  });
@@ -916,13 +1017,15 @@
916
1017
  }
917
1018
 
918
1019
  function renderBreadcrumb(dir) {
919
- const parts = dir.split('/').filter(Boolean);
920
- let html = `<button class="crumb" data-path="/">/</button>`;
921
- let accumulated = '';
1020
+ const sep = dir.includes('\\') ? '\\' : '/';
1021
+ const parts = dir.split(/[/\\]/).filter(Boolean);
1022
+ const isWindows = /^[A-Za-z]:/.test(dir);
1023
+ let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
1024
+ let accumulated = isWindows ? '' : '';
922
1025
  parts.forEach((part, i) => {
923
- accumulated += '/' + part;
1026
+ accumulated += (i === 0 && isWindows ? '' : sep) + part;
924
1027
  const isCurrent = i === parts.length - 1;
925
- html += `<span class="crumb-sep">›</span>`;
1028
+ if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
926
1029
  html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${esc(accumulated)}">${esc(part)}</button>`;
927
1030
  });
928
1031
  browserBreadcrumb.innerHTML = html;
@@ -942,8 +1045,50 @@
942
1045
  .catch(() => {});
943
1046
 
944
1047
  loadSessions();
1048
+ loadShells();
945
1049
  setInterval(loadSessions, 3000);
946
1050
 
1051
+ // Share button
1052
+ function copyToClipboardFallback(text) {
1053
+ const ta = document.createElement('textarea');
1054
+ ta.value = text;
1055
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1056
+ document.body.appendChild(ta);
1057
+ ta.select();
1058
+ try { document.execCommand('copy'); } catch {}
1059
+ document.body.removeChild(ta);
1060
+ }
1061
+
1062
+ function showShareToast(msg) {
1063
+ const toast = document.createElement('div');
1064
+ toast.textContent = msg;
1065
+ 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;';
1066
+ document.body.appendChild(toast);
1067
+ setTimeout(() => toast.remove(), 1500);
1068
+ }
1069
+
1070
+ document.getElementById('share-btn').addEventListener('click', async () => {
1071
+ const url = location.href;
1072
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1073
+ try { await navigator.clipboard.writeText(url); showShareToast('Link copied!'); return; } catch {}
1074
+ }
1075
+ copyToClipboardFallback(url);
1076
+ showShareToast('Link copied!');
1077
+ });
1078
+
1079
+ // Refresh button: clear SW cache and reload
1080
+ document.getElementById('refresh-btn').addEventListener('click', async () => {
1081
+ if ('caches' in window) {
1082
+ const keys = await caches.keys();
1083
+ await Promise.all(keys.map(k => caches.delete(k)));
1084
+ }
1085
+ if (navigator.serviceWorker) {
1086
+ const reg = await navigator.serviceWorker.getRegistration();
1087
+ if (reg) await reg.update();
1088
+ }
1089
+ location.reload();
1090
+ });
1091
+
947
1092
  if ('serviceWorker' in navigator) {
948
1093
  navigator.serviceWorker.register('/sw.js').catch(() => {});
949
1094
  }
package/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'termbeam-v1';
1
+ const CACHE_NAME = 'termbeam-v5';
2
2
  const SHELL_URLS = ['/', '/terminal'];
3
3
 
4
4
  self.addEventListener('install', (event) => {
@@ -20,16 +20,34 @@ self.addEventListener('activate', (event) => {
20
20
  self.addEventListener('fetch', (event) => {
21
21
  const url = new URL(event.request.url);
22
22
 
23
- // Don't cache WebSocket upgrades or external resources
23
+ // Don't cache WebSocket upgrades
24
24
  if (
25
25
  event.request.mode === 'websocket' ||
26
26
  url.protocol === 'ws:' ||
27
- url.protocol === 'wss:' ||
28
- url.origin !== self.location.origin
27
+ url.protocol === 'wss:'
29
28
  ) {
30
29
  return;
31
30
  }
32
31
 
32
+ // Cache-first for CDN font files (NerdFont from jsdelivr)
33
+ if (url.origin !== self.location.origin) {
34
+ if (url.hostname === 'cdn.jsdelivr.net' && url.pathname.endsWith('.ttf')) {
35
+ event.respondWith(
36
+ caches.match(event.request).then((cached) => {
37
+ if (cached) return cached;
38
+ return fetch(event.request).then((response) => {
39
+ if (response.ok) {
40
+ const clone = response.clone();
41
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
42
+ }
43
+ return response;
44
+ });
45
+ })
46
+ );
47
+ }
48
+ return;
49
+ }
50
+
33
51
  // Network-first for API calls
34
52
  if (url.pathname.startsWith('/api/')) {
35
53
  event.respondWith(
@@ -38,7 +56,21 @@ self.addEventListener('fetch', (event) => {
38
56
  return;
39
57
  }
40
58
 
41
- // Cache-first for static assets
59
+ // Network-first for HTML pages (always get latest code)
60
+ if (event.request.mode === 'navigate' || event.request.headers.get('accept')?.includes('text/html')) {
61
+ event.respondWith(
62
+ fetch(event.request).then((response) => {
63
+ if (response.ok) {
64
+ const clone = response.clone();
65
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
66
+ }
67
+ return response;
68
+ }).catch(() => caches.match(event.request))
69
+ );
70
+ return;
71
+ }
72
+
73
+ // Cache-first for static assets (JS, CSS, images)
42
74
  event.respondWith(
43
75
  caches.match(event.request).then((cached) => {
44
76
  if (cached) return cached;
@@ -49,6 +81,6 @@ self.addEventListener('fetch', (event) => {
49
81
  }
50
82
  return response;
51
83
  });
52
- })
84
+ }).catch(() => new Response('Offline', { status: 503, statusText: 'Service Unavailable' }))
53
85
  );
54
86
  });