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 +41 -22
- package/package.json +1 -1
- package/public/index.html +168 -23
- package/public/sw.js +38 -6
- package/public/terminal.html +1933 -515
- package/src/auth.js +14 -0
- package/src/cli.js +32 -8
- package/src/routes.js +74 -5
- package/src/server.js +19 -13
- package/src/sessions.js +29 -4
- package/src/shells.js +1 -1
- package/src/tunnel.js +19 -13
- package/src/websocket.js +30 -5
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/
|
|
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
|
-
###
|
|
33
|
+
### Secure by default
|
|
34
34
|
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
termbeam
|
|
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
|
|
45
|
-
- **
|
|
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
|
-
#
|
|
59
|
-
termbeam
|
|
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
|
|
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
|
|
84
|
-
| `--
|
|
85
|
-
| `--
|
|
86
|
-
| `--
|
|
87
|
-
| `--
|
|
88
|
-
| `--
|
|
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` (
|
|
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
|
|
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
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="
|
|
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
|
|
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"
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
998
|
+
items += data.dirs
|
|
899
999
|
.map((d) => {
|
|
900
|
-
const name = d.split(
|
|
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
|
|
920
|
-
|
|
921
|
-
|
|
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 += '
|
|
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-
|
|
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
|
|
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
|
-
//
|
|
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
|
});
|