webtun 1.4.3 → 1.4.6
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 +44 -30
- package/package.json +1 -1
- package/postinstall.js +3 -3
- package/public/index.html +604 -27
- package/server.js +164 -16
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|

|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
**Access your
|
|
9
|
+
**Access your server from any browser — no VPN, no SSH client, no installing anything.**
|
|
10
10
|
|
|
11
11
|
[](install.sh)
|
|
12
12
|
[](https://github.com/unn-known1/webtun/releases/latest)
|
|
@@ -97,6 +97,17 @@ webtun
|
|
|
97
97
|
| `--help` | Show help |
|
|
98
98
|
| `--version` | Show version |
|
|
99
99
|
|
|
100
|
+
**Environment Variables:**
|
|
101
|
+
| Variable | Description |
|
|
102
|
+
|----------|-------------|
|
|
103
|
+
| `PORT` | Server port (default: 3000) |
|
|
104
|
+
| `HOST` | Bind address (default: 0.0.0.0) |
|
|
105
|
+
| `PIN` | Authentication PIN (empty = no auth) |
|
|
106
|
+
| `SHELL` | Shell to use (default: PowerShell on Windows, bash/sh elsewhere) |
|
|
107
|
+
| `WORKSPACE_ROOT` | Root directory for file operations (default: ~) |
|
|
108
|
+
| `TRUST_PROXY` | Set to `true` if behind a reverse proxy (default: loopback only) |
|
|
109
|
+
| `WEBTUN_SHELL` | Override shell on Windows (e.g., `/usr/bin/bash` for Git Bash) |
|
|
110
|
+
|
|
100
111
|
**Examples:**
|
|
101
112
|
```bash
|
|
102
113
|
npx webtun # Start on port 3000
|
|
@@ -148,35 +159,35 @@ npm start
|
|
|
148
159
|
## Architecture
|
|
149
160
|
|
|
150
161
|
```
|
|
151
|
-
|
|
152
|
-
│
|
|
153
|
-
│
|
|
154
|
-
│ │Terminal │ │
|
|
155
|
-
│ │
|
|
156
|
-
│
|
|
157
|
-
│
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
│
|
|
163
|
-
│
|
|
164
|
-
│ │ WebSocket
|
|
165
|
-
│ │
|
|
166
|
-
│
|
|
167
|
-
│
|
|
168
|
-
│
|
|
169
|
-
│
|
|
170
|
-
│ │ node-pty│ │Local Filesystem│ │ cloudflared daemon
|
|
171
|
-
│ │
|
|
172
|
-
│
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
162
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
163
|
+
│ Your Browser │
|
|
164
|
+
│ ┌──────────┐ ┌───────────┐ ┌─────────────┐ ┌───────────┐ │
|
|
165
|
+
│ │ Terminal │ │ File │ │ Tunnel │ │ Settings │ │
|
|
166
|
+
│ │ (xterm) │ │ Explorer │ │ Manager │ │ Panel │ │
|
|
167
|
+
│ └─────┬────┘ └─────┬─────┘ └──────┬──────┘ └─────┬─────┘ │
|
|
168
|
+
│ │ │ │ │ │
|
|
169
|
+
└────────┼─────────────┼───────────────┼───────────────┼─────────┘
|
|
170
|
+
│ WebSocket │ REST API │ │
|
|
171
|
+
▼ ▼ ▼ │
|
|
172
|
+
┌────────────────────────────────────────────────────────────────┐
|
|
173
|
+
│ Node.js Server │
|
|
174
|
+
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
|
175
|
+
│ │ WebSocket │ │ File API │ │ Cloudflare Tunnel │ │
|
|
176
|
+
│ │ Handler │ │ (read/write) │ │ Manager │ │
|
|
177
|
+
│ └──────┬──────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
|
178
|
+
│ │ │ │ │
|
|
179
|
+
│ ▼ ▼ ▼ │
|
|
180
|
+
│ ┌──────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
|
|
181
|
+
│ │ node-pty │ │ Local Filesystem │ │ cloudflared daemon │ │
|
|
182
|
+
│ │ (shell) │ │ (your server) │ │ (exposes to internet)│ │
|
|
183
|
+
│ └──────────┘ └──────────────────┘ └──────────────────────┘ │
|
|
184
|
+
└────────────────────────────────────────────────────────────────┘
|
|
185
|
+
│
|
|
186
|
+
▼
|
|
187
|
+
┌─────────────────┐
|
|
188
|
+
│ Internet Users │
|
|
189
|
+
│ (HTTPS URL) │
|
|
190
|
+
└─────────────────┘
|
|
180
191
|
```
|
|
181
192
|
|
|
182
193
|
---
|
|
@@ -231,6 +242,9 @@ kill $(pgrep -f 'cloudflared tunnel')
|
|
|
231
242
|
| Permission denied on shell | Ensure user has shell access: `chsh -s /bin/bash` |
|
|
232
243
|
| File upload fails | Check `public/uploads/` permissions: `chmod 755 public/uploads/` |
|
|
233
244
|
| Port 3000 in use | Change port: `PORT=3001 npm start` |
|
|
245
|
+
| Windows terminal opens PowerShell instead of Git Bash | Set `WEBTUN_SHELL=/usr/bin/bash` in `.env` |
|
|
246
|
+
| Behind reverse proxy, wrong client IP | Set `TRUST_PROXY=true` in `.env` |
|
|
247
|
+
| Tunnel persists after server restart | Tunnels are auto-managed; manual cleanup: `kill $(pgrep -f 'cloudflared tunnel')` |
|
|
234
248
|
|
|
235
249
|
---
|
|
236
250
|
|
package/package.json
CHANGED
package/postinstall.js
CHANGED
|
@@ -135,7 +135,7 @@ async function main() {
|
|
|
135
135
|
if (!downloadWithCurlOrWget()) {
|
|
136
136
|
console.log(' cloudflared install failed (download error) — skipping');
|
|
137
137
|
cleanup();
|
|
138
|
-
|
|
138
|
+
return;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -149,12 +149,12 @@ async function main() {
|
|
|
149
149
|
if (/^<!doctype\s+html/i.test(head) || /^<html/i.test(head)) {
|
|
150
150
|
console.log(' cloudflared install failed (downloaded file is not a binary) — skipping');
|
|
151
151
|
cleanup();
|
|
152
|
-
|
|
152
|
+
return;
|
|
153
153
|
}
|
|
154
154
|
} catch (e) {
|
|
155
155
|
console.log(' cloudflared install failed (cannot validate download): ' + e.message);
|
|
156
156
|
cleanup();
|
|
157
|
-
|
|
157
|
+
return;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
function installBin(src, dest) {
|
package/public/index.html
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
15
15
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
|
|
16
16
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
17
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.7.0/lib/addon-unicode11.min.js"></script>
|
|
18
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.1/lib/addon-webgl.min.js"></script>
|
|
17
19
|
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
|
|
18
20
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
|
|
19
21
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/lib/codemirror.min.css">
|
|
@@ -166,6 +168,8 @@ svg { display: block; }
|
|
|
166
168
|
.term-wrapper.active { display: flex; }
|
|
167
169
|
.xterm { flex: 1; }
|
|
168
170
|
.xterm-viewport { border-radius: 0; }
|
|
171
|
+
.term-bell-flash { animation: bellFlash 0.15s ease-out; }
|
|
172
|
+
@keyframes bellFlash { 0% { filter: brightness(1.8); } 100% { filter: brightness(1); } }
|
|
169
173
|
#terminals.tiles-mode { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-auto-rows: 1fr; gap: 4px; padding: 4px; position: relative; overflow: auto; min-height: 0; overscroll-behavior: none; }
|
|
170
174
|
#terminals.tiles-mode .term-wrapper { position: relative; inset: unset; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; min-height: 150px; }
|
|
171
175
|
#terminals.tiles-mode .term-wrapper.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
@@ -546,13 +550,18 @@ svg { display: block; }
|
|
|
546
550
|
/* ═══════════════════════════════════════════════════════
|
|
547
551
|
CONTEXT MENU
|
|
548
552
|
═══════════════════════════════════════════════════════ */
|
|
549
|
-
#ctx-menu { position: fixed; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 20px var(--shadow-color); z-index: 500; display: none; min-width: 160px; overflow: hidden; }
|
|
553
|
+
#ctx-menu { position: fixed; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 20px var(--shadow-color); z-index: 500; display: none; min-width: 160px; overflow: hidden; max-height: calc(100vh - 16px); overflow-y: auto; }
|
|
550
554
|
@media (max-width: 360px) { #ctx-menu { min-width: 140px; max-width: calc(100vw - 16px); font-size: 11px; } }
|
|
551
555
|
#ctx-menu.open { display: block; }
|
|
552
556
|
.ctx-item { padding: 8px 14px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
|
|
553
557
|
.ctx-item:hover { background: var(--bg4); }
|
|
554
558
|
.ctx-sep { height: 1px; background: var(--border); margin: 3px 0; }
|
|
555
559
|
.ctx-item.danger { color: var(--red); }
|
|
560
|
+
.ctx-submenu-wrap { }
|
|
561
|
+
.ctx-submenu-arrow { margin-left: auto; font-size: 10px; color: var(--fg3); transition: transform 0.15s; }
|
|
562
|
+
.ctx-submenu-wrap.open > .ctx-item > .ctx-submenu-arrow { transform: rotate(90deg); }
|
|
563
|
+
.ctx-submenu-items { display: none; }
|
|
564
|
+
.ctx-submenu-wrap.open > .ctx-submenu-items { display: block; }
|
|
556
565
|
|
|
557
566
|
/* ═══════════════════════════════════════════════════════
|
|
558
567
|
TOAST
|
|
@@ -656,8 +665,65 @@ svg { display: block; }
|
|
|
656
665
|
#cmd-lib-search {
|
|
657
666
|
width: calc(100% - 28px); height: 28px; font-size: 12px; margin: 8px 14px 0;
|
|
658
667
|
}
|
|
668
|
+
.cmd-lib-tabs {
|
|
669
|
+
display: flex; gap: 0; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
|
670
|
+
}
|
|
671
|
+
.cmd-lib-tab {
|
|
672
|
+
flex: 1; height: 32px; font-size: 12px; font-weight: 600; color: var(--fg2);
|
|
673
|
+
background: none; border: none; border-bottom: 2px solid transparent;
|
|
674
|
+
cursor: pointer; transition: all var(--transition-fast);
|
|
675
|
+
display: flex; align-items: center; justify-content: center; gap: 5px;
|
|
676
|
+
}
|
|
677
|
+
.cmd-lib-tab:hover { color: var(--fg); background: var(--bg3); }
|
|
678
|
+
.cmd-lib-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
|
679
|
+
#cmd-hist-list {
|
|
680
|
+
flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px; min-height: 0;
|
|
681
|
+
display: none; flex-direction: column; gap: 2px;
|
|
682
|
+
}
|
|
683
|
+
#cmd-hist-empty {
|
|
684
|
+
text-align: center; color: var(--fg2); padding: 24px 16px; font-size: 12px;
|
|
685
|
+
}
|
|
686
|
+
.cmd-hist-item {
|
|
687
|
+
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
|
688
|
+
border-radius: var(--radius-sm); cursor: default; font-size: 12px;
|
|
689
|
+
transition: background var(--transition-fast);
|
|
690
|
+
}
|
|
691
|
+
.cmd-hist-item:hover { background: var(--bg4); }
|
|
692
|
+
.cmd-hist-item .cmd-hist-cmd {
|
|
693
|
+
flex: 1; color: var(--fg); font-family: var(--font); overflow: hidden;
|
|
694
|
+
text-overflow: ellipsis; white-space: nowrap;
|
|
695
|
+
}
|
|
696
|
+
.cmd-hist-item .cmd-hist-time {
|
|
697
|
+
font-size: 10px; color: var(--fg3); flex-shrink: 0;
|
|
698
|
+
}
|
|
699
|
+
.cmd-hist-item .cmd-hist-run {
|
|
700
|
+
width: 24px; height: 24px; display: none; align-items: center; justify-content: center;
|
|
701
|
+
border-radius: var(--radius-sm); color: var(--fg2); flex-shrink: 0; cursor: pointer;
|
|
702
|
+
}
|
|
703
|
+
.cmd-hist-item:hover .cmd-hist-run { display: flex; }
|
|
704
|
+
.cmd-hist-item .cmd-hist-run:hover { background: var(--bg4); color: var(--green); }
|
|
705
|
+
.cmd-hist-item .cmd-hist-del {
|
|
706
|
+
width: 24px; height: 24px; display: none; align-items: center; justify-content: center;
|
|
707
|
+
border-radius: var(--radius-sm); color: var(--fg2); flex-shrink: 0; cursor: pointer;
|
|
708
|
+
}
|
|
709
|
+
.cmd-hist-item:hover .cmd-hist-del { display: flex; }
|
|
710
|
+
.cmd-hist-item .cmd-hist-del:hover { color: var(--red); background: var(--bg4); }
|
|
711
|
+
.cmd-hist-header {
|
|
712
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
713
|
+
padding: 6px 10px; flex-shrink: 0;
|
|
714
|
+
}
|
|
715
|
+
.cmd-hist-header label { font-size: 11px; color: var(--fg2); display: flex; align-items: center; gap: 4px; }
|
|
716
|
+
.cmd-hist-header input[type="number"] {
|
|
717
|
+
width: 52px; height: 22px; font-size: 11px; padding: 0 4px; text-align: center;
|
|
718
|
+
}
|
|
719
|
+
.cmd-hist-clear {
|
|
720
|
+
font-size: 11px; color: var(--red); background: none; border: none; cursor: pointer;
|
|
721
|
+
padding: 2px 6px; border-radius: var(--radius-sm);
|
|
722
|
+
}
|
|
723
|
+
.cmd-hist-clear:hover { background: var(--bg4); }
|
|
659
724
|
#cmd-lib-list {
|
|
660
|
-
flex: 1; overflow-y: auto; padding: 6px; min-height: 0;
|
|
725
|
+
flex: 1; overflow-y: auto; overflow-x: hidden; padding: 6px; min-height: 0;
|
|
726
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
661
727
|
}
|
|
662
728
|
#cmd-lib-empty {
|
|
663
729
|
text-align: center; color: var(--fg2); padding: 24px 16px; font-size: 12px;
|
|
@@ -953,8 +1019,19 @@ svg { display: block; }
|
|
|
953
1019
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
954
1020
|
</button>
|
|
955
1021
|
</div>
|
|
1022
|
+
<div class="cmd-lib-tabs">
|
|
1023
|
+
<button class="cmd-lib-tab active" id="cmd-tab-lib" onclick="switchCmdTab('library')">Library</button>
|
|
1024
|
+
<button class="cmd-lib-tab" id="cmd-tab-hist" onclick="switchCmdTab('history')">History</button>
|
|
1025
|
+
</div>
|
|
956
1026
|
<input id="cmd-lib-search" type="text" placeholder="Filter commands…" oninput="filterCmdLib()">
|
|
957
1027
|
<div id="cmd-lib-list"></div>
|
|
1028
|
+
<div id="cmd-hist-list">
|
|
1029
|
+
<div id="cmd-hist-empty">No commands run yet</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
<div class="cmd-hist-header" id="cmd-hist-header" style="display:none">
|
|
1032
|
+
<label>Keep last <input type="number" id="cmd-hist-max" min="10" max="500" value="50" onchange="updateHistMax(+this.value)"> cmds</label>
|
|
1033
|
+
<button class="cmd-hist-clear" onclick="clearCmdHist()">Clear History</button>
|
|
1034
|
+
</div>
|
|
958
1035
|
<div id="cmd-lib-footer">
|
|
959
1036
|
<input id="cmd-lib-name-input" type="text" placeholder="Name" onkeydown="if(event.key==='Enter')document.getElementById('cmd-lib-cmd-input').focus()">
|
|
960
1037
|
<input id="cmd-lib-cmd-input" type="text" placeholder="Command (e.g. ls -la)" onkeydown="if(event.key==='Enter')addCustomCmd()">
|
|
@@ -1193,6 +1270,40 @@ svg { display: block; }
|
|
|
1193
1270
|
<div class="ctx-item danger" id="ctx-delete" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> Delete</div>
|
|
1194
1271
|
</div>
|
|
1195
1272
|
|
|
1273
|
+
<!-- Terminal context menu -->
|
|
1274
|
+
<div id="term-ctx-menu" role="menu" style="position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 20px var(--shadow-color);z-index:500;display:none;min-width:180px;overflow:hidden">
|
|
1275
|
+
<!-- Clipboard -->
|
|
1276
|
+
<div class="ctx-item" id="term-ctx-copy" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</div>
|
|
1277
|
+
<div class="ctx-item" id="term-ctx-paste" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="14" y="2" width="8" height="8" rx="2"/><path d="M16 14H8M8 18h8"/><path d="M5 4h4"/><rect x="2" y="6" width="16" height="16" rx="2"/></svg> Paste</div>
|
|
1278
|
+
<div class="ctx-sep"></div>
|
|
1279
|
+
<div class="ctx-item" id="term-ctx-select-all" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/><path d="M3 9h18"/><path d="M3 15h18"/></svg> Select All</div>
|
|
1280
|
+
<!-- Tab / Path -->
|
|
1281
|
+
<div class="ctx-sep"></div>
|
|
1282
|
+
<div class="ctx-item" id="term-ctx-new-tab" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> New Tab Here</div>
|
|
1283
|
+
<div class="ctx-item" id="term-ctx-copy-cwd" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy Working Directory</div>
|
|
1284
|
+
<!-- Control -->
|
|
1285
|
+
<div class="ctx-sep"></div>
|
|
1286
|
+
<div class="ctx-item" id="term-ctx-interrupt" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg> Interrupt (Ctrl+C)</div>
|
|
1287
|
+
<!-- More Options -->
|
|
1288
|
+
<div class="ctx-sep"></div>
|
|
1289
|
+
<div class="ctx-submenu-wrap">
|
|
1290
|
+
<div class="ctx-item" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> More Options<span class="ctx-submenu-arrow">▸</span></div>
|
|
1291
|
+
<div class="ctx-submenu-items">
|
|
1292
|
+
<div class="ctx-item" id="term-ctx-find" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Find in Terminal</div>
|
|
1293
|
+
<div class="ctx-item" id="term-ctx-zoom-in" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg> Zoom In</div>
|
|
1294
|
+
<div class="ctx-item" id="term-ctx-zoom-out" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg> Zoom Out</div>
|
|
1295
|
+
<div class="ctx-item" id="term-ctx-select-line" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/></svg> Select Line</div>
|
|
1296
|
+
<div class="ctx-item" id="term-ctx-scroll-top" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg> Scroll to Top</div>
|
|
1297
|
+
<div class="ctx-item" id="term-ctx-scroll-bottom" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> Scroll to Bottom</div>
|
|
1298
|
+
<div class="ctx-item" id="term-ctx-rename" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg> Rename Tab</div>
|
|
1299
|
+
<div class="ctx-item" id="term-ctx-bookmark" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg> Bookmark Directory</div>
|
|
1300
|
+
</div>
|
|
1301
|
+
</div>
|
|
1302
|
+
<!-- Danger -->
|
|
1303
|
+
<div class="ctx-sep"></div>
|
|
1304
|
+
<div class="ctx-item danger" id="term-ctx-clear" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg> Clear</div>
|
|
1305
|
+
</div>
|
|
1306
|
+
|
|
1196
1307
|
<!-- Fuzzy Finder Modal -->
|
|
1197
1308
|
<div class="overlay" id="finder-overlay">
|
|
1198
1309
|
<div class="modal modal-md" style="padding:12px">
|
|
@@ -1362,6 +1473,7 @@ let settings = {
|
|
|
1362
1473
|
cursor: 'block', blink: true, scrollback: 5000, bell: false,
|
|
1363
1474
|
mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false
|
|
1364
1475
|
};
|
|
1476
|
+
let serverPlatform = '';
|
|
1365
1477
|
|
|
1366
1478
|
const isElectron = !!(window.electronAPI && window.electronAPI.isElectron);
|
|
1367
1479
|
|
|
@@ -1471,6 +1583,7 @@ async function unlockApp() {
|
|
|
1471
1583
|
const info = await api('/api/home');
|
|
1472
1584
|
homeDir = info.home;
|
|
1473
1585
|
currentPath = homeDir;
|
|
1586
|
+
serverPlatform = info.platform || '';
|
|
1474
1587
|
document.getElementById('hostname-badge').textContent = info.hostname || '—';
|
|
1475
1588
|
|
|
1476
1589
|
// Check tmux availability
|
|
@@ -1789,6 +1902,25 @@ function connectWebSocket(tab, isReconnect = false) {
|
|
|
1789
1902
|
|
|
1790
1903
|
const sendInput = data => {
|
|
1791
1904
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1905
|
+
// On Enter, read command from terminal screen buffer (avoids echo-back pollution)
|
|
1906
|
+
for (let i = 0; i < data.length; i++) {
|
|
1907
|
+
const ch = data.charCodeAt(i);
|
|
1908
|
+
if (ch === 13 || ch === 10) { // Enter
|
|
1909
|
+
try {
|
|
1910
|
+
const buf = tab.term.buffer.active;
|
|
1911
|
+
const line = buf.getLine(buf.baseY + buf.cursorY);
|
|
1912
|
+
if (line) {
|
|
1913
|
+
let text = line.translateToString(true).trim();
|
|
1914
|
+
// Strip prompt prefix: find last $ # or % and take text after it
|
|
1915
|
+
const promptEnd = Math.max(text.lastIndexOf('$'), text.lastIndexOf('#'), text.lastIndexOf('%'));
|
|
1916
|
+
if (promptEnd >= 0 && promptEnd < text.length - 1) {
|
|
1917
|
+
text = text.substring(promptEnd + 1).trim();
|
|
1918
|
+
}
|
|
1919
|
+
if (text) addToCmdHist(text);
|
|
1920
|
+
}
|
|
1921
|
+
} catch {}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1792
1924
|
const enc = new TextEncoder().encode(data);
|
|
1793
1925
|
const buf = new Uint8Array(1 + enc.length);
|
|
1794
1926
|
buf[0] = 0x00; buf.set(enc, 1);
|
|
@@ -1819,10 +1951,77 @@ function connectWebSocket(tab, isReconnect = false) {
|
|
|
1819
1951
|
setupVisualViewport();
|
|
1820
1952
|
};
|
|
1821
1953
|
|
|
1954
|
+
// OSC sequence handler: intercepts OSC 7, 133, 52 before passing to xterm.js
|
|
1955
|
+
let oscBuf = '';
|
|
1956
|
+
function processTerminalOutput(data) {
|
|
1957
|
+
const str = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
|
1958
|
+
let out = '';
|
|
1959
|
+
let i = 0;
|
|
1960
|
+
while (i < str.length) {
|
|
1961
|
+
// Look for ESC ] (OSC introducer)
|
|
1962
|
+
if (str[i] === '\x1b' && str[i + 1] === ']') {
|
|
1963
|
+
const endIdx = str.indexOf('\x07', i + 2);
|
|
1964
|
+
const stIdx = str.indexOf('\x1b\\', i + 2);
|
|
1965
|
+
let oscEnd = -1;
|
|
1966
|
+
if (endIdx !== -1 && (stIdx === -1 || endIdx < stIdx)) oscEnd = endIdx;
|
|
1967
|
+
else if (stIdx !== -1) oscEnd = stIdx;
|
|
1968
|
+
|
|
1969
|
+
if (oscEnd !== -1) {
|
|
1970
|
+
const oscData = str.substring(i + 2, oscEnd);
|
|
1971
|
+
const semi = oscData.indexOf(';');
|
|
1972
|
+
if (semi !== -1) {
|
|
1973
|
+
const code = oscData.substring(0, semi);
|
|
1974
|
+
const value = oscData.substring(semi + 1);
|
|
1975
|
+
if (code === '7') {
|
|
1976
|
+
// OSC 7: CWD update — file://hostname/path
|
|
1977
|
+
try {
|
|
1978
|
+
const url = new URL(value);
|
|
1979
|
+
tab.cwd = decodeURIComponent(url.pathname);
|
|
1980
|
+
} catch (_) {}
|
|
1981
|
+
} else if (code === '133') {
|
|
1982
|
+
// OSC 133: Shell integration markers (prompt/cmd start/end/done)
|
|
1983
|
+
// Handled silently — available for future command tracking
|
|
1984
|
+
} else if (code === '52') {
|
|
1985
|
+
// OSC 52: Clipboard operations
|
|
1986
|
+
const parts = value.split(';');
|
|
1987
|
+
const targets = parts[0] || 'c';
|
|
1988
|
+
const b64 = parts.slice(1).join(';');
|
|
1989
|
+
if (b64) {
|
|
1990
|
+
// SET clipboard
|
|
1991
|
+
try {
|
|
1992
|
+
const decoded = atob(b64);
|
|
1993
|
+
if (targets.includes('c') || targets.includes('p')) {
|
|
1994
|
+
navigator.clipboard.writeText(decoded).catch(() => {});
|
|
1995
|
+
}
|
|
1996
|
+
} catch (_) {}
|
|
1997
|
+
} else {
|
|
1998
|
+
// GET clipboard — respond with current clipboard contents
|
|
1999
|
+
navigator.clipboard.readText().then(text => {
|
|
2000
|
+
const response = '\x1b]52;c;' + btoa(text) + '\x07';
|
|
2001
|
+
if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
|
|
2002
|
+
const enc = new TextEncoder().encode(response);
|
|
2003
|
+
const buf = new Uint8Array(1 + enc.length);
|
|
2004
|
+
buf[0] = 0x00; buf.set(enc, 1);
|
|
2005
|
+
tab.ws.send(buf.buffer);
|
|
2006
|
+
}
|
|
2007
|
+
}).catch(() => {});
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
i = oscEnd + (str[oscEnd] === '\x07' ? 1 : 2);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
out += str[i];
|
|
2016
|
+
i++;
|
|
2017
|
+
}
|
|
2018
|
+
if (out) tab.term.write(out);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1822
2021
|
ws.onmessage = e => {
|
|
1823
2022
|
const buf = new Uint8Array(e.data);
|
|
1824
2023
|
const type = buf[0], payload = buf.slice(1);
|
|
1825
|
-
if (type === 0x00)
|
|
2024
|
+
if (type === 0x00) processTerminalOutput(payload);
|
|
1826
2025
|
else if (type === 0x01) tab.term.writeln('\r\n\x1b[31m[Process exited]\x1b[0m');
|
|
1827
2026
|
else if (type === 0x02) tab.term.writeln('\r\n\x1b[31m' + new TextDecoder().decode(payload) + '\x1b[0m');
|
|
1828
2027
|
};
|
|
@@ -1919,7 +2118,7 @@ function initTerminal(tab) {
|
|
|
1919
2118
|
scrollback: settings.scrollback,
|
|
1920
2119
|
allowTransparency: false,
|
|
1921
2120
|
theme: getXtermTheme(),
|
|
1922
|
-
windowsMode:
|
|
2121
|
+
windowsMode: serverPlatform === 'win32',
|
|
1923
2122
|
convertEol: false,
|
|
1924
2123
|
bellStyle: settings.bell ? 'sound' : 'none',
|
|
1925
2124
|
smoothScrollDuration: 80,
|
|
@@ -1939,6 +2138,19 @@ function initTerminal(tab) {
|
|
|
1939
2138
|
term.loadAddon(searchAddon);
|
|
1940
2139
|
term.loadAddon(webLinksAddon);
|
|
1941
2140
|
|
|
2141
|
+
try {
|
|
2142
|
+
const unicodeAddon = new Unicode11Addon.Unicode11Addon();
|
|
2143
|
+
term.loadAddon(unicodeAddon);
|
|
2144
|
+
term.unicode.activeVersion = '11';
|
|
2145
|
+
} catch (_) {}
|
|
2146
|
+
|
|
2147
|
+
// GPU-accelerated renderer — falls back to canvas if WebGL unavailable
|
|
2148
|
+
try {
|
|
2149
|
+
const webglAddon = new WebglAddon.WebglAddon();
|
|
2150
|
+
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
|
|
2151
|
+
term.loadAddon(webglAddon);
|
|
2152
|
+
} catch (_) {}
|
|
2153
|
+
|
|
1942
2154
|
term.open(tab.wrapper);
|
|
1943
2155
|
|
|
1944
2156
|
tab.term = term;
|
|
@@ -1960,6 +2172,23 @@ function initTerminal(tab) {
|
|
|
1960
2172
|
tab.el.querySelector('.tab-title').textContent = tab.title;
|
|
1961
2173
|
});
|
|
1962
2174
|
|
|
2175
|
+
// Bell: visual flash + desktop notification for background tabs
|
|
2176
|
+
term.onBell(() => {
|
|
2177
|
+
// Visual flash on terminal screen
|
|
2178
|
+
const screen = term.element?.querySelector('.xterm-screen');
|
|
2179
|
+
if (screen) {
|
|
2180
|
+
screen.classList.remove('term-bell-flash');
|
|
2181
|
+
void screen.offsetWidth; // reflow to restart animation
|
|
2182
|
+
screen.classList.add('term-bell-flash');
|
|
2183
|
+
}
|
|
2184
|
+
// Desktop notification if tab is in the background
|
|
2185
|
+
if (document.hidden && settings.bell && Notification.permission === 'granted') {
|
|
2186
|
+
try {
|
|
2187
|
+
new Notification(tab.title || 'WebTun', { body: 'Terminal bell', tag: 'webtun-bell' });
|
|
2188
|
+
} catch (_) {}
|
|
2189
|
+
}
|
|
2190
|
+
});
|
|
2191
|
+
|
|
1963
2192
|
// Resize observer
|
|
1964
2193
|
tab.resizeObserver = new ResizeObserver(() => {
|
|
1965
2194
|
if (activeTabId === tab.id) { try { fitTerm(tab); } catch(e) { console.warn(e); } }
|
|
@@ -1979,6 +2208,69 @@ function initTerminal(tab) {
|
|
|
1979
2208
|
}
|
|
1980
2209
|
});
|
|
1981
2210
|
|
|
2211
|
+
// Terminal right-click context menu
|
|
2212
|
+
term.element.addEventListener('contextmenu', e => {
|
|
2213
|
+
e.preventDefault();
|
|
2214
|
+
const menu = document.getElementById('term-ctx-menu');
|
|
2215
|
+
const sel = term.getSelection();
|
|
2216
|
+
document.getElementById('term-ctx-copy').style.opacity = sel ? '' : '0.5';
|
|
2217
|
+
// Show first to measure actual dimensions
|
|
2218
|
+
menu.style.display = 'block';
|
|
2219
|
+
const mw = menu.offsetWidth;
|
|
2220
|
+
const mh = menu.offsetHeight;
|
|
2221
|
+
const vw = window.innerWidth;
|
|
2222
|
+
const vh = window.innerHeight;
|
|
2223
|
+
let left = e.clientX;
|
|
2224
|
+
let top = e.clientY;
|
|
2225
|
+
// Flip horizontally if overflowing right
|
|
2226
|
+
if (left + mw > vw) left = Math.max(0, e.clientX - mw);
|
|
2227
|
+
// Flip vertically if overflowing bottom
|
|
2228
|
+
if (top + mh > vh) top = Math.max(0, e.clientY - mh);
|
|
2229
|
+
// Clamp to viewport
|
|
2230
|
+
left = Math.max(0, Math.min(left, vw - mw));
|
|
2231
|
+
top = Math.max(0, Math.min(top, vh - mh));
|
|
2232
|
+
menu.style.left = left + 'px';
|
|
2233
|
+
menu.style.top = top + 'px';
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
// Touch-to-mouse translation for TUI apps (mobile)
|
|
2237
|
+
let _tapTimeout = null, _tapPos = null, _longPressTimer = null;
|
|
2238
|
+
term.element.addEventListener('touchstart', e => {
|
|
2239
|
+
if (e.touches.length !== 1) return;
|
|
2240
|
+
const touch = e.touches[0];
|
|
2241
|
+
_tapPos = { x: touch.clientX, y: touch.clientY, time: Date.now() };
|
|
2242
|
+
// Long press detection for right-click
|
|
2243
|
+
_longPressTimer = setTimeout(() => {
|
|
2244
|
+
if (_tapPos) {
|
|
2245
|
+
const evt = new MouseEvent('mousedown', { clientX: _tapPos.x, clientY: _tapPos.y, button: 2, bubbles: true });
|
|
2246
|
+
term.element.dispatchEvent(evt);
|
|
2247
|
+
const upEvt = new MouseEvent('mouseup', { clientX: _tapPos.x, clientY: _tapPos.y, button: 2, bubbles: true });
|
|
2248
|
+
term.element.dispatchEvent(upEvt);
|
|
2249
|
+
_tapPos = null;
|
|
2250
|
+
}
|
|
2251
|
+
}, 500);
|
|
2252
|
+
}, { passive: true });
|
|
2253
|
+
term.element.addEventListener('touchend', e => {
|
|
2254
|
+
clearTimeout(_longPressTimer);
|
|
2255
|
+
if (!_tapPos || e.changedTouches.length !== 1) return;
|
|
2256
|
+
const touch = e.changedTouches[0];
|
|
2257
|
+
const dx = Math.abs(touch.clientX - _tapPos.x);
|
|
2258
|
+
const dy = Math.abs(touch.clientY - _tapPos.y);
|
|
2259
|
+
const dt = Date.now() - _tapPos.time;
|
|
2260
|
+
if (dx < 10 && dy < 10 && dt < 300) {
|
|
2261
|
+
// Short tap → left click
|
|
2262
|
+
const evt = new MouseEvent('mousedown', { clientX: touch.clientX, clientY: touch.clientY, button: 0, bubbles: true });
|
|
2263
|
+
term.element.dispatchEvent(evt);
|
|
2264
|
+
const upEvt = new MouseEvent('mouseup', { clientX: touch.clientX, clientY: touch.clientY, button: 0, bubbles: true });
|
|
2265
|
+
term.element.dispatchEvent(upEvt);
|
|
2266
|
+
}
|
|
2267
|
+
_tapPos = null;
|
|
2268
|
+
}, { passive: true });
|
|
2269
|
+
term.element.addEventListener('touchmove', () => {
|
|
2270
|
+
clearTimeout(_longPressTimer);
|
|
2271
|
+
_tapPos = null;
|
|
2272
|
+
}, { passive: true });
|
|
2273
|
+
|
|
1982
2274
|
// Fit terminal then connect WebSocket — ensures WS uses correct dimensions
|
|
1983
2275
|
requestAnimationFrame(() => {
|
|
1984
2276
|
fitTerm(tab);
|
|
@@ -2110,6 +2402,10 @@ function sendKey(key) {
|
|
|
2110
2402
|
altLatch = false;
|
|
2111
2403
|
}
|
|
2112
2404
|
updateModifierButtons();
|
|
2405
|
+
} else {
|
|
2406
|
+
shiftLatch = false;
|
|
2407
|
+
altLatch = false;
|
|
2408
|
+
updateModifierButtons();
|
|
2113
2409
|
}
|
|
2114
2410
|
const tab = getActiveTab();
|
|
2115
2411
|
if (tab?.ws?.readyState === WebSocket.OPEN) {
|
|
@@ -2520,6 +2816,118 @@ function renderBreadcrumb(fullPath) {
|
|
|
2520
2816
|
// ═══════════════════════════════════════════════════════
|
|
2521
2817
|
let fsClipboard = null;
|
|
2522
2818
|
|
|
2819
|
+
// Terminal context menu actions (registered once)
|
|
2820
|
+
function hideTermCtxMenu() {
|
|
2821
|
+
document.getElementById('term-ctx-menu').style.display = 'none';
|
|
2822
|
+
document.querySelectorAll('#term-ctx-menu .ctx-submenu-wrap.open').forEach(el => el.classList.remove('open'));
|
|
2823
|
+
}
|
|
2824
|
+
document.getElementById('term-ctx-copy').addEventListener('click', () => {
|
|
2825
|
+
const t = getActiveTab();
|
|
2826
|
+
if (t?.term) {
|
|
2827
|
+
const sel = t.term.getSelection();
|
|
2828
|
+
if (sel) {
|
|
2829
|
+
navigator.clipboard.writeText(sel).then(() => {
|
|
2830
|
+
toast('Copied to clipboard', 'success');
|
|
2831
|
+
}).catch(() => {
|
|
2832
|
+
document.execCommand('copy');
|
|
2833
|
+
toast('Copied to clipboard', 'success');
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
hideTermCtxMenu();
|
|
2838
|
+
});
|
|
2839
|
+
document.getElementById('term-ctx-paste').addEventListener('click', () => {
|
|
2840
|
+
pasteToTerminal();
|
|
2841
|
+
hideTermCtxMenu();
|
|
2842
|
+
});
|
|
2843
|
+
document.getElementById('term-ctx-select-all').addEventListener('click', () => {
|
|
2844
|
+
const t = getActiveTab();
|
|
2845
|
+
if (t?.term) t.term.selectAll();
|
|
2846
|
+
hideTermCtxMenu();
|
|
2847
|
+
});
|
|
2848
|
+
document.getElementById('term-ctx-select-line').addEventListener('click', () => {
|
|
2849
|
+
selectTermLine();
|
|
2850
|
+
hideTermCtxMenu();
|
|
2851
|
+
});
|
|
2852
|
+
document.getElementById('term-ctx-find').addEventListener('click', () => {
|
|
2853
|
+
toggleSearch();
|
|
2854
|
+
hideTermCtxMenu();
|
|
2855
|
+
});
|
|
2856
|
+
document.getElementById('term-ctx-zoom-in').addEventListener('click', () => {
|
|
2857
|
+
applyFontSize((settings.fontSize || 14) + 1);
|
|
2858
|
+
hideTermCtxMenu();
|
|
2859
|
+
});
|
|
2860
|
+
document.getElementById('term-ctx-zoom-out').addEventListener('click', () => {
|
|
2861
|
+
applyFontSize((settings.fontSize || 14) - 1);
|
|
2862
|
+
hideTermCtxMenu();
|
|
2863
|
+
});
|
|
2864
|
+
document.getElementById('term-ctx-new-tab').addEventListener('click', () => {
|
|
2865
|
+
const t = getActiveTab();
|
|
2866
|
+
newTab(null, null, t?.cwd || currentPath);
|
|
2867
|
+
hideTermCtxMenu();
|
|
2868
|
+
});
|
|
2869
|
+
document.getElementById('term-ctx-copy-cwd').addEventListener('click', () => {
|
|
2870
|
+
const t = getActiveTab();
|
|
2871
|
+
const cwd = t?.cwd || currentPath;
|
|
2872
|
+
if (cwd) {
|
|
2873
|
+
navigator.clipboard.writeText(cwd).then(() => {
|
|
2874
|
+
toast('Path copied', 'success');
|
|
2875
|
+
}).catch(() => {
|
|
2876
|
+
document.execCommand('copy');
|
|
2877
|
+
toast('Path copied', 'success');
|
|
2878
|
+
});
|
|
2879
|
+
}
|
|
2880
|
+
hideTermCtxMenu();
|
|
2881
|
+
});
|
|
2882
|
+
document.getElementById('term-ctx-bookmark').addEventListener('click', () => {
|
|
2883
|
+
const t = getActiveTab();
|
|
2884
|
+
const cwd = t?.cwd || currentPath;
|
|
2885
|
+
if (cwd) {
|
|
2886
|
+
const bm = getBookmarks();
|
|
2887
|
+
if (bm.some(b => b.path === cwd)) { toast('Already bookmarked', 'info'); }
|
|
2888
|
+
else {
|
|
2889
|
+
const sep = cwd.includes('\\') ? '\\' : '/';
|
|
2890
|
+
bm.push({ name: cwd.split(sep).filter(Boolean).pop() || cwd, path: cwd });
|
|
2891
|
+
saveBookmarks(bm);
|
|
2892
|
+
toast('Bookmarked', 'success');
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
hideTermCtxMenu();
|
|
2896
|
+
});
|
|
2897
|
+
document.getElementById('term-ctx-interrupt').addEventListener('click', () => {
|
|
2898
|
+
sendKey('\x03');
|
|
2899
|
+
hideTermCtxMenu();
|
|
2900
|
+
});
|
|
2901
|
+
document.getElementById('term-ctx-rename').addEventListener('click', () => {
|
|
2902
|
+
const t = getActiveTab();
|
|
2903
|
+
if (t?.el) {
|
|
2904
|
+
const span = t.el.querySelector('.tab-title');
|
|
2905
|
+
if (span) span.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
2906
|
+
}
|
|
2907
|
+
hideTermCtxMenu();
|
|
2908
|
+
});
|
|
2909
|
+
document.getElementById('term-ctx-scroll-top').addEventListener('click', () => {
|
|
2910
|
+
const t = getActiveTab();
|
|
2911
|
+
if (t?.term) t.term.scrollToTop();
|
|
2912
|
+
hideTermCtxMenu();
|
|
2913
|
+
});
|
|
2914
|
+
document.getElementById('term-ctx-scroll-bottom').addEventListener('click', () => {
|
|
2915
|
+
const t = getActiveTab();
|
|
2916
|
+
if (t?.term) t.term.scrollToBottom();
|
|
2917
|
+
hideTermCtxMenu();
|
|
2918
|
+
});
|
|
2919
|
+
document.getElementById('term-ctx-clear').addEventListener('click', () => {
|
|
2920
|
+
const t = getActiveTab();
|
|
2921
|
+
if (t?.term) t.term.clear();
|
|
2922
|
+
hideTermCtxMenu();
|
|
2923
|
+
});
|
|
2924
|
+
// Submenu toggle on click
|
|
2925
|
+
document.querySelector('#term-ctx-menu .ctx-submenu-wrap > .ctx-item').addEventListener('click', e => {
|
|
2926
|
+
e.stopPropagation();
|
|
2927
|
+
const wrap = e.currentTarget.closest('.ctx-submenu-wrap');
|
|
2928
|
+
wrap.classList.toggle('open');
|
|
2929
|
+
});
|
|
2930
|
+
|
|
2523
2931
|
function showCtxMenu(e, file) {
|
|
2524
2932
|
e.preventDefault();
|
|
2525
2933
|
ctxTarget = file;
|
|
@@ -2532,27 +2940,28 @@ function showCtxMenu(e, file) {
|
|
|
2532
2940
|
document.getElementById('ctx-zip').style.display = '';
|
|
2533
2941
|
document.getElementById('ctx-folder-size').style.display = file.isDir ? '' : 'none';
|
|
2534
2942
|
document.getElementById('ctx-extract').style.display = file.isDir ? 'none' : (file.ext === '.zip' ? '' : 'none');
|
|
2943
|
+
// Measure after content is set
|
|
2535
2944
|
const mw = menu.offsetWidth, mh = menu.offsetHeight;
|
|
2536
|
-
|
|
2537
|
-
let
|
|
2538
|
-
|
|
2539
|
-
if (
|
|
2945
|
+
const vw = window.innerWidth, vh = window.innerHeight;
|
|
2946
|
+
let left = e.clientX, top = e.clientY;
|
|
2947
|
+
// Flip horizontally if overflowing right
|
|
2948
|
+
if (left + mw > vw) left = Math.max(0, e.clientX - mw);
|
|
2949
|
+
// Flip vertically if overflowing bottom
|
|
2950
|
+
if (top + mh > vh) top = Math.max(0, e.clientY - mh);
|
|
2951
|
+
// Clamp to viewport
|
|
2952
|
+
left = Math.max(0, Math.min(left, vw - mw));
|
|
2953
|
+
top = Math.max(0, Math.min(top, vh - mh));
|
|
2540
2954
|
menu.style.left = left + 'px';
|
|
2541
2955
|
menu.style.top = top + 'px';
|
|
2542
2956
|
menu.classList.add('open');
|
|
2543
2957
|
menu.style.display = '';
|
|
2544
|
-
requestAnimationFrame(() => {
|
|
2545
|
-
const rect = menu.getBoundingClientRect();
|
|
2546
|
-
if (rect.right > window.innerWidth) {
|
|
2547
|
-
menu.style.left = Math.max(0, window.innerWidth - rect.width - 8) + 'px';
|
|
2548
|
-
}
|
|
2549
|
-
if (rect.bottom > window.innerHeight) {
|
|
2550
|
-
menu.style.top = Math.max(0, window.innerHeight - rect.height - 8) + 'px';
|
|
2551
|
-
}
|
|
2552
|
-
});
|
|
2553
2958
|
}
|
|
2554
2959
|
|
|
2555
|
-
document.addEventListener('click', () =>
|
|
2960
|
+
document.addEventListener('click', () => {
|
|
2961
|
+
document.getElementById('ctx-menu').classList.remove('open');
|
|
2962
|
+
const termMenu = document.getElementById('term-ctx-menu');
|
|
2963
|
+
if (termMenu) termMenu.style.display = 'none';
|
|
2964
|
+
});
|
|
2556
2965
|
|
|
2557
2966
|
document.getElementById('ctx-open').onclick = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
|
|
2558
2967
|
document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
|
|
@@ -3498,6 +3907,9 @@ function toggleSetting(k) {
|
|
|
3498
3907
|
const ctrlBtn = document.getElementById('ctrl-toggle-btn');
|
|
3499
3908
|
if (ctrlBtn) ctrlBtn.style.background = '';
|
|
3500
3909
|
}
|
|
3910
|
+
if (k === 'bell' && settings.bell && 'Notification' in window && Notification.permission === 'default') {
|
|
3911
|
+
Notification.requestPermission();
|
|
3912
|
+
}
|
|
3501
3913
|
if (k === 'datasaver') {
|
|
3502
3914
|
if (settings.datasaver) {
|
|
3503
3915
|
if (sysStatsTimer) { clearInterval(sysStatsTimer); sysStatsTimer = null; }
|
|
@@ -3998,6 +4410,11 @@ function setupKeyboardShortcuts() {
|
|
|
3998
4410
|
if (ctrl && e.shiftKey && e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); }
|
|
3999
4411
|
if (ctrl && e.shiftKey && e.key === 'r') { e.preventDefault(); refreshPreview(); }
|
|
4000
4412
|
if (e.key === 'Escape') {
|
|
4413
|
+
const termMenu = document.getElementById('term-ctx-menu');
|
|
4414
|
+
if (termMenu && termMenu.style.display !== 'none') {
|
|
4415
|
+
termMenu.style.display = 'none';
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4001
4418
|
if (document.getElementById('ctx-menu').classList.contains('open')) {
|
|
4002
4419
|
document.getElementById('ctx-menu').classList.remove('open');
|
|
4003
4420
|
return;
|
|
@@ -4151,10 +4568,13 @@ function pasteToTerminal() {
|
|
|
4151
4568
|
if (!text) return;
|
|
4152
4569
|
const tab = getActiveTab();
|
|
4153
4570
|
if (tab?.ws && tab.ws.readyState === WebSocket.OPEN) {
|
|
4154
|
-
|
|
4571
|
+
// Bracketed paste: wrap in escape sequences so the shell buffers the input
|
|
4572
|
+
const bracketed = '\x1b[200~' + text + '\x1b[201~';
|
|
4573
|
+
const enc = new TextEncoder().encode(bracketed);
|
|
4155
4574
|
const buf = new Uint8Array(1 + enc.length);
|
|
4156
4575
|
buf[0] = 0x00; buf.set(enc, 1);
|
|
4157
4576
|
tab.ws.send(buf.buffer);
|
|
4577
|
+
tab.term?.focus();
|
|
4158
4578
|
toast('Pasted to terminal', 'success');
|
|
4159
4579
|
}
|
|
4160
4580
|
};
|
|
@@ -4171,14 +4591,16 @@ function fallbackPaste(callback) {
|
|
|
4171
4591
|
_pasteInput = document.createElement('textarea');
|
|
4172
4592
|
_pasteInput.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1';
|
|
4173
4593
|
document.body.appendChild(_pasteInput);
|
|
4174
|
-
_pasteInput.addEventListener('
|
|
4175
|
-
|
|
4176
|
-
|
|
4594
|
+
_pasteInput.addEventListener('paste', () => {
|
|
4595
|
+
setTimeout(() => {
|
|
4596
|
+
callback(_pasteInput.value);
|
|
4597
|
+
_pasteInput.value = '';
|
|
4598
|
+
}, 0);
|
|
4177
4599
|
});
|
|
4178
4600
|
}
|
|
4179
4601
|
_pasteInput.value = '';
|
|
4180
4602
|
_pasteInput.focus();
|
|
4181
|
-
|
|
4603
|
+
document.execCommand('paste');
|
|
4182
4604
|
}
|
|
4183
4605
|
|
|
4184
4606
|
function selectAllTerm() {
|
|
@@ -4720,9 +5142,8 @@ function toggleCmdLib() {
|
|
|
4720
5142
|
panel.classList.toggle('open');
|
|
4721
5143
|
document.getElementById('cmd-lib-toggle').classList.toggle('active', !isOpen);
|
|
4722
5144
|
if (!isOpen) {
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
document.getElementById('cmd-lib-search').focus();
|
|
5145
|
+
loadHistMax();
|
|
5146
|
+
switchCmdTab('library');
|
|
4726
5147
|
setTimeout(() => {
|
|
4727
5148
|
document.addEventListener('click', closeCmdLibOnClickOutside, true);
|
|
4728
5149
|
}, 0);
|
|
@@ -4804,7 +5225,13 @@ function renderCmdLib(filter) {
|
|
|
4804
5225
|
item.appendChild(delBtn);
|
|
4805
5226
|
}
|
|
4806
5227
|
|
|
4807
|
-
item.addEventListener('click', () =>
|
|
5228
|
+
item.addEventListener('click', () => {
|
|
5229
|
+
const tab = getActiveTab();
|
|
5230
|
+
if (!tab?.term) return;
|
|
5231
|
+
tab.term.focus();
|
|
5232
|
+
document.execCommand('insertText', false, c.cmd);
|
|
5233
|
+
toggleCmdLib();
|
|
5234
|
+
});
|
|
4808
5235
|
section.appendChild(item);
|
|
4809
5236
|
});
|
|
4810
5237
|
list.appendChild(section);
|
|
@@ -4828,6 +5255,7 @@ function runCmdLib(cmd) {
|
|
|
4828
5255
|
buf.set(enc, 1);
|
|
4829
5256
|
tab.ws.send(buf.buffer);
|
|
4830
5257
|
tab.term?.focus();
|
|
5258
|
+
addToCmdHist(cmd);
|
|
4831
5259
|
toast('Running: ' + cmd, 'info');
|
|
4832
5260
|
}
|
|
4833
5261
|
|
|
@@ -4858,6 +5286,155 @@ function deleteCustomCmd(name, cmd) {
|
|
|
4858
5286
|
toast('Command removed', 'info');
|
|
4859
5287
|
}
|
|
4860
5288
|
|
|
5289
|
+
// ═══════════════════════════════════════════════════════
|
|
5290
|
+
// COMMAND HISTORY (server-side storage)
|
|
5291
|
+
// ═══════════════════════════════════════════════════════
|
|
5292
|
+
let cmdHistMax = 50;
|
|
5293
|
+
let _cmdHistCache = [];
|
|
5294
|
+
|
|
5295
|
+
function histHeaders() {
|
|
5296
|
+
return { 'Content-Type': 'application/json', 'x-pin-token': localStorage.getItem('wt-pin') || '' };
|
|
5297
|
+
}
|
|
5298
|
+
function histHeadersDelete() {
|
|
5299
|
+
return { 'x-pin-token': localStorage.getItem('wt-pin') || '' };
|
|
5300
|
+
}
|
|
5301
|
+
async function getCmdHist() {
|
|
5302
|
+
try {
|
|
5303
|
+
const r = await fetch('/api/history', { headers: histHeadersDelete() });
|
|
5304
|
+
if (!r.ok) return _cmdHistCache;
|
|
5305
|
+
const data = await r.json();
|
|
5306
|
+
_cmdHistCache = data.history || [];
|
|
5307
|
+
if (data.max) cmdHistMax = data.max;
|
|
5308
|
+
return _cmdHistCache;
|
|
5309
|
+
} catch { return _cmdHistCache; }
|
|
5310
|
+
}
|
|
5311
|
+
function loadHistMax() {
|
|
5312
|
+
getCmdHist().then(() => {
|
|
5313
|
+
const el = document.getElementById('cmd-hist-max');
|
|
5314
|
+
if (el) el.value = cmdHistMax;
|
|
5315
|
+
});
|
|
5316
|
+
}
|
|
5317
|
+
async function addToCmdHist(cmd) {
|
|
5318
|
+
if (!cmd || !cmd.trim()) return;
|
|
5319
|
+
// Strip ANSI escapes and control characters, keep only printable + space + tab
|
|
5320
|
+
const clean = cmd.replace(/\x1b\[[0-9;]*[a-zA-Z~]/g, '')
|
|
5321
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
5322
|
+
.replace(/[\x00-\x08\x0e-\x1f]/g, '')
|
|
5323
|
+
.trim();
|
|
5324
|
+
if (!clean) return;
|
|
5325
|
+
try {
|
|
5326
|
+
await fetch('/api/history', { method: 'POST', headers: histHeaders(), body: JSON.stringify({ cmd: clean, max: cmdHistMax }) });
|
|
5327
|
+
await getCmdHist();
|
|
5328
|
+
} catch {}
|
|
5329
|
+
}
|
|
5330
|
+
async function removeCmdHistItem(idx) {
|
|
5331
|
+
try {
|
|
5332
|
+
await fetch('/api/history/' + idx, { method: 'DELETE', headers: histHeadersDelete() });
|
|
5333
|
+
await getCmdHist();
|
|
5334
|
+
renderCmdHist();
|
|
5335
|
+
} catch {}
|
|
5336
|
+
}
|
|
5337
|
+
async function clearCmdHist() {
|
|
5338
|
+
try {
|
|
5339
|
+
await fetch('/api/history', { method: 'DELETE', headers: histHeadersDelete() });
|
|
5340
|
+
_cmdHistCache = [];
|
|
5341
|
+
renderCmdHist();
|
|
5342
|
+
toast('History cleared', 'info');
|
|
5343
|
+
} catch {}
|
|
5344
|
+
}
|
|
5345
|
+
async function updateHistMax(val) {
|
|
5346
|
+
cmdHistMax = Math.max(10, Math.min(500, val || 50));
|
|
5347
|
+
try {
|
|
5348
|
+
await fetch('/api/history', { method: 'POST', headers: histHeaders(), body: JSON.stringify({ cmd: '', max: cmdHistMax }) });
|
|
5349
|
+
} catch {}
|
|
5350
|
+
}
|
|
5351
|
+
function switchCmdTab(tab) {
|
|
5352
|
+
const libBtn = document.getElementById('cmd-tab-lib');
|
|
5353
|
+
const histBtn = document.getElementById('cmd-tab-hist');
|
|
5354
|
+
const libList = document.getElementById('cmd-lib-list');
|
|
5355
|
+
const histList = document.getElementById('cmd-hist-list');
|
|
5356
|
+
const histHeader = document.getElementById('cmd-hist-header');
|
|
5357
|
+
const footer = document.getElementById('cmd-lib-footer');
|
|
5358
|
+
const search = document.getElementById('cmd-lib-search');
|
|
5359
|
+
if (tab === 'library') {
|
|
5360
|
+
libBtn.classList.add('active');
|
|
5361
|
+
histBtn.classList.remove('active');
|
|
5362
|
+
libList.style.display = '';
|
|
5363
|
+
histList.style.display = 'none';
|
|
5364
|
+
histHeader.style.display = 'none';
|
|
5365
|
+
footer.style.display = '';
|
|
5366
|
+
search.placeholder = 'Filter commands…';
|
|
5367
|
+
search.oninput = () => filterCmdLib();
|
|
5368
|
+
filterCmdLib();
|
|
5369
|
+
} else {
|
|
5370
|
+
histBtn.classList.add('active');
|
|
5371
|
+
libBtn.classList.remove('active');
|
|
5372
|
+
histList.style.display = 'flex';
|
|
5373
|
+
libList.style.display = 'none';
|
|
5374
|
+
histHeader.style.display = '';
|
|
5375
|
+
footer.style.display = 'none';
|
|
5376
|
+
search.placeholder = 'Filter history…';
|
|
5377
|
+
search.oninput = () => renderCmdHist();
|
|
5378
|
+
renderCmdHist();
|
|
5379
|
+
}
|
|
5380
|
+
search.value = '';
|
|
5381
|
+
search.focus();
|
|
5382
|
+
}
|
|
5383
|
+
async function renderCmdHist() {
|
|
5384
|
+
const list = document.getElementById('cmd-hist-list');
|
|
5385
|
+
const hist = await getCmdHist();
|
|
5386
|
+
const q = (document.getElementById('cmd-lib-search').value || '').toLowerCase();
|
|
5387
|
+
const filtered = q ? hist.filter(h => h.cmd.toLowerCase().includes(q)) : hist;
|
|
5388
|
+
|
|
5389
|
+
list.innerHTML = '';
|
|
5390
|
+
if (!filtered.length) {
|
|
5391
|
+
list.innerHTML = '<div id="cmd-hist-empty">No commands run yet</div>';
|
|
5392
|
+
return;
|
|
5393
|
+
}
|
|
5394
|
+
|
|
5395
|
+
filtered.forEach((h, i) => {
|
|
5396
|
+
const realIdx = q ? hist.indexOf(h) : i;
|
|
5397
|
+
const item = document.createElement('div');
|
|
5398
|
+
item.className = 'cmd-hist-item';
|
|
5399
|
+
|
|
5400
|
+
const cmdSpan = document.createElement('span');
|
|
5401
|
+
cmdSpan.className = 'cmd-hist-cmd';
|
|
5402
|
+
cmdSpan.textContent = h.cmd;
|
|
5403
|
+
cmdSpan.title = h.cmd;
|
|
5404
|
+
item.appendChild(cmdSpan);
|
|
5405
|
+
|
|
5406
|
+
const timeSpan = document.createElement('span');
|
|
5407
|
+
timeSpan.className = 'cmd-hist-time';
|
|
5408
|
+
const ago = Date.now() - h.time;
|
|
5409
|
+
timeSpan.textContent = ago < 60000 ? 'just now' : ago < 3600000 ? Math.floor(ago / 60000) + 'm ago' : ago < 86400000 ? Math.floor(ago / 3600000) + 'h ago' : new Date(h.time).toLocaleDateString();
|
|
5410
|
+
if (h.count > 1) timeSpan.textContent = '×' + h.count + ' ' + timeSpan.textContent;
|
|
5411
|
+
item.appendChild(timeSpan);
|
|
5412
|
+
|
|
5413
|
+
const runBtn = document.createElement('button');
|
|
5414
|
+
runBtn.className = 'cmd-hist-run';
|
|
5415
|
+
runBtn.title = 'Run in terminal';
|
|
5416
|
+
runBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6,3 20,12 6,21"/></svg>';
|
|
5417
|
+
runBtn.addEventListener('click', e => { e.stopPropagation(); runCmdLib(h.cmd); });
|
|
5418
|
+
item.appendChild(runBtn);
|
|
5419
|
+
|
|
5420
|
+
const delBtn = document.createElement('button');
|
|
5421
|
+
delBtn.className = 'cmd-hist-del';
|
|
5422
|
+
delBtn.title = 'Remove';
|
|
5423
|
+
delBtn.textContent = '×';
|
|
5424
|
+
delBtn.addEventListener('click', e => { e.stopPropagation(); removeCmdHistItem(realIdx); });
|
|
5425
|
+
item.appendChild(delBtn);
|
|
5426
|
+
|
|
5427
|
+
item.addEventListener('click', () => {
|
|
5428
|
+
const tab = getActiveTab();
|
|
5429
|
+
if (!tab?.term) return;
|
|
5430
|
+
tab.term.focus();
|
|
5431
|
+
document.execCommand('insertText', false, h.cmd);
|
|
5432
|
+
toggleCmdLib();
|
|
5433
|
+
});
|
|
5434
|
+
list.appendChild(item);
|
|
5435
|
+
});
|
|
5436
|
+
}
|
|
5437
|
+
|
|
4861
5438
|
// ═══════════════════════════════════════════════════════
|
|
4862
5439
|
// SYSTEM STATS
|
|
4863
5440
|
// ═══════════════════════════════════════════════════════
|
package/server.js
CHANGED
|
@@ -8,7 +8,10 @@ try {
|
|
|
8
8
|
const idx = trimmed.indexOf('=');
|
|
9
9
|
if (idx === -1) return;
|
|
10
10
|
const key = trimmed.slice(0, idx).trim();
|
|
11
|
-
|
|
11
|
+
let val = trimmed.slice(idx + 1).trim();
|
|
12
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
13
|
+
val = val.slice(1, -1);
|
|
14
|
+
}
|
|
12
15
|
if (!(key in process.env)) process.env[key] = val;
|
|
13
16
|
});
|
|
14
17
|
} catch {}
|
|
@@ -114,13 +117,24 @@ const wss = new WebSocket.Server({ server, path: '/ws' });
|
|
|
114
117
|
|
|
115
118
|
const PORT = process.env.PORT || 3000;
|
|
116
119
|
const PIN = process.env.PIN || '';
|
|
117
|
-
|
|
120
|
+
// On Windows, ignore SHELL env from Git Bash/MSYS2/WSL — prefer PowerShell
|
|
121
|
+
const SHELL = (os.platform() === 'win32' && !process.env.WEBTUN_SHELL)
|
|
122
|
+
? 'powershell.exe'
|
|
123
|
+
: (process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh')));
|
|
118
124
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
119
125
|
const WORKSPACE_ROOT = process.env.WORKSPACE_ROOT ? path.resolve(process.env.WORKSPACE_ROOT) : os.homedir();
|
|
120
126
|
|
|
121
127
|
app.use(express.json({ limit: '50mb' }));
|
|
122
128
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
123
129
|
|
|
130
|
+
// Handle malformed JSON gracefully
|
|
131
|
+
app.use((err, req, res, next) => {
|
|
132
|
+
if (err.type === 'entity.parse.failed') {
|
|
133
|
+
return res.status(400).json({ error: 'Invalid JSON' });
|
|
134
|
+
}
|
|
135
|
+
next(err);
|
|
136
|
+
});
|
|
137
|
+
|
|
124
138
|
// Security headers
|
|
125
139
|
app.use((req, res, next) => {
|
|
126
140
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
@@ -132,7 +146,9 @@ app.use((req, res, next) => {
|
|
|
132
146
|
});
|
|
133
147
|
|
|
134
148
|
// Trust proxy for proper IP detection behind reverse proxy
|
|
135
|
-
|
|
149
|
+
// 'loopback' only trusts 127.0.0.1/::1 — safe default for direct connections
|
|
150
|
+
// Set TRUST_PROXY=true in .env if behind a reverse proxy
|
|
151
|
+
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : 'loopback');
|
|
136
152
|
|
|
137
153
|
function constantTimeEqual(a, b) {
|
|
138
154
|
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
@@ -161,7 +177,7 @@ app.post('/api/auth', authRateLimiter, (req, res) => {
|
|
|
161
177
|
if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
|
|
162
178
|
res.json({ success: true, token: PIN || 'open' });
|
|
163
179
|
} else {
|
|
164
|
-
res.status(401).json({ error: '
|
|
180
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
165
181
|
}
|
|
166
182
|
});
|
|
167
183
|
|
|
@@ -332,7 +348,13 @@ function killPid(pid, signal = 'SIGTERM') {
|
|
|
332
348
|
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return;
|
|
333
349
|
try {
|
|
334
350
|
if (os.platform() === 'win32') {
|
|
335
|
-
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
|
|
351
|
+
try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); } catch {}
|
|
352
|
+
// Fallback: also kill any remaining child processes via WMIC
|
|
353
|
+
try {
|
|
354
|
+
const out = execSync(`wmic process where "ParentProcessId=${pid}" get ProcessId /format:list`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
355
|
+
const childPids = out.split('\n').filter(l => l.startsWith('ProcessId=')).map(l => parseInt(l.split('=')[1])).filter(Boolean);
|
|
356
|
+
for (const cp of childPids) { try { execSync(`taskkill /PID ${cp} /F`, { stdio: 'ignore' }); } catch {} }
|
|
357
|
+
} catch {}
|
|
336
358
|
} else {
|
|
337
359
|
process.kill(pid, signal);
|
|
338
360
|
}
|
|
@@ -344,7 +366,7 @@ function buildSessionEnv() {
|
|
|
344
366
|
const env = { ...process.env };
|
|
345
367
|
env.TERM = env.TERM || 'xterm-256color';
|
|
346
368
|
env.COLORTERM = env.COLORTERM || 'truecolor';
|
|
347
|
-
if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE;
|
|
369
|
+
if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE.replace(/\\/g, '/');
|
|
348
370
|
if (!env.USER && env.USERNAME) env.USER = env.USERNAME;
|
|
349
371
|
env.SHELL = SHELL;
|
|
350
372
|
// Prefer Path (Windows) over PATH if both set
|
|
@@ -747,7 +769,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
|
|
|
747
769
|
cb(err);
|
|
748
770
|
}
|
|
749
771
|
},
|
|
750
|
-
filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[
|
|
772
|
+
filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[^\w\u00C0-\u024F\u0400-\u04FF\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF_.\-]/g, '_'))
|
|
751
773
|
});
|
|
752
774
|
const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024, files: 100 } }).array('files');
|
|
753
775
|
upload(req, res, err => {
|
|
@@ -779,10 +801,22 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
|
|
|
779
801
|
isSocket: st.isSocket(), isFIFO: st.isFIFO(),
|
|
780
802
|
};
|
|
781
803
|
try {
|
|
782
|
-
|
|
804
|
+
if (os.platform() === 'win32') {
|
|
805
|
+
stat.owner = String(st.uid);
|
|
806
|
+
} else {
|
|
807
|
+
stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
808
|
+
}
|
|
783
809
|
} catch { stat.owner = String(st.uid); }
|
|
784
810
|
try {
|
|
785
|
-
|
|
811
|
+
if (os.platform() === 'win32') {
|
|
812
|
+
stat.group = String(st.gid);
|
|
813
|
+
} else if (os.platform() === 'darwin') {
|
|
814
|
+
const dscl = execSync(`dscl . -read /Groups/${st.gid} RecordName 2>/dev/null`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
815
|
+
const m = dscl.match(/RecordName:\s*(.+)/);
|
|
816
|
+
stat.group = m ? m[1].trim() : String(st.gid);
|
|
817
|
+
} else {
|
|
818
|
+
stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
|
|
819
|
+
}
|
|
786
820
|
} catch { stat.group = String(st.gid); }
|
|
787
821
|
try {
|
|
788
822
|
const symlink = lst && lst.isSymbolicLink() ? await fsPromises.readlink(p) : null;
|
|
@@ -873,7 +907,8 @@ app.post('/api/files/chmod', checkPin, async (req, res) => {
|
|
|
873
907
|
if (!/^[0-7]{3,4}$/.test(req.body.mode)) return res.status(400).json({ error: 'mode must be a 3-4 digit octal number (e.g. 755, 644, 1777)' });
|
|
874
908
|
const mode = parseInt(req.body.mode, 8);
|
|
875
909
|
await fsPromises.chmod(p, mode);
|
|
876
|
-
|
|
910
|
+
const warning = os.platform() === 'win32' ? 'chmod has no effect on Windows' : undefined;
|
|
911
|
+
res.json({ success: true, mode: req.body.mode, ...(warning && { warning }) });
|
|
877
912
|
} catch (e) {
|
|
878
913
|
res.status(500).json({ error: e.message });
|
|
879
914
|
}
|
|
@@ -1071,10 +1106,21 @@ app.get('/api/system/network', checkPin, async (req, res) => {
|
|
|
1071
1106
|
gateway = route.split('\n')[0].trim() || null;
|
|
1072
1107
|
const dnsOut = execFileSync('powershell.exe', ['-Command', '(Get-DnsClientServerAddress -AddressFamily IPv4).ServerAddresses'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1073
1108
|
dns = dnsOut.split('\n').filter(Boolean);
|
|
1109
|
+
} else if (os.platform() === 'darwin') {
|
|
1110
|
+
const route = execFileSync('sh', ['-c', "route -n get default 2>/dev/null | awk '/gateway:/{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1111
|
+
gateway = route || null;
|
|
1112
|
+
const resolv = execFileSync('sh', ['-c', "scutil --dns 2>/dev/null | awk '/nameserver\[0\]/{print $3}' | head -3"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1113
|
+
dns = resolv.split('\n').filter(Boolean);
|
|
1074
1114
|
} else {
|
|
1075
1115
|
const route = execFileSync('sh', ['-c', "ip route | grep default | head -1 | awk '{print $3}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1076
1116
|
gateway = route || null;
|
|
1077
|
-
|
|
1117
|
+
let resolv = '';
|
|
1118
|
+
try {
|
|
1119
|
+
resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1120
|
+
} catch {}
|
|
1121
|
+
if (!resolv) {
|
|
1122
|
+
try { resolv = execFileSync('sh', ['-c', "resolvectl status 2>/dev/null | awk '/DNS Servers/{found=1; next} /^$/{found=0} found{print $1}' | head -3"], { encoding: 'utf8', stdio: 'pipe' }).trim(); } catch {}
|
|
1123
|
+
}
|
|
1078
1124
|
dns = resolv.split('\n').filter(Boolean);
|
|
1079
1125
|
}
|
|
1080
1126
|
} catch {}
|
|
@@ -1085,6 +1131,15 @@ app.get('/api/system/network', checkPin, async (req, res) => {
|
|
|
1085
1131
|
const m = l.match(/:(\d+)\s+/);
|
|
1086
1132
|
return m ? { port: parseInt(m[1]), process: l.split(/\s+/).pop() } : null;
|
|
1087
1133
|
}).filter(Boolean);
|
|
1134
|
+
} else if (os.platform() === 'darwin') {
|
|
1135
|
+
const out = execFileSync('lsof', ['-i', '-P', '-n', '-sTCP:LISTEN'], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1136
|
+
const lines = out.split('\n').slice(1).filter(Boolean);
|
|
1137
|
+
listenPorts = lines.map(l => {
|
|
1138
|
+
const parts = l.split(/\s+/);
|
|
1139
|
+
const addr = parts[8] || '';
|
|
1140
|
+
const m = addr.match(/:(\d+)$/);
|
|
1141
|
+
return m ? { port: parseInt(m[1]), address: addr, process: parts[0] || '' } : null;
|
|
1142
|
+
}).filter(Boolean);
|
|
1088
1143
|
} else {
|
|
1089
1144
|
const out = execFileSync('sh', ['-c', "ss -tlnp 2>/dev/null | tail -n+2"], { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
1090
1145
|
listenPorts = out.split('\n').filter(Boolean).map(l => {
|
|
@@ -1158,6 +1213,65 @@ app.delete('/api/clipboard', checkPin, (req, res) => {
|
|
|
1158
1213
|
res.json({ success: true });
|
|
1159
1214
|
});
|
|
1160
1215
|
|
|
1216
|
+
// ── Command history (server-side, persists across sessions) ────────────
|
|
1217
|
+
const HISTORY_FILE = path.join(__dirname, '.cmdhist.json');
|
|
1218
|
+
let cmdHistory = [];
|
|
1219
|
+
let cmdHistMax = 50;
|
|
1220
|
+
|
|
1221
|
+
function loadCmdHistory() {
|
|
1222
|
+
try { cmdHistory = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf8')); } catch { cmdHistory = []; }
|
|
1223
|
+
}
|
|
1224
|
+
function saveCmdHistory() {
|
|
1225
|
+
try { fs.writeFileSync(HISTORY_FILE, JSON.stringify(cmdHistory)); } catch {}
|
|
1226
|
+
}
|
|
1227
|
+
loadCmdHistory();
|
|
1228
|
+
|
|
1229
|
+
app.get('/api/history', checkPin, (req, res) => {
|
|
1230
|
+
res.json({ history: cmdHistory, max: cmdHistMax });
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
app.post('/api/history', checkPin, (req, res) => {
|
|
1234
|
+
try {
|
|
1235
|
+
const { cmd, max } = req.body;
|
|
1236
|
+
if (typeof max === 'number' && max >= 10 && max <= 500) {
|
|
1237
|
+
cmdHistMax = max;
|
|
1238
|
+
}
|
|
1239
|
+
if (!cmd || typeof cmd !== 'string' || !cmd.trim()) {
|
|
1240
|
+
// No command — just updating max
|
|
1241
|
+
saveCmdHistory();
|
|
1242
|
+
return res.json({ success: true, history: cmdHistory, max: cmdHistMax });
|
|
1243
|
+
}
|
|
1244
|
+
const clean = cmd.trim();
|
|
1245
|
+
if (cmdHistory.length && cmdHistory[0].cmd === clean) {
|
|
1246
|
+
cmdHistory[0].time = Date.now();
|
|
1247
|
+
cmdHistory[0].count = (cmdHistory[0].count || 1) + 1;
|
|
1248
|
+
} else {
|
|
1249
|
+
cmdHistory.unshift({ cmd: clean, time: Date.now(), count: 1 });
|
|
1250
|
+
}
|
|
1251
|
+
if (cmdHistory.length > cmdHistMax) cmdHistory.length = cmdHistMax;
|
|
1252
|
+
saveCmdHistory();
|
|
1253
|
+
res.json({ success: true, history: cmdHistory });
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
res.status(500).json({ error: e.message });
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
app.delete('/api/history', checkPin, (req, res) => {
|
|
1260
|
+
cmdHistory = [];
|
|
1261
|
+
saveCmdHistory();
|
|
1262
|
+
res.json({ success: true });
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
app.delete('/api/history/:index', checkPin, (req, res) => {
|
|
1266
|
+
const idx = parseInt(req.params.index);
|
|
1267
|
+
if (isNaN(idx) || idx < 0 || idx >= cmdHistory.length) {
|
|
1268
|
+
return res.status(400).json({ error: 'invalid index' });
|
|
1269
|
+
}
|
|
1270
|
+
cmdHistory.splice(idx, 1);
|
|
1271
|
+
saveCmdHistory();
|
|
1272
|
+
res.json({ success: true, history: cmdHistory });
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1161
1275
|
// ── Session persistence via tmux ──────────────────────────────────────
|
|
1162
1276
|
const TMUX = (() => { try { return execSync('command -v tmux', { stdio: ['ignore','pipe','ignore'] }).toString().trim(); } catch { return null; } })();
|
|
1163
1277
|
|
|
@@ -1282,7 +1396,8 @@ wss.on('connection', (ws, req) => {
|
|
|
1282
1396
|
});
|
|
1283
1397
|
}
|
|
1284
1398
|
} else {
|
|
1285
|
-
|
|
1399
|
+
const shellArgs = os.platform() === 'win32' ? ['-NoLogo'] : ['-l'];
|
|
1400
|
+
proc = pty.spawn(SHELL, shellArgs, {
|
|
1286
1401
|
name: 'xterm-256color', cols, rows, cwd,
|
|
1287
1402
|
env: sessionEnv
|
|
1288
1403
|
});
|
|
@@ -1293,9 +1408,29 @@ wss.on('connection', (ws, req) => {
|
|
|
1293
1408
|
return;
|
|
1294
1409
|
}
|
|
1295
1410
|
|
|
1296
|
-
|
|
1411
|
+
// Back-pressure: pause PTY output when WebSocket send buffer is full
|
|
1412
|
+
let paused = false;
|
|
1413
|
+
const HIGH_WATER = 4 * 1024 * 1024; // 4MB — pause PTY above this
|
|
1414
|
+
const LOW_WATER = 1 * 1024 * 1024; // 1MB — resume PTY below this
|
|
1415
|
+
|
|
1416
|
+
proc.onData(data => {
|
|
1417
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1418
|
+
send(0x00, data);
|
|
1419
|
+
// If WebSocket buffer is backing up, pause PTY to prevent OOM
|
|
1420
|
+
if (!paused && ws.bufferedAmount > HIGH_WATER) {
|
|
1421
|
+
try { proc.pause(); paused = true; } catch (_) {}
|
|
1422
|
+
}
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
// Drain check: resume PTY when buffer drops
|
|
1426
|
+
const drainCheck = setInterval(() => {
|
|
1427
|
+
if (paused && ws.bufferedAmount < LOW_WATER) {
|
|
1428
|
+
try { proc.resume(); paused = false; } catch (_) {}
|
|
1429
|
+
}
|
|
1430
|
+
}, 50);
|
|
1297
1431
|
|
|
1298
1432
|
proc.onExit(() => {
|
|
1433
|
+
clearInterval(drainCheck);
|
|
1299
1434
|
if (!TMUX || !sessionId) send(0x01, Buffer.from([0]));
|
|
1300
1435
|
ws.close();
|
|
1301
1436
|
});
|
|
@@ -1314,8 +1449,8 @@ wss.on('connection', (ws, req) => {
|
|
|
1314
1449
|
if (buf.length < 1) return;
|
|
1315
1450
|
const type = buf[0];
|
|
1316
1451
|
if (type === 0x00) {
|
|
1317
|
-
// Limit input to
|
|
1318
|
-
const payload = buf.slice(1, Math.min(buf.length,
|
|
1452
|
+
// Limit input to 1MB per message
|
|
1453
|
+
const payload = buf.slice(1, Math.min(buf.length, 1048577));
|
|
1319
1454
|
proc.write(payload.toString('utf8'));
|
|
1320
1455
|
} else if (type === 0x01 && buf.length >= 5) {
|
|
1321
1456
|
const c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
|
|
@@ -1331,6 +1466,7 @@ wss.on('connection', (ws, req) => {
|
|
|
1331
1466
|
|
|
1332
1467
|
const cleanup = () => {
|
|
1333
1468
|
clearInterval(pingInterval);
|
|
1469
|
+
clearInterval(drainCheck);
|
|
1334
1470
|
try { proc.kill(); } catch {}
|
|
1335
1471
|
};
|
|
1336
1472
|
ws.on('close', cleanup);
|
|
@@ -1448,6 +1584,15 @@ app.get('/api/system', checkPin, async (req, res) => {
|
|
|
1448
1584
|
mem: p.mem || '',
|
|
1449
1585
|
cmd: p.cmd || ''
|
|
1450
1586
|
}));
|
|
1587
|
+
} else if (os.platform() === 'darwin') {
|
|
1588
|
+
const psOut = await spawnRead('ps', ['-axo', 'pid,user,%cpu,%mem,command', '-r']);
|
|
1589
|
+
const lines = psOut.trim().split('\n').slice(1, 16);
|
|
1590
|
+
for (const line of lines) {
|
|
1591
|
+
const m = line.match(/^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)/);
|
|
1592
|
+
if (m) {
|
|
1593
|
+
processes.push({ user: m[2], pid: m[1], cpu: m[3], mem: m[4], cmd: m[5] });
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1451
1596
|
} else {
|
|
1452
1597
|
const psOut = await spawnRead('ps', ['-eo', 'pid,user,%cpu,%mem,cmd', '--no-headers', '--sort=-%cpu']);
|
|
1453
1598
|
const lines = psOut.trim().split('\n').slice(0, 15);
|
|
@@ -1482,9 +1627,12 @@ function isCloudflaredProcess(pid) {
|
|
|
1482
1627
|
if (os.platform() === 'win32') {
|
|
1483
1628
|
const stdout = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1484
1629
|
return stdout.toLowerCase().includes('cloudflared');
|
|
1485
|
-
} else {
|
|
1630
|
+
} else if (os.platform() === 'linux') {
|
|
1486
1631
|
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
1487
1632
|
return cmdline.toLowerCase().includes('cloudflared');
|
|
1633
|
+
} else {
|
|
1634
|
+
const stdout = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
1635
|
+
return stdout.toLowerCase().includes('cloudflared');
|
|
1488
1636
|
}
|
|
1489
1637
|
} catch {
|
|
1490
1638
|
return false;
|