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 +128 -203
- package/package.json +1 -1
- package/public/favicon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +1 -0
- package/public/index.html +246 -105
- package/server.js +60 -3
package/README.md
CHANGED
|
@@ -1,223 +1,139 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-

|
|
5
|
-

|
|
6
|
-

|
|
7
|
-

|
|
8
|
-
|
|
9
|
-
**Access your server from any browser — no VPN, no SSH client, no installing anything.**
|
|
10
|
-
|
|
11
|
-
[](install.sh)
|
|
12
|
-
[](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
|
-
|
|
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
|
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
```bash
|
|
122
|
-
# Allow scripts once during install
|
|
123
|
-
npm install -g --allow-scripts=webtun,node-pty webtun
|
|
39
|
+
### Install globally
|
|
124
40
|
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
```bash
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
##
|
|
64
|
+
## Features
|
|
160
65
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
##
|
|
113
|
+
## Configuration
|
|
209
114
|
|
|
210
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 |
|
|
229
|
-
| Backend | Node.js
|
|
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
|
-
##
|
|
152
|
+
## Security
|
|
238
153
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
##
|
|
162
|
+
## Troubleshooting
|
|
252
163
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
186
|
+
### v1.5.0
|
|
187
|
+
- Initial release
|
|
263
188
|
|
|
264
189
|
---
|
|
265
190
|
|
|
266
191
|
<p align="center">
|
|
267
|
-
<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
|
Binary file
|
package/public/icon-192.png
CHANGED
|
Binary file
|
package/public/icon-512.png
CHANGED
|
Binary file
|
package/public/icon.svg
ADDED
|
@@ -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(
|
|
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 {
|
|
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(
|
|
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,
|
|
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
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
//
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
|
2342
|
+
// Scroll terminal on wheel
|
|
2281
2343
|
tab.wrapper.addEventListener('wheel', e => {
|
|
2282
2344
|
if (e.ctrlKey || e.metaKey) return;
|
|
2283
|
-
|
|
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())
|
|
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(
|
|
2392
|
+
const persisted = JSON.parse(safeStorage.getItem('wt-settings') || '{}');
|
|
2327
2393
|
persisted.fontSize = tab.term.options.fontSize;
|
|
2328
|
-
|
|
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 = () =>
|
|
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
|
-
|
|
3039
|
+
const files = selectedFiles.length > 0 ? selectedFiles : (ctxTarget ? [ctxTarget.path] : []);
|
|
3040
|
+
if (!files.length) return;
|
|
2971
3041
|
const t = toast('Zipping…', 'info');
|
|
2972
|
-
|
|
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 = () =>
|
|
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 = () => {
|
|
3001
|
-
|
|
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
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
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
|
|
3039
|
-
if (
|
|
3040
|
-
else toast(
|
|
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
|
-
}
|
|
3044
|
-
|
|
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(
|
|
3204
|
+
async function downloadFile(filePath) {
|
|
3106
3205
|
try {
|
|
3107
|
-
const r = await fetch(`/api/files/download?path=${encodeURIComponent(
|
|
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
|
-
|
|
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
|
-
|
|
3223
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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() {
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
|
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(
|
|
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) {
|
|
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 =
|
|
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 {
|
|
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(
|
|
5244
|
+
try { return JSON.parse(safeStorage.getItem('wt-cmdlib')) || []; } catch(e) { return []; }
|
|
5134
5245
|
}
|
|
5135
5246
|
function saveCmdLib(cmds) {
|
|
5136
|
-
|
|
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':
|
|
5407
|
+
return { 'Content-Type': 'application/json', 'x-pin-token': authToken || '' };
|
|
5297
5408
|
}
|
|
5298
5409
|
function histHeadersDelete() {
|
|
5299
|
-
return { 'x-pin-token':
|
|
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
|
|
5320
|
-
const clean = cmd
|
|
5321
|
-
.replace(/\x1b
|
|
5322
|
-
.replace(
|
|
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 =
|
|
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 {
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
});
|