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 CHANGED
@@ -6,7 +6,7 @@
6
6
  ![Desktop](https://img.shields.io/badge/Desktop-Electron-47848F?style=for-the-badge)
7
7
  ![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)
8
8
 
9
- **Access your Linux server from any browser — no VPN, no SSH client, no installing anything.**
9
+ **Access your server from any browser — no VPN, no SSH client, no installing anything.**
10
10
 
11
11
  [![Quick Start](https://img.shields.io/badge/Quick_Start-One_Command-2D5B8E?style=for-the-badge&logo=gnu-bash)](install.sh)
12
12
  [![Download](https://img.shields.io/github/v/release/unn-known1/webtun?style=for-the-badge&logo=github)](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
- Your Browser
153
- ┌─────────┐ ┌──────────┐ ┌────────────┐ ┌────────────┐
154
- │ │Terminal │ │ File │ │ Tunnel │ │ Settings
155
- │ │ (xterm) │ │ Explorer │ │ Manager │ │ Panel
156
- └────┬────┘ └────┬─────┘ └──────┬─────┘ └─────┬──────┘
157
- │ │ │ │
158
- └───────┼────────────┼───────────────┼──────────────┼─────────┘
159
- WebSocket │ │
160
- ▼ ▼
161
- ┌─────────────────────────────────────────────────────────────┐
162
- Node.js Server
163
- ┌────────────┐ ┌─────────────┐ ┌────────────────────┐
164
- │ │ WebSocket │ │ File API │ Cloudflare Tunnel
165
- │ │ Handler │ │ (read/write)│ │ Manager
166
- └─────┬──────┘ └──────┬──────┘ └─────────┬──────────┘
167
- │ │
168
- ▼ ▼
169
- ┌─────────┐ ┌────────────────┐ ┌──────────────────────┐ │
170
- │ │ node-pty│ │Local Filesystem│ │ cloudflared daemon │ │
171
- │ │ (shell) │ │(your server) │ │ (exposes to internet)│ │
172
- └─────────┘ └────────────────┘ └──────────────────────┘ │
173
- └─────────────────────────────────────────────────────────────┘
174
-
175
-
176
- ┌─────────────────┐
177
- │ Internet Users │
178
- │ (HTTPS URL) │
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.4.3",
3
+ "version": "1.4.6",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
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
- process.exit(0);
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
- process.exit(0);
152
+ return;
153
153
  }
154
154
  } catch (e) {
155
155
  console.log(' cloudflared install failed (cannot validate download): ' + e.message);
156
156
  cleanup();
157
- process.exit(0);
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) tab.term.write(payload);
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: false,
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
- let left = Math.min(e.clientX, window.innerWidth - mw - 8);
2537
- let top = Math.min(e.clientY, window.innerHeight - mh - 8);
2538
- if (left < 0) left = 8;
2539
- if (top < 0) top = 8;
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', () => document.getElementById('ctx-menu').classList.remove('open'));
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
- const enc = new TextEncoder().encode(text);
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('input', () => {
4175
- callback(_pasteInput.value);
4176
- _pasteInput.value = '';
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
- toast('Tap & hold to paste', 'info');
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
- renderCmdLib();
4724
- document.getElementById('cmd-lib-search').value = '';
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', () => runCmdLib(c.cmd));
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
- const val = trimmed.slice(idx + 1).trim();
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
- const SHELL = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : (fs.existsSync('/bin/bash') ? '/bin/bash' : 'sh'));
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
- app.set('trust proxy', 1);
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: 'Invalid PIN' });
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(/[^a-zA-Z0-9_.\-]/g, '_'))
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
- stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
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
- stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
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
- res.json({ success: true, mode: req.body.mode });
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
- const resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
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
- proc = pty.spawn(SHELL, [], {
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
- proc.onData(data => send(0x00, data));
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 64KB per message
1318
- const payload = buf.slice(1, Math.min(buf.length, 65537));
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;