webtun 1.4.3 → 1.4.5

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.5",
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); }
@@ -1193,6 +1197,15 @@ svg { display: block; }
1193
1197
  <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
1198
  </div>
1195
1199
 
1200
+ <!-- Terminal context menu -->
1201
+ <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:160px;overflow:hidden">
1202
+ <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>
1203
+ <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>
1204
+ <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>
1205
+ <div class="ctx-sep"></div>
1206
+ <div class="ctx-item" 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>
1207
+ </div>
1208
+
1196
1209
  <!-- Fuzzy Finder Modal -->
1197
1210
  <div class="overlay" id="finder-overlay">
1198
1211
  <div class="modal modal-md" style="padding:12px">
@@ -1362,6 +1375,7 @@ let settings = {
1362
1375
  cursor: 'block', blink: true, scrollback: 5000, bell: false,
1363
1376
  mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false
1364
1377
  };
1378
+ let serverPlatform = '';
1365
1379
 
1366
1380
  const isElectron = !!(window.electronAPI && window.electronAPI.isElectron);
1367
1381
 
@@ -1471,6 +1485,7 @@ async function unlockApp() {
1471
1485
  const info = await api('/api/home');
1472
1486
  homeDir = info.home;
1473
1487
  currentPath = homeDir;
1488
+ serverPlatform = info.platform || '';
1474
1489
  document.getElementById('hostname-badge').textContent = info.hostname || '—';
1475
1490
 
1476
1491
  // Check tmux availability
@@ -1819,10 +1834,77 @@ function connectWebSocket(tab, isReconnect = false) {
1819
1834
  setupVisualViewport();
1820
1835
  };
1821
1836
 
1837
+ // OSC sequence handler: intercepts OSC 7, 133, 52 before passing to xterm.js
1838
+ let oscBuf = '';
1839
+ function processTerminalOutput(data) {
1840
+ const str = typeof data === 'string' ? data : new TextDecoder().decode(data);
1841
+ let out = '';
1842
+ let i = 0;
1843
+ while (i < str.length) {
1844
+ // Look for ESC ] (OSC introducer)
1845
+ if (str[i] === '\x1b' && str[i + 1] === ']') {
1846
+ const endIdx = str.indexOf('\x07', i + 2);
1847
+ const stIdx = str.indexOf('\x1b\\', i + 2);
1848
+ let oscEnd = -1;
1849
+ if (endIdx !== -1 && (stIdx === -1 || endIdx < stIdx)) oscEnd = endIdx;
1850
+ else if (stIdx !== -1) oscEnd = stIdx;
1851
+
1852
+ if (oscEnd !== -1) {
1853
+ const oscData = str.substring(i + 2, oscEnd);
1854
+ const semi = oscData.indexOf(';');
1855
+ if (semi !== -1) {
1856
+ const code = oscData.substring(0, semi);
1857
+ const value = oscData.substring(semi + 1);
1858
+ if (code === '7') {
1859
+ // OSC 7: CWD update — file://hostname/path
1860
+ try {
1861
+ const url = new URL(value);
1862
+ tab.cwd = decodeURIComponent(url.pathname);
1863
+ } catch (_) {}
1864
+ } else if (code === '133') {
1865
+ // OSC 133: Shell integration markers (prompt/cmd start/end/done)
1866
+ // Handled silently — available for future command tracking
1867
+ } else if (code === '52') {
1868
+ // OSC 52: Clipboard operations
1869
+ const parts = value.split(';');
1870
+ const targets = parts[0] || 'c';
1871
+ const b64 = parts.slice(1).join(';');
1872
+ if (b64) {
1873
+ // SET clipboard
1874
+ try {
1875
+ const decoded = atob(b64);
1876
+ if (targets.includes('c') || targets.includes('p')) {
1877
+ navigator.clipboard.writeText(decoded).catch(() => {});
1878
+ }
1879
+ } catch (_) {}
1880
+ } else {
1881
+ // GET clipboard — respond with current clipboard contents
1882
+ navigator.clipboard.readText().then(text => {
1883
+ const response = '\x1b]52;c;' + btoa(text) + '\x07';
1884
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1885
+ const enc = new TextEncoder().encode(response);
1886
+ const buf = new Uint8Array(1 + enc.length);
1887
+ buf[0] = 0x00; buf.set(enc, 1);
1888
+ tab.ws.send(buf.buffer);
1889
+ }
1890
+ }).catch(() => {});
1891
+ }
1892
+ }
1893
+ }
1894
+ i = oscEnd + (str[oscEnd] === '\x07' ? 1 : 2);
1895
+ continue;
1896
+ }
1897
+ }
1898
+ out += str[i];
1899
+ i++;
1900
+ }
1901
+ if (out) tab.term.write(out);
1902
+ }
1903
+
1822
1904
  ws.onmessage = e => {
1823
1905
  const buf = new Uint8Array(e.data);
1824
1906
  const type = buf[0], payload = buf.slice(1);
1825
- if (type === 0x00) tab.term.write(payload);
1907
+ if (type === 0x00) processTerminalOutput(payload);
1826
1908
  else if (type === 0x01) tab.term.writeln('\r\n\x1b[31m[Process exited]\x1b[0m');
1827
1909
  else if (type === 0x02) tab.term.writeln('\r\n\x1b[31m' + new TextDecoder().decode(payload) + '\x1b[0m');
1828
1910
  };
@@ -1919,7 +2001,7 @@ function initTerminal(tab) {
1919
2001
  scrollback: settings.scrollback,
1920
2002
  allowTransparency: false,
1921
2003
  theme: getXtermTheme(),
1922
- windowsMode: false,
2004
+ windowsMode: serverPlatform === 'win32',
1923
2005
  convertEol: false,
1924
2006
  bellStyle: settings.bell ? 'sound' : 'none',
1925
2007
  smoothScrollDuration: 80,
@@ -1939,6 +2021,19 @@ function initTerminal(tab) {
1939
2021
  term.loadAddon(searchAddon);
1940
2022
  term.loadAddon(webLinksAddon);
1941
2023
 
2024
+ try {
2025
+ const unicodeAddon = new Unicode11Addon.Unicode11Addon();
2026
+ term.loadAddon(unicodeAddon);
2027
+ term.unicode.activeVersion = '11';
2028
+ } catch (_) {}
2029
+
2030
+ // GPU-accelerated renderer — falls back to canvas if WebGL unavailable
2031
+ try {
2032
+ const webglAddon = new WebglAddon.WebglAddon();
2033
+ webglAddon.onContextLoss(() => { webglAddon.dispose(); });
2034
+ term.loadAddon(webglAddon);
2035
+ } catch (_) {}
2036
+
1942
2037
  term.open(tab.wrapper);
1943
2038
 
1944
2039
  tab.term = term;
@@ -1960,6 +2055,23 @@ function initTerminal(tab) {
1960
2055
  tab.el.querySelector('.tab-title').textContent = tab.title;
1961
2056
  });
1962
2057
 
2058
+ // Bell: visual flash + desktop notification for background tabs
2059
+ term.onBell(() => {
2060
+ // Visual flash on terminal screen
2061
+ const screen = term.element?.querySelector('.xterm-screen');
2062
+ if (screen) {
2063
+ screen.classList.remove('term-bell-flash');
2064
+ void screen.offsetWidth; // reflow to restart animation
2065
+ screen.classList.add('term-bell-flash');
2066
+ }
2067
+ // Desktop notification if tab is in the background
2068
+ if (document.hidden && settings.bell && Notification.permission === 'granted') {
2069
+ try {
2070
+ new Notification(tab.title || 'WebTun', { body: 'Terminal bell', tag: 'webtun-bell' });
2071
+ } catch (_) {}
2072
+ }
2073
+ });
2074
+
1963
2075
  // Resize observer
1964
2076
  tab.resizeObserver = new ResizeObserver(() => {
1965
2077
  if (activeTabId === tab.id) { try { fitTerm(tab); } catch(e) { console.warn(e); } }
@@ -1979,6 +2091,55 @@ function initTerminal(tab) {
1979
2091
  }
1980
2092
  });
1981
2093
 
2094
+ // Terminal right-click context menu
2095
+ term.element.addEventListener('contextmenu', e => {
2096
+ e.preventDefault();
2097
+ const menu = document.getElementById('term-ctx-menu');
2098
+ const sel = term.getSelection();
2099
+ document.getElementById('term-ctx-copy').style.opacity = sel ? '' : '0.5';
2100
+ menu.style.left = Math.min(e.clientX, window.innerWidth - 170) + 'px';
2101
+ menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
2102
+ menu.style.display = 'block';
2103
+ });
2104
+
2105
+ // Touch-to-mouse translation for TUI apps (mobile)
2106
+ let _tapTimeout = null, _tapPos = null, _longPressTimer = null;
2107
+ term.element.addEventListener('touchstart', e => {
2108
+ if (e.touches.length !== 1) return;
2109
+ const touch = e.touches[0];
2110
+ _tapPos = { x: touch.clientX, y: touch.clientY, time: Date.now() };
2111
+ // Long press detection for right-click
2112
+ _longPressTimer = setTimeout(() => {
2113
+ if (_tapPos) {
2114
+ const evt = new MouseEvent('mousedown', { clientX: _tapPos.x, clientY: _tapPos.y, button: 2, bubbles: true });
2115
+ term.element.dispatchEvent(evt);
2116
+ const upEvt = new MouseEvent('mouseup', { clientX: _tapPos.x, clientY: _tapPos.y, button: 2, bubbles: true });
2117
+ term.element.dispatchEvent(upEvt);
2118
+ _tapPos = null;
2119
+ }
2120
+ }, 500);
2121
+ }, { passive: true });
2122
+ term.element.addEventListener('touchend', e => {
2123
+ clearTimeout(_longPressTimer);
2124
+ if (!_tapPos || e.changedTouches.length !== 1) return;
2125
+ const touch = e.changedTouches[0];
2126
+ const dx = Math.abs(touch.clientX - _tapPos.x);
2127
+ const dy = Math.abs(touch.clientY - _tapPos.y);
2128
+ const dt = Date.now() - _tapPos.time;
2129
+ if (dx < 10 && dy < 10 && dt < 300) {
2130
+ // Short tap → left click
2131
+ const evt = new MouseEvent('mousedown', { clientX: touch.clientX, clientY: touch.clientY, button: 0, bubbles: true });
2132
+ term.element.dispatchEvent(evt);
2133
+ const upEvt = new MouseEvent('mouseup', { clientX: touch.clientX, clientY: touch.clientY, button: 0, bubbles: true });
2134
+ term.element.dispatchEvent(upEvt);
2135
+ }
2136
+ _tapPos = null;
2137
+ }, { passive: true });
2138
+ term.element.addEventListener('touchmove', () => {
2139
+ clearTimeout(_longPressTimer);
2140
+ _tapPos = null;
2141
+ }, { passive: true });
2142
+
1982
2143
  // Fit terminal then connect WebSocket — ensures WS uses correct dimensions
1983
2144
  requestAnimationFrame(() => {
1984
2145
  fitTerm(tab);
@@ -2110,6 +2271,10 @@ function sendKey(key) {
2110
2271
  altLatch = false;
2111
2272
  }
2112
2273
  updateModifierButtons();
2274
+ } else {
2275
+ shiftLatch = false;
2276
+ altLatch = false;
2277
+ updateModifierButtons();
2113
2278
  }
2114
2279
  const tab = getActiveTab();
2115
2280
  if (tab?.ws?.readyState === WebSocket.OPEN) {
@@ -2520,6 +2685,27 @@ function renderBreadcrumb(fullPath) {
2520
2685
  // ═══════════════════════════════════════════════════════
2521
2686
  let fsClipboard = null;
2522
2687
 
2688
+ // Terminal context menu actions (registered once)
2689
+ document.getElementById('term-ctx-copy').addEventListener('click', () => {
2690
+ const t = getActiveTab();
2691
+ if (t?.term) { const sel = t.term.getSelection(); if (sel) navigator.clipboard.writeText(sel); }
2692
+ document.getElementById('term-ctx-menu').style.display = 'none';
2693
+ });
2694
+ document.getElementById('term-ctx-paste').addEventListener('click', () => {
2695
+ pasteToTerminal();
2696
+ document.getElementById('term-ctx-menu').style.display = 'none';
2697
+ });
2698
+ document.getElementById('term-ctx-select-all').addEventListener('click', () => {
2699
+ const t = getActiveTab();
2700
+ if (t?.term) t.term.selectAll();
2701
+ document.getElementById('term-ctx-menu').style.display = 'none';
2702
+ });
2703
+ document.getElementById('term-ctx-clear').addEventListener('click', () => {
2704
+ const t = getActiveTab();
2705
+ if (t?.term) t.term.clear();
2706
+ document.getElementById('term-ctx-menu').style.display = 'none';
2707
+ });
2708
+
2523
2709
  function showCtxMenu(e, file) {
2524
2710
  e.preventDefault();
2525
2711
  ctxTarget = file;
@@ -2552,7 +2738,11 @@ function showCtxMenu(e, file) {
2552
2738
  });
2553
2739
  }
2554
2740
 
2555
- document.addEventListener('click', () => document.getElementById('ctx-menu').classList.remove('open'));
2741
+ document.addEventListener('click', () => {
2742
+ document.getElementById('ctx-menu').classList.remove('open');
2743
+ const termMenu = document.getElementById('term-ctx-menu');
2744
+ if (termMenu) termMenu.style.display = 'none';
2745
+ });
2556
2746
 
2557
2747
  document.getElementById('ctx-open').onclick = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
2558
2748
  document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
@@ -3498,6 +3688,9 @@ function toggleSetting(k) {
3498
3688
  const ctrlBtn = document.getElementById('ctrl-toggle-btn');
3499
3689
  if (ctrlBtn) ctrlBtn.style.background = '';
3500
3690
  }
3691
+ if (k === 'bell' && settings.bell && 'Notification' in window && Notification.permission === 'default') {
3692
+ Notification.requestPermission();
3693
+ }
3501
3694
  if (k === 'datasaver') {
3502
3695
  if (settings.datasaver) {
3503
3696
  if (sysStatsTimer) { clearInterval(sysStatsTimer); sysStatsTimer = null; }
@@ -4151,7 +4344,9 @@ function pasteToTerminal() {
4151
4344
  if (!text) return;
4152
4345
  const tab = getActiveTab();
4153
4346
  if (tab?.ws && tab.ws.readyState === WebSocket.OPEN) {
4154
- const enc = new TextEncoder().encode(text);
4347
+ // Bracketed paste: wrap in escape sequences so the shell buffers the input
4348
+ const bracketed = '\x1b[200~' + text + '\x1b[201~';
4349
+ const enc = new TextEncoder().encode(bracketed);
4155
4350
  const buf = new Uint8Array(1 + enc.length);
4156
4351
  buf[0] = 0x00; buf.set(enc, 1);
4157
4352
  tab.ws.send(buf.buffer);
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,7 +117,10 @@ 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
 
@@ -132,7 +138,9 @@ app.use((req, res, next) => {
132
138
  });
133
139
 
134
140
  // Trust proxy for proper IP detection behind reverse proxy
135
- app.set('trust proxy', 1);
141
+ // 'loopback' only trusts 127.0.0.1/::1 — safe default for direct connections
142
+ // Set TRUST_PROXY=true in .env if behind a reverse proxy
143
+ app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : 'loopback');
136
144
 
137
145
  function constantTimeEqual(a, b) {
138
146
  if (typeof a !== 'string' || typeof b !== 'string') return false;
@@ -161,7 +169,7 @@ app.post('/api/auth', authRateLimiter, (req, res) => {
161
169
  if (!PIN || (pin && constantTimeEqual(pin, PIN))) {
162
170
  res.json({ success: true, token: PIN || 'open' });
163
171
  } else {
164
- res.status(401).json({ error: 'Invalid PIN' });
172
+ res.status(401).json({ error: 'Unauthorized' });
165
173
  }
166
174
  });
167
175
 
@@ -332,7 +340,13 @@ function killPid(pid, signal = 'SIGTERM') {
332
340
  if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return;
333
341
  try {
334
342
  if (os.platform() === 'win32') {
335
- execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
343
+ try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); } catch {}
344
+ // Fallback: also kill any remaining child processes via WMIC
345
+ try {
346
+ const out = execSync(`wmic process where "ParentProcessId=${pid}" get ProcessId /format:list`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
347
+ const childPids = out.split('\n').filter(l => l.startsWith('ProcessId=')).map(l => parseInt(l.split('=')[1])).filter(Boolean);
348
+ for (const cp of childPids) { try { execSync(`taskkill /PID ${cp} /F`, { stdio: 'ignore' }); } catch {} }
349
+ } catch {}
336
350
  } else {
337
351
  process.kill(pid, signal);
338
352
  }
@@ -344,7 +358,7 @@ function buildSessionEnv() {
344
358
  const env = { ...process.env };
345
359
  env.TERM = env.TERM || 'xterm-256color';
346
360
  env.COLORTERM = env.COLORTERM || 'truecolor';
347
- if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE;
361
+ if (!env.HOME && env.USERPROFILE) env.HOME = env.USERPROFILE.replace(/\\/g, '/');
348
362
  if (!env.USER && env.USERNAME) env.USER = env.USERNAME;
349
363
  env.SHELL = SHELL;
350
364
  // Prefer Path (Windows) over PATH if both set
@@ -747,7 +761,7 @@ app.post('/api/files/upload', checkPin, (req, res) => {
747
761
  cb(err);
748
762
  }
749
763
  },
750
- filename: (_, file, cb) => cb(null, path.basename(file.originalname).replace(/[^a-zA-Z0-9_.\-]/g, '_'))
764
+ 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
765
  });
752
766
  const upload = multer({ storage, limits: { fileSize: 500 * 1024 * 1024, files: 100 } }).array('files');
753
767
  upload(req, res, err => {
@@ -779,10 +793,22 @@ app.get('/api/files/stat', checkPin, async (req, res) => {
779
793
  isSocket: st.isSocket(), isFIFO: st.isFIFO(),
780
794
  };
781
795
  try {
782
- stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
796
+ if (os.platform() === 'win32') {
797
+ stat.owner = String(st.uid);
798
+ } else {
799
+ stat.owner = execFileSync('id', ['-nu', String(st.uid)], { encoding: 'utf8', stdio: 'pipe' }).trim();
800
+ }
783
801
  } catch { stat.owner = String(st.uid); }
784
802
  try {
785
- stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
803
+ if (os.platform() === 'win32') {
804
+ stat.group = String(st.gid);
805
+ } else if (os.platform() === 'darwin') {
806
+ const dscl = execSync(`dscl . -read /Groups/${st.gid} RecordName 2>/dev/null`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
807
+ const m = dscl.match(/RecordName:\s*(.+)/);
808
+ stat.group = m ? m[1].trim() : String(st.gid);
809
+ } else {
810
+ stat.group = execFileSync('getent', ['group', String(st.gid)], { encoding: 'utf8', stdio: 'pipe' }).split(':')[0];
811
+ }
786
812
  } catch { stat.group = String(st.gid); }
787
813
  try {
788
814
  const symlink = lst && lst.isSymbolicLink() ? await fsPromises.readlink(p) : null;
@@ -873,7 +899,8 @@ app.post('/api/files/chmod', checkPin, async (req, res) => {
873
899
  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
900
  const mode = parseInt(req.body.mode, 8);
875
901
  await fsPromises.chmod(p, mode);
876
- res.json({ success: true, mode: req.body.mode });
902
+ const warning = os.platform() === 'win32' ? 'chmod has no effect on Windows' : undefined;
903
+ res.json({ success: true, mode: req.body.mode, ...(warning && { warning }) });
877
904
  } catch (e) {
878
905
  res.status(500).json({ error: e.message });
879
906
  }
@@ -1071,10 +1098,21 @@ app.get('/api/system/network', checkPin, async (req, res) => {
1071
1098
  gateway = route.split('\n')[0].trim() || null;
1072
1099
  const dnsOut = execFileSync('powershell.exe', ['-Command', '(Get-DnsClientServerAddress -AddressFamily IPv4).ServerAddresses'], { encoding: 'utf8', stdio: 'pipe' }).trim();
1073
1100
  dns = dnsOut.split('\n').filter(Boolean);
1101
+ } else if (os.platform() === 'darwin') {
1102
+ const route = execFileSync('sh', ['-c', "route -n get default 2>/dev/null | awk '/gateway:/{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
1103
+ gateway = route || null;
1104
+ const resolv = execFileSync('sh', ['-c', "scutil --dns 2>/dev/null | awk '/nameserver\[0\]/{print $3}' | head -3"], { encoding: 'utf8', stdio: 'pipe' }).trim();
1105
+ dns = resolv.split('\n').filter(Boolean);
1074
1106
  } else {
1075
1107
  const route = execFileSync('sh', ['-c', "ip route | grep default | head -1 | awk '{print $3}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
1076
1108
  gateway = route || null;
1077
- const resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
1109
+ let resolv = '';
1110
+ try {
1111
+ resolv = execFileSync('sh', ['-c', "grep nameserver /etc/resolv.conf | awk '{print $2}'"], { encoding: 'utf8', stdio: 'pipe' }).trim();
1112
+ } catch {}
1113
+ if (!resolv) {
1114
+ 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 {}
1115
+ }
1078
1116
  dns = resolv.split('\n').filter(Boolean);
1079
1117
  }
1080
1118
  } catch {}
@@ -1085,6 +1123,15 @@ app.get('/api/system/network', checkPin, async (req, res) => {
1085
1123
  const m = l.match(/:(\d+)\s+/);
1086
1124
  return m ? { port: parseInt(m[1]), process: l.split(/\s+/).pop() } : null;
1087
1125
  }).filter(Boolean);
1126
+ } else if (os.platform() === 'darwin') {
1127
+ const out = execFileSync('lsof', ['-i', '-P', '-n', '-sTCP:LISTEN'], { encoding: 'utf8', stdio: 'pipe' }).trim();
1128
+ const lines = out.split('\n').slice(1).filter(Boolean);
1129
+ listenPorts = lines.map(l => {
1130
+ const parts = l.split(/\s+/);
1131
+ const addr = parts[8] || '';
1132
+ const m = addr.match(/:(\d+)$/);
1133
+ return m ? { port: parseInt(m[1]), address: addr, process: parts[0] || '' } : null;
1134
+ }).filter(Boolean);
1088
1135
  } else {
1089
1136
  const out = execFileSync('sh', ['-c', "ss -tlnp 2>/dev/null | tail -n+2"], { encoding: 'utf8', stdio: 'pipe' }).trim();
1090
1137
  listenPorts = out.split('\n').filter(Boolean).map(l => {
@@ -1282,7 +1329,8 @@ wss.on('connection', (ws, req) => {
1282
1329
  });
1283
1330
  }
1284
1331
  } else {
1285
- proc = pty.spawn(SHELL, [], {
1332
+ const shellArgs = os.platform() === 'win32' ? ['-NoLogo'] : ['-l'];
1333
+ proc = pty.spawn(SHELL, shellArgs, {
1286
1334
  name: 'xterm-256color', cols, rows, cwd,
1287
1335
  env: sessionEnv
1288
1336
  });
@@ -1293,9 +1341,29 @@ wss.on('connection', (ws, req) => {
1293
1341
  return;
1294
1342
  }
1295
1343
 
1296
- proc.onData(data => send(0x00, data));
1344
+ // Back-pressure: pause PTY output when WebSocket send buffer is full
1345
+ let paused = false;
1346
+ const HIGH_WATER = 4 * 1024 * 1024; // 4MB — pause PTY above this
1347
+ const LOW_WATER = 1 * 1024 * 1024; // 1MB — resume PTY below this
1348
+
1349
+ proc.onData(data => {
1350
+ if (ws.readyState !== WebSocket.OPEN) return;
1351
+ send(0x00, data);
1352
+ // If WebSocket buffer is backing up, pause PTY to prevent OOM
1353
+ if (!paused && ws.bufferedAmount > HIGH_WATER) {
1354
+ try { proc.pause(); paused = true; } catch (_) {}
1355
+ }
1356
+ });
1357
+
1358
+ // Drain check: resume PTY when buffer drops
1359
+ const drainCheck = setInterval(() => {
1360
+ if (paused && ws.bufferedAmount < LOW_WATER) {
1361
+ try { proc.resume(); paused = false; } catch (_) {}
1362
+ }
1363
+ }, 50);
1297
1364
 
1298
1365
  proc.onExit(() => {
1366
+ clearInterval(drainCheck);
1299
1367
  if (!TMUX || !sessionId) send(0x01, Buffer.from([0]));
1300
1368
  ws.close();
1301
1369
  });
@@ -1314,8 +1382,8 @@ wss.on('connection', (ws, req) => {
1314
1382
  if (buf.length < 1) return;
1315
1383
  const type = buf[0];
1316
1384
  if (type === 0x00) {
1317
- // Limit input to 64KB per message
1318
- const payload = buf.slice(1, Math.min(buf.length, 65537));
1385
+ // Limit input to 1MB per message
1386
+ const payload = buf.slice(1, Math.min(buf.length, 1048577));
1319
1387
  proc.write(payload.toString('utf8'));
1320
1388
  } else if (type === 0x01 && buf.length >= 5) {
1321
1389
  const c = buf.readUInt16LE(1), r = buf.readUInt16LE(3);
@@ -1331,6 +1399,7 @@ wss.on('connection', (ws, req) => {
1331
1399
 
1332
1400
  const cleanup = () => {
1333
1401
  clearInterval(pingInterval);
1402
+ clearInterval(drainCheck);
1334
1403
  try { proc.kill(); } catch {}
1335
1404
  };
1336
1405
  ws.on('close', cleanup);
@@ -1448,6 +1517,15 @@ app.get('/api/system', checkPin, async (req, res) => {
1448
1517
  mem: p.mem || '',
1449
1518
  cmd: p.cmd || ''
1450
1519
  }));
1520
+ } else if (os.platform() === 'darwin') {
1521
+ const psOut = await spawnRead('ps', ['-axo', 'pid,user,%cpu,%mem,command', '-r']);
1522
+ const lines = psOut.trim().split('\n').slice(1, 16);
1523
+ for (const line of lines) {
1524
+ const m = line.match(/^\s*(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)/);
1525
+ if (m) {
1526
+ processes.push({ user: m[2], pid: m[1], cpu: m[3], mem: m[4], cmd: m[5] });
1527
+ }
1528
+ }
1451
1529
  } else {
1452
1530
  const psOut = await spawnRead('ps', ['-eo', 'pid,user,%cpu,%mem,cmd', '--no-headers', '--sort=-%cpu']);
1453
1531
  const lines = psOut.trim().split('\n').slice(0, 15);
@@ -1482,9 +1560,12 @@ function isCloudflaredProcess(pid) {
1482
1560
  if (os.platform() === 'win32') {
1483
1561
  const stdout = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1484
1562
  return stdout.toLowerCase().includes('cloudflared');
1485
- } else {
1563
+ } else if (os.platform() === 'linux') {
1486
1564
  const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
1487
1565
  return cmdline.toLowerCase().includes('cloudflared');
1566
+ } else {
1567
+ const stdout = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1568
+ return stdout.toLowerCase().includes('cloudflared');
1488
1569
  }
1489
1570
  } catch {
1490
1571
  return false;