webtun 1.4.6 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,223 +1,139 @@
1
- # WebTun — Self-Hosted Web Terminal
2
-
3
- ![Terminal](https://img.shields.io/badge/Terminal-Web--Native-2D5B8E?style=for-the-badge)
4
- ![PWA](https://img.shields.io/badge/PWA-Installable-6BA428?style=for-the-badge)
5
- ![Cloudflare](https://img.shields.io/badge/Tunnel-Cloudflare-F38020?style=for-the-badge)
6
- ![Desktop](https://img.shields.io/badge/Desktop-Electron-47848F?style=for-the-badge)
7
- ![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)
8
-
9
- **Access your server from any browser — no VPN, no SSH client, no installing anything.**
10
-
11
- [![Quick Start](https://img.shields.io/badge/Quick_Start-One_Command-2D5B8E?style=for-the-badge&logo=gnu-bash)](install.sh)
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)
13
-
14
- ---
15
-
16
- ## Why WebTun?
17
-
18
- | Traditional SSH | WebTun |
19
- |----------------|--------|
20
- | Need SSH client installed | Open any browser |
21
- | Configure VPN or port forwarding | Cloudflare Tunnel auto-configured |
22
- | Can't access from phone easily | PWA works on iOS/Android |
23
- | Share access requires key exchange | Web-based sharing in 1 click |
24
- | Corporate firewall blocks port 22 | Runs over HTTPS (port 443) |
25
-
26
- ---
27
-
28
- ## Features
1
+ <p align="center">
2
+ <img src="public/icon.svg" width="80" height="80" alt="WebTun">
3
+ </p>
29
4
 
30
- ### Core
31
- - **Real shell sessions** — node-pty backed, full bash/zsh support
32
- - **Multi-tab terminal** — drag tabs to reorder, side-by-side sessions like your desktop
33
- - **File explorer** — browse, upload, download, rename, delete, cut/copy/paste with conflict resolution (replace, merge, keep both, skip)
34
- - **WebSocket + xterm.js** — responsive, low-latency typing
5
+ <h1 align="center">WebTun</h1>
35
6
 
36
- ### Mobile-First UX
37
- - **Touch gestures** swipe to close tabs, swipe from edge to open sidebar, pull-to-refresh file list
38
- - **Mobile keyboard bar** ESC, Tab, arrow keys, Ctrl combos (Ctrl+C, Ctrl+D, etc.)
39
- - **Virtual keyboard aware** — terminal resizes when keyboard opens, no overlap
40
- - **Pinch-to-zoom** — adjust terminal font size (10–28px) with two fingers
41
- - **Selection mode** — toggle between terminal interaction and text selection
42
- - **Responsive** — optimized for phones (480px), tablets (1024px), and landscape orientation
7
+ <p align="center">
8
+ <strong>Self-hosted web terminal with file explorer, Cloudflare Tunnel, and PWA support</strong><br>
9
+ Access your server from any browser no VPN, no SSH client, no installing anything.
10
+ </p>
43
11
 
44
- ### File Explorer
45
- - **Multi-select** — select all / deselect all, batch delete and download files
46
- - **Zip / Extract** — right-click any file or folder to zip; right-click `.zip` files to extract
47
- - **Image viewer** — click images (.png, .jpg, .gif, .svg, .webp, .bmp, .ico) to preview inline
48
- - **Cut, Copy, Paste** — with conflict resolution dialog (replace, merge folders, keep both, skip, cancel)
49
- - **Upload with progress** — per-file progress bar via XHR
50
- - **Breadcrumb navigation** — tappable directory segments for quick navigation
51
- - **Long-press context menu** — rename, delete, download, copy on touch devices
52
- - **Text selection** — long-press file names to select and copy text on touch devices
53
-
54
- ### Desktop App (Electron)
55
- - Cross-platform app for Linux & Windows
56
- - Runs the server as a child process — no terminal needed
57
- - Native window with system tray, copy/paste, and file dialogs
58
- - Download the latest release from [GitHub Releases](https://github.com/unn-known1/webtun/releases/latest)
59
- - Or build from source: `npm run dist:linux` / `npm run dist:win`
60
-
61
- ### PWA (Install as App)
62
- - Add to iOS/Android home screen — looks and feels like native app
63
- - Install banner prompts on first visit (Chrome) with iOS instructions fallback
64
- - Works offline (shows last session state)
65
- - Push notifications for tunnel status
66
-
67
- ### Cloudflare Tunnel (Zero-Config)
68
- - Create tunnel from UI — no CLI commands
69
- - Built-in health check warns if local server is unreachable before creating tunnel
70
- - Live/orphan status + target responsiveness indicator in tunnel list
71
- - Get public HTTPS URL instantly
72
- - Tunnels survive server restarts
73
- - Stop/kill tunnels from settings panel
12
+ <p align="center">
13
+ <a href="https://github.com/unn-known1/webtun/releases/latest"><img src="https://img.shields.io/github/v/release/unn-known1/webtun?style=flat-square&logo=github" alt="Release"></a>
14
+ <img src="https://img.shields.io/badge/Terminal-xterm.js-2D5B8E?style=flat-square" alt="Terminal">
15
+ <img src="https://img.shields.io/badge/PWA-Installable-6BA428?style=flat-square" alt="PWA">
16
+ <img src="https://img.shields.io/badge/Tunnel-Cloudflare-F38020?style=flat-square" alt="Tunnel">
17
+ <img src="https://img.shields.io/badge/Desktop-Electron-47848F?style=flat-square" alt="Electron">
18
+ <img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
19
+ </p>
74
20
 
75
21
  ---
76
22
 
77
23
  ## Quick Start
78
24
 
79
- ### npm (Recommended)
80
25
  ```bash
81
26
  npx webtun
82
27
  ```
83
28
 
84
- Install globally:
85
- ```bash
86
- npm install -g webtun
87
- webtun
88
- ```
29
+ That's it. Open `http://localhost:3000` in your browser.
89
30
 
90
- **Options:**
91
- | Flag | Description |
92
- |------|-------------|
93
- | `--port, -p` | Port (default: 3000) |
94
- | `--host, -h` | Host (default: 0.0.0.0) |
95
- | `--pin` | Authentication PIN |
96
- | `--tunnel, -t` | Start Cloudflare Tunnel |
97
- | `--help` | Show help |
98
- | `--version` | Show version |
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
-
111
- **Examples:**
112
31
  ```bash
113
- npx webtun # Start on port 3000
114
- npx webtun --port 8080 # Custom port
115
32
  npx webtun --pin secret123 # With PIN protection
116
- npx webtun --tunnel # With Cloudflare Tunnel
117
- npx webtun -p 4000 -t # Port 4000 + tunnel
33
+ npx webtun --tunnel # With Cloudflare Tunnel (public URL)
34
+ npx webtun -p 8080 -t # Custom port + tunnel
35
+ npx webtun --help # Show all options
36
+ npx webtun --version # Show version
118
37
  ```
119
38
 
120
- **Note:** npm 12+ blocks install scripts by default. Allow them for `node-pty` (native module):
121
- ```bash
122
- # Allow scripts once during install
123
- npm install -g --allow-scripts=webtun,node-pty webtun
39
+ ### Install globally
124
40
 
125
- # Or allow globally (one-time setup)
126
- npm config set allow-scripts=webtun,node-pty --location=user
41
+ ```bash
127
42
  npm install -g webtun
43
+ webtun
128
44
  ```
129
45
 
130
- Build tools are also required (for compiling native modules):
131
- ```bash
132
- # Debian/Ubuntu
133
- sudo apt-get install -y python3 make g++
46
+ > **npm 12+ note:** Install scripts are blocked by default. Allow them for `node-pty`:
47
+ > ```bash
48
+ > npm install -g --allow-scripts=webtun,node-pty webtun
49
+ > ```
50
+ > Build tools required for native modules: `sudo apt-get install -y python3 make g++` (Debian/Ubuntu) or `xcode-select --install` (macOS).
134
51
 
135
- # macOS
136
- xcode-select --install
137
- ```
52
+ ### Other install methods
138
53
 
139
- ### One-Command Install
140
54
  ```bash
55
+ # One-command install
141
56
  bash -c "$(curl -fsSL https://raw.githubusercontent.com/unn-Known1/webtun/main/install.sh)"
142
- ```
143
-
144
- ### Manual Setup
145
- ```bash
146
- git clone https://github.com/unn-Known1/webtun.git
147
- cd webtun
148
- chmod +x setup.sh && ./setup.sh
149
- npm start
150
- ```
151
57
 
152
- ### Google Colab (Instant Terminal)
153
- ```python
154
- !rm -rf webtun && git clone https://github.com/unn-Known1/webtun.git && cd webtun && npm install --loglevel=error && node server.js > /tmp/webtun.log 2>&1 & sleep 4 && for i in 1 2 3; do curl -sf http://localhost:3000/api/auth/required >/dev/null && break; sleep 2; done && curl -s -X POST http://localhost:3000/api/tunnel -H 'Content-Type: application/json' -d '{"url":"http://localhost:3000"}' --max-time 20 | python3 -c "import sys,json; d=json.load(sys.stdin); print('🌐 WebTun ready at:', d.get('url','Error: '+d.get('error','')))"
58
+ # Manual setup
59
+ git clone https://github.com/unn-Known1/webtun.git && cd webtun && ./setup.sh && npm start
155
60
  ```
156
61
 
157
62
  ---
158
63
 
159
- ## Architecture
64
+ ## Features
160
65
 
161
- ```
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
- └─────────────────┘
191
- ```
66
+ ### Terminal
67
+ - **Real shell sessions** — node-pty backed, full bash/zsh/PowerShell support
68
+ - **Multi-tab** — drag to reorder, side-by-side sessions
69
+ - **Bracketed paste** — Ctrl+V works in TUI apps (vim, nano, htop)
70
+ - **Command history** — keystroke-based capture, strips ANSI/control sequences
71
+ - **tmux sessions** — persistent sessions survive page reload
192
72
 
193
- ---
73
+ ### File Explorer
74
+ - Browse, upload, download, rename, delete files
75
+ - **Multi-select** — batch delete, download, zip, cut, copy
76
+ - **Zip/Extract** — right-click any file or folder
77
+ - **Image viewer** — preview inline (png, jpg, gif, svg, webp)
78
+ - **Conflict resolution** — replace, merge, keep both, skip
79
+ - **Breadcrumb navigation** — tappable directory segments
80
+
81
+ ### Mobile
82
+ - **Touch gestures** — swipe to close tabs, pull-to-refresh
83
+ - **Mobile keyboard bar** — ESC, Tab, arrows, Ctrl combos
84
+ - **Pinch-to-zoom** — adjust font size with two fingers
85
+ - **Virtual keyboard aware** — no overlap when keyboard opens
86
+ - **Selection mode** — toggle between interaction and text selection
87
+
88
+ ### Cloudflare Tunnel
89
+ - Create tunnels from the UI — zero CLI commands
90
+ - Built-in health check before tunnel creation
91
+ - Live status indicators, tunnels survive restarts
92
+ - Stop/kill tunnels from settings panel
194
93
 
195
- ## Use Cases
94
+ ### System Stats
95
+ - CPU, Memory, Disk, Uptime — real-time with progress bars
96
+ - GPU detection (nvidia-smi, system_profiler, WMI)
97
+ - Multi-GPU support with VRAM, utilization, temperature
98
+ - Top processes sorted by CPU
196
99
 
197
- | Scenario | Why WebTun |
198
- |----------|------------|
199
- | **Home server access** | Access from anywhere without opening ports |
200
- | **Emergency debugging** | Phone/laptop without SSH client |
201
- | **Share terminal with friend** | No key exchange, just send URL |
202
- | **Colab power-up** | Persistent terminal alongside Python notebooks |
203
- | **Demo environment** | Spin up temp shell for presentations |
204
- | **Corporate restrictions** | HTTPS works where SSH is blocked |
100
+ ### Desktop (Electron)
101
+ - Cross-platform app for Linux, Windows, macOS
102
+ - Native window with system tray and file dialogs
103
+ - Build: `npm run dist:linux` / `npm run dist:win` / `npm run dist:mac`
104
+ - [Download latest release](https://github.com/unn-known1/webtun/releases/latest)
105
+
106
+ ### PWA
107
+ - Install to iOS/Android home screen
108
+ - Works offline with last session state
109
+ - Install banner with iOS instructions fallback
205
110
 
206
111
  ---
207
112
 
208
- ## Tunnel Manager
113
+ ## Configuration
209
114
 
210
- 1. Open **Settings** (gear icon)
211
- 2. Go to **Tunnel** section
212
- 3. Enter URL (default: `http://localhost:3000`)
213
- 4. Click **Create** — a health check pings the target first, warns if unreachable
214
- 5. Copy the public URL — share it with anyone
215
- 6. View all tunnels with live status indicators (green/orange)
115
+ ### CLI Flags
216
116
 
217
- **Note:** Tunnels created before a server restart need manual cleanup:
218
- ```bash
219
- kill $(pgrep -f 'cloudflared tunnel')
220
- ```
117
+ | Flag | Description | Default |
118
+ |------|-------------|---------|
119
+ | `--port, -p` | Server port | `3000` |
120
+ | `--host, -h` | Bind address | `0.0.0.0` |
121
+ | `--pin` | Authentication PIN | none |
122
+ | `--tunnel, -t` | Start Cloudflare Tunnel | off |
123
+ | `--help` | Show help | — |
124
+ | `--version` | Show version | — |
125
+
126
+ ### Environment Variables
127
+
128
+ | Variable | Description | Default |
129
+ |----------|-------------|---------|
130
+ | `PORT` | Server port | `3000` |
131
+ | `HOST` | Bind address | `0.0.0.0` |
132
+ | `PIN` | Auth PIN (empty = no auth) | none |
133
+ | `SHELL` | Shell to use | platform default |
134
+ | `WORKSPACE_ROOT` | File explorer root | `~` |
135
+ | `TRUST_PROXY` | Trust `X-Forwarded-For` from first proxy | `false` |
136
+ | `WEBTUN_SHELL` | Override shell on Windows | PowerShell |
221
137
 
222
138
  ---
223
139
 
@@ -225,44 +141,53 @@ kill $(pgrep -f 'cloudflared tunnel')
225
141
 
226
142
  | Layer | Technology |
227
143
  |-------|------------|
228
- | Frontend | HTML + Vanilla JS (PWA, prefers-color-scheme, visualViewport, Drag & Drop) |
229
- | Backend | Node.js + node-pty + Express |
230
- | Terminal | xterm.js + fit addon |
231
- | Protocol | WebSocket (ws) |
144
+ | Frontend | Vanilla JS, xterm.js, CodeMirror, PWA |
145
+ | Backend | Node.js, Express, node-pty |
232
146
  | Tunnel | Cloudflare Tunnel (cloudflared) |
233
147
  | Desktop | Electron + electron-builder |
148
+ | Archives | archiver v8 + yauzl |
234
149
 
235
150
  ---
236
151
 
237
- ## Troubleshooting
152
+ ## Security
238
153
 
239
- | Problem | Solution |
240
- |---------|----------|
241
- | Tunnel URL not loading | Check Cloudflare account quota at [dash.cloudflare.com](https://dash.cloudflare.com) |
242
- | Permission denied on shell | Ensure user has shell access: `chsh -s /bin/bash` |
243
- | File upload fails | Check `public/uploads/` permissions: `chmod 755 public/uploads/` |
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')` |
154
+ - PIN authentication on all API endpoints (`x-pin-token` header or `?token=` query)
155
+ - CSP headers restrict script sources to CDN
156
+ - WebSocket origin check prevents cross-site hijacking
157
+ - Per-IP rate limiting on auth and search endpoints
158
+ - npm audit clean 0 known vulnerabilities
248
159
 
249
160
  ---
250
161
 
251
- ## Contributing
162
+ ## Troubleshooting
252
163
 
253
- 1. Fork Branch → Commit → PR
254
- 2. Follow existing code style (ES6+, no frameworks)
255
- 3. Test locally with `npm start`
256
- 4. Update this README if adding features
164
+ | Problem | Solution |
165
+ |---------|----------|
166
+ | Tunnel not loading | Check quota at [dash.cloudflare.com](https://dash.cloudflare.com) |
167
+ | Port 3000 in use | `PORT=3001 npm start` |
168
+ | Shell permission denied | `chsh -s /bin/bash` |
169
+ | Behind reverse proxy | Set `TRUST_PROXY=true` in `.env` |
170
+ | Windows shell wrong | Set `WEBTUN_SHELL=/usr/bin/bash` in `.env` |
171
+ | Tunnel persists after restart | `kill $(pgrep -f 'cloudflared tunnel')` |
257
172
 
258
173
  ---
259
174
 
260
- ## License
175
+ ## Changelog
176
+
177
+ ### v1.5.1
178
+ - Fixed command history saving terminal garbage instead of actual commands
179
+ - Fixed history 401 errors on Cloudflare tunnel URLs
180
+ - Fixed VT/ANSI parameter sequences leaking into history
181
+ - Fixed CodeMirror `defineSimpleMode` error
182
+ - Fixed console accessibility warnings (labels, form fields)
183
+ - Added safe `localStorage` wrapper for Tracking Prevention compatibility
184
+ - New terminal icon matching in-app logo
261
185
 
262
- MIT — do whatever you want with it.
186
+ ### v1.5.0
187
+ - Initial release
263
188
 
264
189
  ---
265
190
 
266
191
  <p align="center">
267
- <sub>Made with ❤️ by <a href="https://github.com/unn-known1">Gaurang Patel</a></sub>
192
+ <sub>Built by <a href="https://github.com/unn-known1">Gaurang Patel</a> · MIT License</sub>
268
193
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.4.6",
3
+ "version": "1.5.1",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#c0caf5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"></rect><line x1="6" y1="8" x2="10" y2="8"></line><line x1="6" y1="12" x2="10" y2="12"></line><line x1="12" y1="8" x2="18" y2="8"></line><line x1="12" y1="12" x2="18" y2="12"></line></svg>
package/public/index.html CHANGED
@@ -4,9 +4,11 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="mobile-web-app-capable" content="yes">
7
8
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
9
  <meta name="theme-color" content="#1a1b26">
9
10
  <title>WebTun</title>
11
+ <link rel="icon" href="/favicon.png" type="image/png">
10
12
  <link rel="manifest" href="/manifest.json">
11
13
  <link rel="apple-touch-icon" href="/icon-192.png">
12
14
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
@@ -32,6 +34,7 @@
32
34
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/yaml/yaml.min.js"></script>
33
35
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/sql/sql.min.js"></script>
34
36
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/go/go.min.js"></script>
37
+ <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/addon/mode/simple.min.js"></script>
35
38
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/rust/rust.min.js"></script>
36
39
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/properties/properties.min.js"></script>
37
40
  <style>
@@ -857,9 +860,15 @@ svg { display: block; }
857
860
  </head>
858
861
  <body data-theme="light">
859
862
  <script>
863
+ // Safe localStorage wrapper — degrades gracefully when Tracking Prevention blocks storage
864
+ const safeStorage = {
865
+ getItem(k) { try { return localStorage.getItem(k); } catch(e) { return null; } },
866
+ setItem(k, v) { try { localStorage.setItem(k, v); } catch(e) {} },
867
+ removeItem(k) { try { localStorage.removeItem(k); } catch(e) {} }
868
+ };
860
869
  (function() {
861
870
  try {
862
- const settings = JSON.parse(localStorage.getItem('wt-settings')) || {};
871
+ const settings = JSON.parse(safeStorage.getItem('wt-settings')) || {};
863
872
  let theme = settings.theme || 'light';
864
873
  if (theme === 'system') {
865
874
  theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dracula' : 'light';
@@ -1061,7 +1070,7 @@ svg { display: block; }
1061
1070
  </div>
1062
1071
  <div id="editor-area">
1063
1072
  <textarea id="editor-textarea" spellcheck="false"></textarea>
1064
- <div id="editor-preview"><iframe id="editor-preview-iframe" sandbox="allow-same-origin"></iframe></div>
1073
+ <div id="editor-preview"><iframe id="editor-preview-iframe" sandbox="allow-same-origin allow-scripts"></iframe></div>
1065
1074
  </div>
1066
1075
  </div>
1067
1076
  </div>
@@ -1143,7 +1152,7 @@ svg { display: block; }
1143
1152
  <div class="settings-section">
1144
1153
  <h3>Appearance</h3>
1145
1154
  <div class="setting-row">
1146
- <label>Theme</label>
1155
+ <label for="s-theme">Theme</label>
1147
1156
  <select id="s-theme" onchange="applyTheme(this.value)">
1148
1157
  <option value="system">System</option>
1149
1158
  <option value="light">Light</option>
@@ -1155,11 +1164,11 @@ svg { display: block; }
1155
1164
  </select>
1156
1165
  </div>
1157
1166
  <div class="setting-row">
1158
- <label>Font Size</label>
1167
+ <label for="s-fontsize">Font Size</label>
1159
1168
  <input type="number" id="s-fontsize" min="8" max="32" value="14" onchange="applyFontSize(+this.value)">
1160
1169
  </div>
1161
1170
  <div class="setting-row">
1162
- <label>Font Family</label>
1171
+ <label for="s-font">Font Family</label>
1163
1172
  <select id="s-font" onchange="applyFont(this.value)">
1164
1173
  <option value="'SF Mono', 'Fira Code', Consolas, monospace">System Mono</option>
1165
1174
  <option value="'Fira Code', monospace">Fira Code</option>
@@ -1169,7 +1178,7 @@ svg { display: block; }
1169
1178
  </select>
1170
1179
  </div>
1171
1180
  <div class="setting-row">
1172
- <label>Cursor Style</label>
1181
+ <label for="s-cursor">Cursor Style</label>
1173
1182
  <select id="s-cursor" onchange="applyCursor(this.value)">
1174
1183
  <option value="block">Block</option>
1175
1184
  <option value="underline">Underline</option>
@@ -1177,44 +1186,48 @@ svg { display: block; }
1177
1186
  </select>
1178
1187
  </div>
1179
1188
  <div class="setting-row">
1180
- <label>Cursor Blink</label>
1181
- <div class="toggle" id="s-blink" role="switch" tabindex="0" aria-checked="true" onclick="toggleSetting('blink')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('blink')}"></div>
1189
+ <label for="s-blink-cb">Cursor Blink</label>
1190
+ <div class="toggle" id="s-blink" role="switch" tabindex="0" aria-checked="true" onclick="toggleSetting('blink')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('blink')}"><input type="checkbox" id="s-blink-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1182
1191
  </div>
1183
1192
  </div>
1184
1193
  <div class="settings-section">
1185
1194
  <h3>Terminal</h3>
1186
1195
  <div class="setting-row">
1187
- <label>Scrollback Lines</label>
1196
+ <label for="s-scrollback">Scrollback Lines</label>
1188
1197
  <input type="number" id="s-scrollback" min="100" max="100000" value="5000" onchange="applyScrollback(+this.value)">
1189
1198
  </div>
1190
1199
  <div class="setting-row">
1191
- <label>Bell Sound</label>
1192
- <div class="toggle" id="s-bell" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('bell')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('bell')}"></div>
1200
+ <label for="s-bell-cb">Bell Sound</label>
1201
+ <div class="toggle" id="s-bell" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('bell')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('bell')}"><input type="checkbox" id="s-bell-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1202
+ </div>
1203
+ <div class="setting-row">
1204
+ <label for="s-termRightClick-cb">Right-Click Menu</label>
1205
+ <div class="toggle on" id="s-termRightClick" role="switch" tabindex="0" aria-checked="true" onclick="toggleSetting('termRightClick')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('termRightClick')}"><input type="checkbox" id="s-termRightClick-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1193
1206
  </div>
1194
1207
  </div>
1195
1208
  <div class="settings-section">
1196
1209
  <h3>Interface</h3>
1197
1210
  <div class="setting-row">
1198
- <label>Show Mobile Keys</label>
1199
- <div class="toggle" id="s-mobilekeys" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('mobilekeys')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('mobilekeys')}"></div>
1211
+ <label for="s-mobilekeys-cb">Show Mobile Keys</label>
1212
+ <div class="toggle" id="s-mobilekeys" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('mobilekeys')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('mobilekeys')}"><input type="checkbox" id="s-mobilekeys-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1200
1213
  </div>
1201
1214
  <div class="setting-row">
1202
- <label>Confirm on Close</label>
1203
- <div class="toggle on" id="s-confirmclose" role="switch" tabindex="0" aria-checked="true" onclick="toggleSetting('confirmclose')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('confirmclose')}"></div>
1215
+ <label for="s-confirmclose-cb">Confirm on Close</label>
1216
+ <div class="toggle on" id="s-confirmclose" role="switch" tabindex="0" aria-checked="true" onclick="toggleSetting('confirmclose')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('confirmclose')}"><input type="checkbox" id="s-confirmclose-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1204
1217
  </div>
1205
1218
  <div class="setting-row">
1206
- <label>Data Saver</label>
1207
- <div class="toggle" id="s-datasaver" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('datasaver')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('datasaver')}"></div>
1219
+ <label for="s-datasaver-cb">Data Saver</label>
1220
+ <div class="toggle" id="s-datasaver" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('datasaver')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('datasaver')}"><input type="checkbox" id="s-datasaver-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1208
1221
  </div>
1209
1222
  <div class="setting-row electron-only" style="display:none">
1210
- <label>Start on Login</label>
1211
- <div class="toggle" id="s-autostart" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('autostart')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('autostart')}"></div>
1223
+ <label for="s-autostart-cb">Start on Login</label>
1224
+ <div class="toggle" id="s-autostart" role="switch" tabindex="0" aria-checked="false" onclick="toggleSetting('autostart')" onkeydown="if(event.key===' '||event.key==='Enter'){event.preventDefault();toggleSetting('autostart')}"><input type="checkbox" id="s-autostart-cb" style="position:absolute;opacity:0;pointer-events:none;width:0;height:0"></div>
1212
1225
  </div>
1213
1226
  </div>
1214
1227
  <div class="settings-section">
1215
1228
  <h3>Tunnel</h3>
1216
1229
  <div class="setting-row" style="flex-wrap:wrap;gap:6px">
1217
- <label style="flex:0 0 auto">Local URL</label>
1230
+ <label for="tunnel-url" style="flex:0 0 auto">Local URL</label>
1218
1231
  <div style="display:flex;flex:1;gap:6px;min-width:0">
1219
1232
  <input type="text" id="tunnel-url" value="http://localhost:3000" placeholder="http://localhost:PORT" style="flex:1;min-width:0">
1220
1233
  <button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:12px;flex-shrink:0" onclick="createTunnel()">Create</button>
@@ -1343,6 +1356,7 @@ svg { display: block; }
1343
1356
  <div class="sys-val" id="sys-uptime-val">—</div>
1344
1357
  <div class="sys-sub" id="sys-uptime-sub"></div>
1345
1358
  </div>
1359
+ <div id="sys-gpu-container"></div>
1346
1360
  </div>
1347
1361
  <div class="settings-section">
1348
1362
  <h3>Top Processes by CPU</h3>
@@ -1471,7 +1485,7 @@ let hasTmux = false;
1471
1485
  let settings = {
1472
1486
  theme: 'light', fontSize: 14, font: "'SF Mono', 'Fira Code', Consolas, monospace",
1473
1487
  cursor: 'block', blink: true, scrollback: 5000, bell: false,
1474
- mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false
1488
+ mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false, termRightClick: true
1475
1489
  };
1476
1490
  let serverPlatform = '';
1477
1491
 
@@ -1505,12 +1519,12 @@ function preventDoubleTap(el, fn, delay = 1000) {
1505
1519
 
1506
1520
  function saveTabState() {
1507
1521
  const state = tabs.map(t => ({ id: t.id, sessionId: t.sessionId, title: t.title }));
1508
- try { localStorage.setItem('wt-tabs', JSON.stringify(state)); } catch(e) { console.warn('Failed to save tabs:', e); }
1522
+ try { safeStorage.setItem('wt-tabs', JSON.stringify(state)); } catch(e) { console.warn('Failed to save tabs:', e); }
1509
1523
  }
1510
1524
 
1511
1525
  function loadTabState() {
1512
1526
  try {
1513
- const val = JSON.parse(localStorage.getItem('wt-tabs'));
1527
+ const val = JSON.parse(safeStorage.getItem('wt-tabs'));
1514
1528
  return Array.isArray(val) ? val : [];
1515
1529
  } catch(e) { console.warn('loadTabState error:', e); return []; }
1516
1530
  }
@@ -1733,6 +1747,8 @@ function setupTabDragDrop(tabEl, id) {
1733
1747
  function setupTabInlineRename(tabTitleSpan, tab) {
1734
1748
  const startRename = (span) => {
1735
1749
  const input = document.createElement('input');
1750
+ input.id = 'tab-rename-input';
1751
+ input.name = 'tab-rename';
1736
1752
  input.type = 'text';
1737
1753
  input.value = tab.title;
1738
1754
  input.style.cssText = `width:${Math.max(60, tab.title.length * 9)}px;height:22px;font-size:12px;padding:0 6px`;
@@ -1902,22 +1918,27 @@ function connectWebSocket(tab, isReconnect = false) {
1902
1918
 
1903
1919
  const sendInput = data => {
1904
1920
  if (ws.readyState !== WebSocket.OPEN) return;
1905
- // On Enter, read command from terminal screen buffer (avoids echo-back pollution)
1921
+ // On Enter, capture the command for history
1906
1922
  for (let i = 0; i < data.length; i++) {
1907
1923
  const ch = data.charCodeAt(i);
1908
1924
  if (ch === 13 || ch === 10) { // Enter
1909
1925
  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);
1926
+ // Prefer stored paste text
1927
+ let text = tab._lastPasteText;
1928
+ tab._lastPasteText = null;
1929
+ if (text) {
1930
+ // For multi-line pastes, take only the last line (the command)
1931
+ const lines = text.split('\n');
1932
+ text = lines[lines.length - 1] || lines[lines.length - 2] || text;
1933
+ text = text.trim();
1934
+ } else {
1935
+ // Use the tracked input buffer (more reliable than buffer scanning)
1936
+ text = (tab._currentInput || '').trim();
1937
+ // Strip any leaked VT/ANSI parameter junk that bypassed the escape parser
1938
+ text = text.replace(/^[>;\d\s]+(?=[a-zA-Z/\\~\-.])/, '').replace(/^[>;\d\s]+$/, '').trim();
1920
1939
  }
1940
+ if (text) addToCmdHist(text);
1941
+ tab._currentInput = ''; // Clear after history save
1921
1942
  } catch {}
1922
1943
  }
1923
1944
  }
@@ -1925,6 +1946,27 @@ function connectWebSocket(tab, isReconnect = false) {
1925
1946
  const buf = new Uint8Array(1 + enc.length);
1926
1947
  buf[0] = 0x00; buf.set(enc, 1);
1927
1948
  ws.send(buf.buffer);
1949
+
1950
+ // Update the keystroke buffer — skip all escape sequences and control characters
1951
+ for (let i = 0; i < data.length; i++) {
1952
+ const ch = data.charCodeAt(i);
1953
+ if (ch === 13 || ch === 10) { // Enter (handled above)
1954
+ continue;
1955
+ } else if (ch === 127 || ch === 8) { // Backspace / Delete
1956
+ if (tab._currentInput) tab._currentInput = tab._currentInput.slice(0, -1);
1957
+ } else if (ch === 27) { // ESC — skip entire escape sequence (CSI, SS3, OSC, DCS, etc.)
1958
+ i++; // skip first char after ESC (usually '[', 'O', ']', 'P')
1959
+ // Skip until we find a final char or run out of data
1960
+ while (i < data.length) {
1961
+ const c = data.charCodeAt(i);
1962
+ if (c >= 0x40 && c <= 0x7E) { i++; break; } // final char — sequence done
1963
+ i++;
1964
+ }
1965
+ } else if (ch >= 32 && ch < 0x7F) { // Printable ASCII only
1966
+ tab._currentInput = (tab._currentInput || '') + data[i];
1967
+ }
1968
+ // All other control chars (0x00-0x1F except 0x0D/0x0A) are silently dropped
1969
+ }
1928
1970
  };
1929
1971
  const sendResize = (cols, rows) => {
1930
1972
  if (ws.readyState !== WebSocket.OPEN) return;
@@ -2158,6 +2200,25 @@ function initTerminal(tab) {
2158
2200
  tab.searchAddon = searchAddon;
2159
2201
  tab.closed = false;
2160
2202
  tab.textarea = term.textarea || term.element?.querySelector('.xterm-textarea, textarea');
2203
+ if (tab.textarea && !tab.textarea.id) {
2204
+ tab.textarea.id = 'xterm-helper-' + tab.id;
2205
+ tab.textarea.name = 'terminal-input';
2206
+ }
2207
+
2208
+ // Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc)
2209
+ if (tab.textarea) {
2210
+ tab.textarea.addEventListener('paste', e => {
2211
+ e.preventDefault();
2212
+ const text = (e.clipboardData || window.clipboardData)?.getData('text');
2213
+ if (!text || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
2214
+ tab._lastPasteText = text;
2215
+ const bracketed = '\x1b[200~' + text + '\x1b[201~';
2216
+ const enc = new TextEncoder().encode(bracketed);
2217
+ const buf = new Uint8Array(1 + enc.length);
2218
+ buf[0] = 0x00; buf.set(enc, 1);
2219
+ tab.ws.send(buf.buffer);
2220
+ }, true);
2221
+ }
2161
2222
 
2162
2223
  // term.onFocus() was removed from xterm.js — use native DOM event instead
2163
2224
  term.element.addEventListener('focusin', () => {
@@ -2210,6 +2271,7 @@ function initTerminal(tab) {
2210
2271
 
2211
2272
  // Terminal right-click context menu
2212
2273
  term.element.addEventListener('contextmenu', e => {
2274
+ if (!settings.termRightClick) return;
2213
2275
  e.preventDefault();
2214
2276
  const menu = document.getElementById('term-ctx-menu');
2215
2277
  const sel = term.getSelection();
@@ -2277,18 +2339,22 @@ function initTerminal(tab) {
2277
2339
  if (!tab.closed) connectWebSocket(tab, false);
2278
2340
  });
2279
2341
 
2280
- // Scroll terminal on wheel; yield to TUI apps when mouse tracking is active
2342
+ // Scroll terminal on wheel
2281
2343
  tab.wrapper.addEventListener('wheel', e => {
2282
2344
  if (e.ctrlKey || e.metaKey) return;
2283
- // When a TUI app (vim, htop, tmux…) has mouse tracking on, let the app handle scroll
2345
+ e.preventDefault();
2346
+ // TUI apps with mouse tracking handle scroll themselves — forward the event to xterm.js
2284
2347
  try {
2285
2348
  const core = term._core;
2286
- if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) return;
2349
+ if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) {
2350
+ const vp = tab.wrapper.querySelector('.xterm-viewport');
2351
+ if (vp) vp.dispatchEvent(new WheelEvent('wheel', e));
2352
+ return;
2353
+ }
2287
2354
  } catch (_) {}
2288
2355
  const atTop = term.buffer.active.baseY === 0;
2289
2356
  const atBottom = term.buffer.active.baseY + term.rows >= term.buffer.active.length;
2290
2357
  if (tilesMode && ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom))) return;
2291
- e.preventDefault();
2292
2358
  let delta = e.deltaY;
2293
2359
  if (e.deltaMode === 1) delta *= 16;
2294
2360
  else if (e.deltaMode === 2) delta *= 16 * term.rows;
@@ -2323,9 +2389,9 @@ function initTerminal(tab) {
2323
2389
  if (tab.fitAddon) tab.fitAddon.fit();
2324
2390
  if (!window._pinchSaveTimer) {
2325
2391
  window._pinchSaveTimer = setTimeout(() => {
2326
- const persisted = JSON.parse(localStorage.getItem('wt-settings') || '{}');
2392
+ const persisted = JSON.parse(safeStorage.getItem('wt-settings') || '{}');
2327
2393
  persisted.fontSize = tab.term.options.fontSize;
2328
- localStorage.setItem('wt-settings', JSON.stringify(persisted));
2394
+ safeStorage.setItem('wt-settings', JSON.stringify(persisted));
2329
2395
  window._pinchSaveTimer = null;
2330
2396
  }, 500);
2331
2397
  }
@@ -2965,12 +3031,20 @@ document.addEventListener('click', () => {
2965
3031
 
2966
3032
  document.getElementById('ctx-open').onclick = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
2967
3033
  document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
2968
- document.getElementById('ctx-download').onclick = () => ctxTarget && downloadFile(ctxTarget.path);
3034
+ document.getElementById('ctx-download').onclick = () => {
3035
+ if (selectedFiles.length > 0) { downloadSelected(); return; }
3036
+ if (ctxTarget) downloadFile(ctxTarget.path);
3037
+ };
2969
3038
  document.getElementById('ctx-zip').onclick = async () => {
2970
- if (!ctxTarget) return;
3039
+ const files = selectedFiles.length > 0 ? selectedFiles : (ctxTarget ? [ctxTarget.path] : []);
3040
+ if (!files.length) return;
2971
3041
  const t = toast('Zipping…', 'info');
2972
- await zipFile(ctxTarget.path, ctxTarget.name);
3042
+ for (const p of files) {
3043
+ const name = p.split(/[\\/]/).pop();
3044
+ await zipFile(p, name);
3045
+ }
2973
3046
  t.remove();
3047
+ clearSelection();
2974
3048
  };
2975
3049
  document.getElementById('ctx-extract').onclick = async () => {
2976
3050
  if (!ctxTarget) return;
@@ -2995,10 +3069,29 @@ document.getElementById('ctx-folder-size').onclick = async () => {
2995
3069
  };
2996
3070
  document.getElementById('ctx-rename').onclick = () => ctxTarget && startRename(ctxTarget.path, ctxTarget.name);
2997
3071
  document.getElementById('ctx-copy-path').onclick = () => { if (!ctxTarget) return; navigator.clipboard.writeText(ctxTarget.path).then(() => toast('Path copied')).catch(() => toast('Copy failed', 'error')); };
2998
- document.getElementById('ctx-delete').onclick = () => ctxTarget && deleteFile(ctxTarget);
3072
+ document.getElementById('ctx-delete').onclick = () => {
3073
+ if (selectedFiles.length > 0) { deleteSelected(); return; }
3074
+ if (ctxTarget) deleteFile(ctxTarget);
3075
+ };
2999
3076
  document.getElementById('ctx-open-term').onclick = () => { if (!ctxTarget) return; newTab(`Term @ ${ctxTarget.name}`, null, ctxTarget.path); };
3000
- document.getElementById('ctx-copy').onclick = () => { if (!ctxTarget) return; fsClipboard = { action: 'copy', path: ctxTarget.path, name: ctxTarget.name }; updatePasteUI(); toast('Copied: ' + ctxTarget.name, 'success'); };
3001
- document.getElementById('ctx-cut').onclick = () => { if (!ctxTarget) return; fsClipboard = { action: 'cut', path: ctxTarget.path, name: ctxTarget.name }; updatePasteUI(); toast('Cut: ' + ctxTarget.name, 'success'); };
3077
+ document.getElementById('ctx-copy').onclick = () => {
3078
+ const files = selectedFiles.length > 0
3079
+ ? selectedFiles.map(p => ({ path: p, name: p.split(/[\\/]/).pop() }))
3080
+ : (ctxTarget ? [{ path: ctxTarget.path, name: ctxTarget.name }] : []);
3081
+ if (!files.length) return;
3082
+ fsClipboard = { action: 'copy', files };
3083
+ updatePasteUI();
3084
+ toast(`Copied ${files.length} item(s)`, 'success');
3085
+ };
3086
+ document.getElementById('ctx-cut').onclick = () => {
3087
+ const files = selectedFiles.length > 0
3088
+ ? selectedFiles.map(p => ({ path: p, name: p.split(/[\\/]/).pop() }))
3089
+ : (ctxTarget ? [{ path: ctxTarget.path, name: ctxTarget.name }] : []);
3090
+ if (!files.length) return;
3091
+ fsClipboard = { action: 'cut', files };
3092
+ updatePasteUI();
3093
+ toast(`Cut ${files.length} item(s)`, 'success');
3094
+ };
3002
3095
  document.getElementById('ctx-paste').onclick = () => { if (fsClipboard) pasteFile(currentPath); };
3003
3096
 
3004
3097
  function updatePasteUI() {
@@ -3013,38 +3106,44 @@ let _pasteBusy = false;
3013
3106
  async function pasteFile(destDir, conflictMode) {
3014
3107
  if (!fsClipboard || _pasteBusy) return;
3015
3108
  _pasteBusy = true;
3016
- const label = fsClipboard.action === 'copy' ? 'Copying' : 'Moving';
3017
- const toastEl = toast(label + '…', 'info');
3018
- const dest = joinPath(destDir, fsClipboard.name);
3019
- const ep = fsClipboard.action === 'copy' ? '/api/files/copy' : '/api/files/move';
3020
- const body = { source: fsClipboard.path, destination: dest };
3021
- if (conflictMode) body.conflict = conflictMode;
3022
- const r = await api(ep, {
3023
- method: 'POST',
3024
- headers: { 'Content-Type': 'application/json' },
3025
- body: JSON.stringify(body)
3026
- });
3027
- if (r.conflict) {
3028
- toastEl.remove();
3029
- _conflictResolve = { destDir, conflictMode: null };
3030
- document.getElementById('conflict-name').textContent = r.name;
3031
- document.getElementById('conflict-dir-hint').style.display = r.isDir ? '' : 'none';
3032
- document.getElementById('conflict-merge').style.display = r.isDir ? '' : 'none';
3033
- openOverlay('conflict-overlay');
3034
- return;
3035
- }
3036
- if (r.success) {
3109
+ try {
3110
+ // Support both old single-file and new multi-file format
3111
+ const files = fsClipboard.files || [{ path: fsClipboard.path, name: fsClipboard.name }];
3112
+ const label = fsClipboard.action === 'copy' ? 'Copying' : 'Moving';
3113
+ const toastEl = toast(label + '…', 'info');
3114
+ const ep = fsClipboard.action === 'copy' ? '/api/files/copy' : '/api/files/move';
3115
+ let succeeded = 0, failed = 0;
3116
+ for (const file of files) {
3117
+ const dest = joinPath(destDir, file.name);
3118
+ const body = { source: file.path, destination: dest };
3119
+ if (conflictMode) body.conflict = conflictMode;
3120
+ const r = await api(ep, {
3121
+ method: 'POST',
3122
+ headers: { 'Content-Type': 'application/json' },
3123
+ body: JSON.stringify(body)
3124
+ });
3125
+ if (r.conflict) {
3126
+ toastEl.remove();
3127
+ _conflictResolve = { destDir, conflictMode: null };
3128
+ document.getElementById('conflict-name').textContent = r.name;
3129
+ document.getElementById('conflict-dir-hint').style.display = r.isDir ? '' : 'none';
3130
+ document.getElementById('conflict-merge').style.display = r.isDir ? '' : 'none';
3131
+ openOverlay('conflict-overlay');
3132
+ return;
3133
+ }
3134
+ if (r.success) succeeded++;
3135
+ else failed++;
3136
+ }
3037
3137
  toastEl.remove();
3038
- const label = fsClipboard.action === 'copy' ? 'Pasted' : 'Moved';
3039
- if (r.skipped) toast(label + ' (skipped existing)', 'info');
3040
- else toast(label, 'success');
3138
+ const opLabel = fsClipboard.action === 'copy' ? 'Pasted' : 'Moved';
3139
+ if (failed === 0) toast(opLabel + ` (${succeeded} item(s))`, 'success');
3140
+ else if (succeeded === 0) toast(opLabel + ' failed', 'error');
3141
+ else toast(`${opLabel} ${succeeded}, ${failed} failed`, 'warning');
3041
3142
  if (fsClipboard.action === 'cut') { fsClipboard = null; updatePasteUI(); }
3042
3143
  refreshFiles();
3043
- } else {
3044
- toastEl.remove();
3045
- toast(r.error || 'Operation failed', 'error');
3144
+ } finally {
3145
+ _pasteBusy = false;
3046
3146
  }
3047
- _pasteBusy = false;
3048
3147
  }
3049
3148
 
3050
3149
  function resolveConflict(mode) {
@@ -3102,18 +3201,24 @@ async function deleteFile(file) {
3102
3201
  else toast(r.error, 'error');
3103
3202
  }
3104
3203
 
3105
- async function downloadFile(path) {
3204
+ async function downloadFile(filePath) {
3106
3205
  try {
3107
- const r = await fetch(`/api/files/download?path=${encodeURIComponent(path)}`, {
3206
+ const r = await fetch(`/api/files/download?path=${encodeURIComponent(filePath)}`, {
3108
3207
  headers: { 'x-pin-token': authToken }
3109
3208
  });
3110
3209
  if (!r.ok) { toast('Download failed', 'error'); return; }
3210
+ const ct = r.headers.get('content-type') || '';
3111
3211
  const blob = await r.blob();
3112
3212
  const url = URL.createObjectURL(blob);
3113
3213
  const a = document.createElement('a');
3114
3214
  a.href = url;
3115
- a.download = path.split(/[\\/]/).pop() || 'download';
3215
+ let name = filePath.split(/[\\/]/).pop() || 'download';
3216
+ // Server returns application/zip for directories — append .zip if missing
3217
+ if (ct.includes('zip') && !name.endsWith('.zip')) name += '.zip';
3218
+ a.download = name;
3219
+ document.body.appendChild(a);
3116
3220
  a.click();
3221
+ a.remove();
3117
3222
  URL.revokeObjectURL(url);
3118
3223
  } catch(e) { console.warn('Download failed:', e); toast('Download failed', 'error'); }
3119
3224
  }
@@ -3219,10 +3324,14 @@ async function deleteSelected() {
3219
3324
  }
3220
3325
 
3221
3326
  async function downloadSelected() {
3222
- for (const p of [...selectedFiles]) {
3223
- await downloadFile(p);
3327
+ const files = [...selectedFiles];
3328
+ if (!files.length) return;
3329
+ toast(`Downloading ${files.length} item(s)…`, 'info');
3330
+ for (let i = 0; i < files.length; i++) {
3331
+ await downloadFile(files[i]);
3332
+ // Small delay between downloads to avoid browser popup blocking
3333
+ if (i < files.length - 1) await new Promise(r => setTimeout(r, 300));
3224
3334
  }
3225
- toast('Downloads started', 'success');
3226
3335
  clearSelection();
3227
3336
  }
3228
3337
 
@@ -3486,7 +3595,7 @@ async function openFileEditor(path) {
3486
3595
  const splitArea = document.getElementById('editor-split-area');
3487
3596
  const orientToggle = document.getElementById('editor-split-toggle');
3488
3597
  try {
3489
- const orient = localStorage.getItem('wt-editor-split-orientation');
3598
+ const orient = safeStorage.getItem('wt-editor-split-orientation');
3490
3599
  const isHoriz = orient === 'horizontal';
3491
3600
  if (splitArea) splitArea.classList.toggle('horizontal', isHoriz);
3492
3601
  if (orientToggle) {
@@ -3510,7 +3619,7 @@ async function openFileEditor(path) {
3510
3619
  try {
3511
3620
  const isHoriz = splitArea && splitArea.classList.contains('horizontal');
3512
3621
  const key = isHoriz ? 'wt-editor-width' : 'wt-editor-height';
3513
- const saved = localStorage.getItem(key);
3622
+ const saved = safeStorage.getItem(key);
3514
3623
  if (saved) {
3515
3624
  document.documentElement.style.setProperty('--editor-size', saved + 'px');
3516
3625
  }
@@ -3843,7 +3952,7 @@ function searchKeydown(e) {
3843
3952
  // ═══════════════════════════════════════════════════════
3844
3953
  function loadSettings() {
3845
3954
  try {
3846
- const s = JSON.parse(localStorage.getItem('wt-settings'));
3955
+ const s = JSON.parse(safeStorage.getItem('wt-settings'));
3847
3956
  if (s && typeof s === 'object' && !Array.isArray(s)) {
3848
3957
  if (typeof s.theme === 'string' && ['system','tokyonight','light','solarized','gruvbox','dracula','monokai'].includes(s.theme)) settings.theme = s.theme;
3849
3958
  if (typeof s.fontSize === 'number' && s.fontSize >= 8 && s.fontSize <= 32) settings.fontSize = s.fontSize;
@@ -3863,7 +3972,7 @@ function loadSettings() {
3863
3972
  document.getElementById('s-scrollback').value = settings.scrollback;
3864
3973
  document.getElementById('s-font').value = settings.font;
3865
3974
  document.getElementById('s-cursor').value = settings.cursor;
3866
- syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver');
3975
+ syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver'); syncToggle('termRightClick');
3867
3976
  syncKeepAwakeUI();
3868
3977
  if (settings.keepAwake && hasWakeLock) { requestWakeLock(); }
3869
3978
  if (isElectron) {
@@ -3875,7 +3984,7 @@ function loadSettings() {
3875
3984
  }
3876
3985
  }
3877
3986
 
3878
- function saveSettings() { localStorage.setItem('wt-settings', JSON.stringify(settings)); }
3987
+ function saveSettings() { safeStorage.setItem('wt-settings', JSON.stringify(settings)); }
3879
3988
 
3880
3989
  function saveSetting(k, v) { settings[k] = v; saveSettings(); }
3881
3990
 
@@ -4175,7 +4284,7 @@ function setupSidebarResize() {
4175
4284
  document.removeEventListener('mouseup', stopResize);
4176
4285
  document.removeEventListener('touchmove', doResize);
4177
4286
  document.removeEventListener('touchend', stopResize);
4178
- try { localStorage.setItem('wt-sidebar-width', String(sidebar.offsetWidth)); } catch(e) { console.warn(e); }
4287
+ try { safeStorage.setItem('wt-sidebar-width', String(sidebar.offsetWidth)); } catch(e) { console.warn(e); }
4179
4288
  }
4180
4289
  handle.addEventListener('mousedown', startResize);
4181
4290
  handle.addEventListener('touchstart', startResize, { passive: false });
@@ -4184,7 +4293,7 @@ function setupSidebarResize() {
4184
4293
  function restoreSidebarWidth() {
4185
4294
  if (window.innerWidth <= 768) return;
4186
4295
  try {
4187
- const savedW = localStorage.getItem('wt-sidebar-width');
4296
+ const savedW = safeStorage.getItem('wt-sidebar-width');
4188
4297
  if (savedW) {
4189
4298
  const w = Math.max(150, Math.min(600, parseInt(savedW)));
4190
4299
  if (w) {
@@ -4262,7 +4371,7 @@ function setupEditorResize() {
4262
4371
  const v = document.documentElement.style.getPropertyValue('--editor-size');
4263
4372
  if (v) {
4264
4373
  const key = isHorizontal() ? 'wt-editor-width' : 'wt-editor-height';
4265
- localStorage.setItem(key, v.replace('px', ''));
4374
+ safeStorage.setItem(key, v.replace('px', ''));
4266
4375
  }
4267
4376
  } catch(e) { console.warn(e); }
4268
4377
  }
@@ -4301,7 +4410,7 @@ function toggleEditorSplit() {
4301
4410
  // Restore saved size for this orientation
4302
4411
  try {
4303
4412
  const key = isHoriz ? 'wt-editor-width' : 'wt-editor-height';
4304
- const saved = localStorage.getItem(key);
4413
+ const saved = safeStorage.getItem(key);
4305
4414
  if (saved) {
4306
4415
  document.documentElement.style.setProperty('--editor-size', saved + 'px');
4307
4416
  }
@@ -4313,7 +4422,7 @@ function toggleEditorSplit() {
4313
4422
  });
4314
4423
 
4315
4424
  // Persist orientation
4316
- try { localStorage.setItem('wt-editor-split-orientation', isHoriz ? 'horizontal' : 'vertical'); } catch(e) { console.warn(e); }
4425
+ try { safeStorage.setItem('wt-editor-split-orientation', isHoriz ? 'horizontal' : 'vertical'); } catch(e) { console.warn(e); }
4317
4426
  }
4318
4427
 
4319
4428
  // ═══════════════════════════════════════════════════════
@@ -4589,6 +4698,8 @@ function pasteToTerminal() {
4589
4698
  function fallbackPaste(callback) {
4590
4699
  if (!_pasteInput) {
4591
4700
  _pasteInput = document.createElement('textarea');
4701
+ _pasteInput.id = 'fallback-paste-area';
4702
+ _pasteInput.name = 'paste-input';
4592
4703
  _pasteInput.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1';
4593
4704
  document.body.appendChild(_pasteInput);
4594
4705
  _pasteInput.addEventListener('paste', () => {
@@ -4822,7 +4933,7 @@ function closeOverlay(id) {
4822
4933
  overlay.classList.remove('open');
4823
4934
  removeFocusTrap();
4824
4935
  if (lastFocusedElement) {
4825
- setTimeout(() => lastFocusedElement.focus(), 50);
4936
+ setTimeout(() => lastFocusedElement?.focus(), 50);
4826
4937
  lastFocusedElement = null;
4827
4938
  }
4828
4939
  }
@@ -5002,9 +5113,9 @@ function showPinScreen() {
5002
5113
  // BOOKMARKS
5003
5114
  // ═══════════════════════════════════════════════════════
5004
5115
  function getBookmarks() {
5005
- try { return JSON.parse(localStorage.getItem('wt-bookmarks')) || []; } catch(e) { console.warn('Failed to parse bookmarks:', e); return []; }
5116
+ try { return JSON.parse(safeStorage.getItem('wt-bookmarks')) || []; } catch(e) { console.warn('Failed to parse bookmarks:', e); return []; }
5006
5117
  }
5007
- function saveBookmarks(bm) { localStorage.setItem('wt-bookmarks', JSON.stringify(bm)); renderBookmarks(); }
5118
+ function saveBookmarks(bm) { safeStorage.setItem('wt-bookmarks', JSON.stringify(bm)); renderBookmarks(); }
5008
5119
 
5009
5120
  function renderBookmarks() {
5010
5121
  const list = document.getElementById('bookmarks-list');
@@ -5053,11 +5164,11 @@ function bookmarkCurrentDir() {
5053
5164
  toast('Bookmarked', 'success');
5054
5165
  }
5055
5166
 
5056
- let bookmarksOpen = (() => { try { const v = localStorage.getItem('wt-bm-open'); return v !== null ? v === 'true' : true; } catch(e) { console.warn('Failed to read bookmarksOpen:', e); return true; } })();
5167
+ let bookmarksOpen = (() => { try { const v = safeStorage.getItem('wt-bm-open'); return v !== null ? v === 'true' : true; } catch(e) { console.warn('Failed to read bookmarksOpen:', e); return true; } })();
5057
5168
  function toggleBookmarks() {
5058
5169
  bookmarksOpen = !bookmarksOpen;
5059
5170
  document.getElementById('bookmarks-list').classList.toggle('open', bookmarksOpen);
5060
- try { localStorage.setItem('wt-bm-open', bookmarksOpen); } catch(e) { console.warn(e); }
5171
+ try { safeStorage.setItem('wt-bm-open', bookmarksOpen); } catch(e) { console.warn(e); }
5061
5172
  }
5062
5173
 
5063
5174
  // ═══════════════════════════════════════════════════════
@@ -5130,10 +5241,10 @@ function finderKeydown(e) {
5130
5241
  const DEFAULT_CMDS = window.DEFAULT_CMDS || [];
5131
5242
 
5132
5243
  function getCmdLib() {
5133
- try { return JSON.parse(localStorage.getItem('wt-cmdlib')) || []; } catch(e) { return []; }
5244
+ try { return JSON.parse(safeStorage.getItem('wt-cmdlib')) || []; } catch(e) { return []; }
5134
5245
  }
5135
5246
  function saveCmdLib(cmds) {
5136
- localStorage.setItem('wt-cmdlib', JSON.stringify(cmds));
5247
+ safeStorage.setItem('wt-cmdlib', JSON.stringify(cmds));
5137
5248
  }
5138
5249
 
5139
5250
  function toggleCmdLib() {
@@ -5293,10 +5404,10 @@ let cmdHistMax = 50;
5293
5404
  let _cmdHistCache = [];
5294
5405
 
5295
5406
  function histHeaders() {
5296
- return { 'Content-Type': 'application/json', 'x-pin-token': localStorage.getItem('wt-pin') || '' };
5407
+ return { 'Content-Type': 'application/json', 'x-pin-token': authToken || '' };
5297
5408
  }
5298
5409
  function histHeadersDelete() {
5299
- return { 'x-pin-token': localStorage.getItem('wt-pin') || '' };
5410
+ return { 'x-pin-token': authToken || '' };
5300
5411
  }
5301
5412
  async function getCmdHist() {
5302
5413
  try {
@@ -5316,10 +5427,15 @@ function loadHistMax() {
5316
5427
  }
5317
5428
  async function addToCmdHist(cmd) {
5318
5429
  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, '')
5430
+ // Strip all ANSI/VT escape sequences and control characters
5431
+ const clean = cmd
5432
+ .replace(/\x1b[^a-zA-Z0-9]*[a-zA-Z0-9~]/g, '') // CSI: ESC [ ... final
5433
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC: ESC ] ... BEL/ST
5434
+ .replace(/\x1b[OPP][^\x40-\x7e]*[\x40-\x7e]/g, '') // SS3/DCS: ESC O/P ... final
5435
+ .replace(/\x1b./g, '') // Any other ESC sequence
5436
+ .replace(/[\x00-\x1f\x7f]/g, '') // All remaining control chars
5437
+ .replace(/^[>;\d\s]+(?=[a-zA-Z/\\~\-.])/, '') // VT params before a real command char
5438
+ .replace(/^[>;\d\s]+$/, '') // Pure VT params, no command at all
5323
5439
  .trim();
5324
5440
  if (!clean) return;
5325
5441
  try {
@@ -5494,6 +5610,31 @@ async function refreshSystemStats(showLoading) {
5494
5610
  document.getElementById('sys-uptime-val').textContent = fmtUptime(data.uptime);
5495
5611
  document.getElementById('sys-uptime-sub').textContent = data.hostname + ' · ' + data.platform;
5496
5612
 
5613
+ const gpuContainer = document.getElementById('sys-gpu-container');
5614
+ gpuContainer.innerHTML = '';
5615
+ const gpuList = data.gpus || (data.gpu ? [data.gpu] : []);
5616
+ if (gpuList.length) {
5617
+ gpuList.forEach((g, i) => {
5618
+ const card = document.createElement('div');
5619
+ card.className = 'sys-card';
5620
+ const label = gpuList.length > 1 ? `GPU ${i + 1}` : 'GPU';
5621
+ let sub = '';
5622
+ if (g.memTotal > 0) sub += formatSize(g.memTotal * 1024 * 1024) + ' VRAM';
5623
+ else if (g.memTotal === 0 && g.driver !== 'lspci') sub += 'Shared memory';
5624
+ if (g.utilization != null) sub += (sub ? ' · ' : '') + g.utilization + '% util';
5625
+ if (g.temp) sub += (sub ? ' · ' : '') + g.temp + '°C';
5626
+ if (g.driver && g.driver !== 'nvidia' && g.driver !== 'macos' && g.driver !== 'lspci') sub += (sub ? ' · ' : '') + 'Driver: ' + g.driver;
5627
+ let barHtml = '';
5628
+ if (g.memTotal > 0 && g.memUsed) {
5629
+ const pct = Math.round((g.memUsed / g.memTotal) * 100);
5630
+ const cls = pct > 80 ? ' danger' : pct > 50 ? ' warn' : '';
5631
+ barHtml = `<div class="sys-bar"><div class="sys-bar-fill${cls}" style="width:${Math.min(pct, 100)}%"></div></div>`;
5632
+ }
5633
+ card.innerHTML = `<h3>${label}</h3><div class="sys-val">${g.name}</div><div class="sys-sub">${sub || 'No details available'}</div>${barHtml}`;
5634
+ gpuContainer.appendChild(card);
5635
+ });
5636
+ }
5637
+
5497
5638
  const tbody = document.getElementById('sys-proc-body');
5498
5639
  tbody.innerHTML = '';
5499
5640
  if (data.processes) {
@@ -5512,7 +5653,7 @@ async function refreshSystemStats(showLoading) {
5512
5653
  // ═══════════════════════════════════════════════════════
5513
5654
  let deferredPrompt = null;
5514
5655
  let installBannerDismissed = false;
5515
- try { installBannerDismissed = localStorage.getItem('wt-install-dismissed') === 'true'; } catch(e) { console.warn(e); }
5656
+ try { installBannerDismissed = safeStorage.getItem('wt-install-dismissed') === 'true'; } catch(e) { console.warn(e); }
5516
5657
 
5517
5658
  window.addEventListener('beforeinstallprompt', e => {
5518
5659
  e.preventDefault();
@@ -5551,7 +5692,7 @@ function installPWA() {
5551
5692
  function dismissInstall() {
5552
5693
  document.getElementById('install-banner').style.display = 'none';
5553
5694
  document.getElementById('toast-container').classList.remove('banner-visible');
5554
- try { localStorage.setItem('wt-install-dismissed', 'true'); } catch(e) { console.warn(e); }
5695
+ try { safeStorage.setItem('wt-install-dismissed', 'true'); } catch(e) { console.warn(e); }
5555
5696
  installBannerDismissed = true;
5556
5697
  }
5557
5698
 
package/server.js CHANGED
@@ -48,7 +48,7 @@ const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
50
  const { execSync, execFileSync, spawn } = require('child_process');
51
- const archiver = require('archiver');
51
+ const { ZipArchive } = require('archiver');
52
52
  const yauzl = require('yauzl');
53
53
  const https = require('https');
54
54
  const http = require('http');
@@ -251,7 +251,7 @@ async function dirSize(dir) {
251
251
  function createZipArchive(entries, zipPath) {
252
252
  return new Promise((resolve, reject) => {
253
253
  const output = fs.createWriteStream(zipPath);
254
- const archive = archiver('zip', { zlib: { level: 6 } });
254
+ const archive = new ZipArchive({ zlib: { level: 6 } });
255
255
  output.on('close', () => resolve());
256
256
  output.on('error', reject);
257
257
  archive.on('error', reject);
@@ -270,7 +270,7 @@ function createZipArchive(entries, zipPath) {
270
270
  }
271
271
 
272
272
  function streamZipDirectory(dirPath, res) {
273
- const archive = archiver('zip', { zlib: { level: 6 } });
273
+ const archive = new ZipArchive({ zlib: { level: 6 } });
274
274
  archive.on('error', err => {
275
275
  if (!res.headersSent) res.status(500).json({ error: err.message });
276
276
  else res.end();
@@ -1605,12 +1605,69 @@ app.get('/api/system', checkPin, async (req, res) => {
1605
1605
  }
1606
1606
  } catch {}
1607
1607
 
1608
+ let gpus = [];
1609
+ try {
1610
+ if (os.platform() === 'linux') {
1611
+ // Try nvidia-smi first (NVIDIA GPUs — supports multi-GPU)
1612
+ try {
1613
+ const nvOut = await spawnRead('nvidia-smi', ['--query-gpu=name,memory.total,memory.used,memory.free,utilization.gpu,temperature.gpu', '--format=csv,noheader,nounits']);
1614
+ if (nvOut && nvOut.trim()) {
1615
+ for (const line of nvOut.trim().split('\n')) {
1616
+ const parts = line.split(',').map(s => s.trim());
1617
+ if (parts.length >= 6 && parts[0]) {
1618
+ gpus.push({ name: parts[0], memTotal: +parts[1] || 0, memUsed: +parts[2] || 0, memFree: +parts[3] || 0, utilization: +parts[4] || 0, temp: +parts[5] || 0, driver: 'nvidia' });
1619
+ }
1620
+ }
1621
+ }
1622
+ } catch {}
1623
+ // Fallback: lspci for any GPU (Intel, AMD, etc.)
1624
+ if (!gpus.length) {
1625
+ try {
1626
+ const lspciOut = await spawnRead('lspci', []);
1627
+ if (lspciOut && lspciOut.trim()) {
1628
+ const lines = lspciOut.split('\n').filter(l => /VGA|3D|Display/i.test(l));
1629
+ for (const line of lines) {
1630
+ const name = line.replace(/^[\da-f]+:[\da-f]+\.[\da-f]+\s+/, '').trim();
1631
+ if (name) gpus.push({ name, driver: 'lspci' });
1632
+ }
1633
+ }
1634
+ } catch {}
1635
+ }
1636
+ } else if (os.platform() === 'darwin') {
1637
+ const spOut = await spawnRead('system_profiler', ['SPDisplaysDataType']);
1638
+ if (spOut) {
1639
+ // Split by chipset sections to handle multiple GPUs
1640
+ const sections = spOut.split(/(?=Chipset Model:)/);
1641
+ for (const section of sections) {
1642
+ const chipMatch = section.match(/Chipset Model:\s*(.+)/);
1643
+ const vramMatch = section.match(/VRAM.*?:\s*(\d+)\s*MB/);
1644
+ if (chipMatch) {
1645
+ gpus.push({ name: chipMatch[1].trim(), memTotal: vramMatch ? +vramMatch[1] : 0, driver: 'macos' });
1646
+ }
1647
+ }
1648
+ }
1649
+ } else if (os.platform() === 'win32') {
1650
+ const psGpu = await spawnRead('powershell.exe', ['-Command', "Get-CimInstance -ClassName Win32_VideoController | Select-Object Name,AdapterRAM,DriverVersion | ConvertTo-Json"]);
1651
+ if (psGpu) {
1652
+ const data = JSON.parse(psGpu);
1653
+ const list = Array.isArray(data) ? data : [data];
1654
+ for (const d of list) {
1655
+ if (d && d.Name) {
1656
+ const vramBytes = d.AdapterRAM || 0;
1657
+ gpus.push({ name: d.Name, memTotal: Math.round(vramBytes / (1024 * 1024)), driver: d.DriverVersion || '' });
1658
+ }
1659
+ }
1660
+ }
1661
+ }
1662
+ } catch {}
1663
+
1608
1664
  res.json({
1609
1665
  hostname: os.hostname(),
1610
1666
  platform: os.platform(),
1611
1667
  uptime: os.uptime(),
1612
1668
  cpu: { model: cpuModel, count: cpuCount, usage: cpuUsage, loadAvg },
1613
1669
  memory: { total: totalMem, free: freeMem, used: usedMem, percent: memPercent },
1670
+ gpus,
1614
1671
  disk,
1615
1672
  processes
1616
1673
  });