webtun 1.4.6 → 1.5.0
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 +28 -3
- package/package.json +1 -1
- package/public/index.html +171 -56
- package/server.js +60 -3
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
- **Multi-tab terminal** — drag tabs to reorder, side-by-side sessions like your desktop
|
|
33
33
|
- **File explorer** — browse, upload, download, rename, delete, cut/copy/paste with conflict resolution (replace, merge, keep both, skip)
|
|
34
34
|
- **WebSocket + xterm.js** — responsive, low-latency typing
|
|
35
|
+
- **Bracketed paste** — Ctrl+V works correctly in TUI apps (vim, nano, htop, mc)
|
|
36
|
+
- **Command history** — automatic capture with smart dedup, works with pasted commands
|
|
35
37
|
|
|
36
38
|
### Mobile-First UX
|
|
37
39
|
- **Touch gestures** — swipe to close tabs, swipe from edge to open sidebar, pull-to-refresh file list
|
|
@@ -41,16 +43,28 @@
|
|
|
41
43
|
- **Selection mode** — toggle between terminal interaction and text selection
|
|
42
44
|
- **Responsive** — optimized for phones (480px), tablets (1024px), and landscape orientation
|
|
43
45
|
|
|
46
|
+
### Settings
|
|
47
|
+
- **Right-click menu toggle** — enable/disable custom terminal context menu (Settings > Terminal)
|
|
48
|
+
- **Terminal scroll** — reliable mouse wheel scroll in all apps, including TUI apps with mouse tracking
|
|
49
|
+
|
|
44
50
|
### File Explorer
|
|
45
|
-
- **Multi-select** — select all / deselect all, batch delete and
|
|
46
|
-
- **
|
|
51
|
+
- **Multi-select** — select all / deselect all, batch delete, download, zip, cut, and copy files
|
|
52
|
+
- **Context menu on selection** — right-click actions (zip, download, delete, cut, copy) operate on all selected files
|
|
53
|
+
- **Zip / Extract** — right-click any file or folder to zip; right-click `.zip` files to extract (archiver v8)
|
|
54
|
+
- **Folder download** — folders download as `.zip` archives automatically
|
|
47
55
|
- **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)
|
|
56
|
+
- **Cut, Copy, Paste** — multi-file support with conflict resolution dialog (replace, merge folders, keep both, skip, cancel)
|
|
49
57
|
- **Upload with progress** — per-file progress bar via XHR
|
|
50
58
|
- **Breadcrumb navigation** — tappable directory segments for quick navigation
|
|
51
59
|
- **Long-press context menu** — rename, delete, download, copy on touch devices
|
|
52
60
|
- **Text selection** — long-press file names to select and copy text on touch devices
|
|
53
61
|
|
|
62
|
+
### System Stats
|
|
63
|
+
- **CPU, Memory, Disk, Uptime** — real-time usage with progress bars
|
|
64
|
+
- **GPU detection** — cross-platform (Linux: nvidia-smi/lspci, macOS: system_profiler, Windows: WMI)
|
|
65
|
+
- **Multi-GPU support** — detects and displays all GPUs with VRAM, utilization, and temperature
|
|
66
|
+
- **Top processes** — sorted by CPU usage
|
|
67
|
+
|
|
54
68
|
### Desktop App (Electron)
|
|
55
69
|
- Cross-platform app for Linux & Windows
|
|
56
70
|
- Runs the server as a child process — no terminal needed
|
|
@@ -231,6 +245,17 @@ kill $(pgrep -f 'cloudflared tunnel')
|
|
|
231
245
|
| Protocol | WebSocket (ws) |
|
|
232
246
|
| Tunnel | Cloudflare Tunnel (cloudflared) |
|
|
233
247
|
| Desktop | Electron + electron-builder |
|
|
248
|
+
| Archives | archiver v8 + yauzl |
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Security
|
|
253
|
+
|
|
254
|
+
- **npm audit clean** — 0 known vulnerabilities
|
|
255
|
+
- **PIN authentication** — optional, protects all API endpoints
|
|
256
|
+
- **CSP headers** — restricts script sources to CDN
|
|
257
|
+
- **WebSocket origin check** — prevents cross-site WebSocket hijacking
|
|
258
|
+
- **Rate limiting** — per-IP rate limits on auth and search endpoints
|
|
234
259
|
|
|
235
260
|
---
|
|
236
261
|
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -1191,6 +1191,10 @@ svg { display: block; }
|
|
|
1191
1191
|
<label>Bell Sound</label>
|
|
1192
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>
|
|
1193
1193
|
</div>
|
|
1194
|
+
<div class="setting-row">
|
|
1195
|
+
<label>Right-Click Menu</label>
|
|
1196
|
+
<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')}"></div>
|
|
1197
|
+
</div>
|
|
1194
1198
|
</div>
|
|
1195
1199
|
<div class="settings-section">
|
|
1196
1200
|
<h3>Interface</h3>
|
|
@@ -1343,6 +1347,7 @@ svg { display: block; }
|
|
|
1343
1347
|
<div class="sys-val" id="sys-uptime-val">—</div>
|
|
1344
1348
|
<div class="sys-sub" id="sys-uptime-sub"></div>
|
|
1345
1349
|
</div>
|
|
1350
|
+
<div id="sys-gpu-container"></div>
|
|
1346
1351
|
</div>
|
|
1347
1352
|
<div class="settings-section">
|
|
1348
1353
|
<h3>Top Processes by CPU</h3>
|
|
@@ -1471,7 +1476,7 @@ let hasTmux = false;
|
|
|
1471
1476
|
let settings = {
|
|
1472
1477
|
theme: 'light', fontSize: 14, font: "'SF Mono', 'Fira Code', Consolas, monospace",
|
|
1473
1478
|
cursor: 'block', blink: true, scrollback: 5000, bell: false,
|
|
1474
|
-
mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false
|
|
1479
|
+
mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false, termRightClick: true
|
|
1475
1480
|
};
|
|
1476
1481
|
let serverPlatform = '';
|
|
1477
1482
|
|
|
@@ -1902,22 +1907,44 @@ function connectWebSocket(tab, isReconnect = false) {
|
|
|
1902
1907
|
|
|
1903
1908
|
const sendInput = data => {
|
|
1904
1909
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
1905
|
-
// On Enter,
|
|
1910
|
+
// On Enter, capture the command for history
|
|
1906
1911
|
for (let i = 0; i < data.length; i++) {
|
|
1907
1912
|
const ch = data.charCodeAt(i);
|
|
1908
1913
|
if (ch === 13 || ch === 10) { // Enter
|
|
1909
1914
|
try {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
//
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
|
|
1915
|
+
// Prefer stored paste text (avoids single-row truncation from getLine)
|
|
1916
|
+
let text = tab._lastPasteText;
|
|
1917
|
+
tab._lastPasteText = null;
|
|
1918
|
+
if (text) {
|
|
1919
|
+
// For multi-line pastes, take only the last line (the command)
|
|
1920
|
+
const lines = text.split('\n');
|
|
1921
|
+
text = lines[lines.length - 1] || lines[lines.length - 2] || text;
|
|
1922
|
+
text = text.trim();
|
|
1923
|
+
} else {
|
|
1924
|
+
// Read from terminal buffer — collect all rows from prompt to cursor
|
|
1925
|
+
const buf = tab.term.buffer.active;
|
|
1926
|
+
const cursorRow = buf.baseY + buf.cursorY;
|
|
1927
|
+
let collected = '';
|
|
1928
|
+
// Scan upward to find the prompt line (contains $, #, or %)
|
|
1929
|
+
for (let r = cursorRow; r >= Math.max(0, cursorRow - 20); r--) {
|
|
1930
|
+
const row = buf.getLine(r);
|
|
1931
|
+
if (!row) break;
|
|
1932
|
+
const rowText = row.translateToString(false);
|
|
1933
|
+
// Prompt detection: line ends with $, #, or % followed by space
|
|
1934
|
+
if (/\s*[#$%]\s*$/.test(rowText)) {
|
|
1935
|
+
const promptEnd = rowText.search(/[#$%]\s*$/);
|
|
1936
|
+
if (promptEnd >= 0) {
|
|
1937
|
+
collected = rowText.substring(promptEnd + 1) + collected;
|
|
1938
|
+
}
|
|
1939
|
+
break;
|
|
1940
|
+
}
|
|
1941
|
+
collected = rowText + collected;
|
|
1918
1942
|
}
|
|
1919
|
-
|
|
1943
|
+
text = collected.trim();
|
|
1944
|
+
// Strip any trailing prompt artifacts from multi-row
|
|
1945
|
+
text = text.replace(/\s*[#$%]\s*$/, '').trim();
|
|
1920
1946
|
}
|
|
1947
|
+
if (text) addToCmdHist(text);
|
|
1921
1948
|
} catch {}
|
|
1922
1949
|
}
|
|
1923
1950
|
}
|
|
@@ -2159,6 +2186,21 @@ function initTerminal(tab) {
|
|
|
2159
2186
|
tab.closed = false;
|
|
2160
2187
|
tab.textarea = term.textarea || term.element?.querySelector('.xterm-textarea, textarea');
|
|
2161
2188
|
|
|
2189
|
+
// Intercept paste events to use bracketed paste mode (required for TUI apps like vim, nano, mc)
|
|
2190
|
+
if (tab.textarea) {
|
|
2191
|
+
tab.textarea.addEventListener('paste', e => {
|
|
2192
|
+
e.preventDefault();
|
|
2193
|
+
const text = (e.clipboardData || window.clipboardData)?.getData('text');
|
|
2194
|
+
if (!text || !tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
|
|
2195
|
+
tab._lastPasteText = text;
|
|
2196
|
+
const bracketed = '\x1b[200~' + text + '\x1b[201~';
|
|
2197
|
+
const enc = new TextEncoder().encode(bracketed);
|
|
2198
|
+
const buf = new Uint8Array(1 + enc.length);
|
|
2199
|
+
buf[0] = 0x00; buf.set(enc, 1);
|
|
2200
|
+
tab.ws.send(buf.buffer);
|
|
2201
|
+
}, true);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2162
2204
|
// term.onFocus() was removed from xterm.js — use native DOM event instead
|
|
2163
2205
|
term.element.addEventListener('focusin', () => {
|
|
2164
2206
|
if (activeTabId !== tab.id) {
|
|
@@ -2210,6 +2252,7 @@ function initTerminal(tab) {
|
|
|
2210
2252
|
|
|
2211
2253
|
// Terminal right-click context menu
|
|
2212
2254
|
term.element.addEventListener('contextmenu', e => {
|
|
2255
|
+
if (!settings.termRightClick) return;
|
|
2213
2256
|
e.preventDefault();
|
|
2214
2257
|
const menu = document.getElementById('term-ctx-menu');
|
|
2215
2258
|
const sel = term.getSelection();
|
|
@@ -2277,18 +2320,22 @@ function initTerminal(tab) {
|
|
|
2277
2320
|
if (!tab.closed) connectWebSocket(tab, false);
|
|
2278
2321
|
});
|
|
2279
2322
|
|
|
2280
|
-
// Scroll terminal on wheel
|
|
2323
|
+
// Scroll terminal on wheel
|
|
2281
2324
|
tab.wrapper.addEventListener('wheel', e => {
|
|
2282
2325
|
if (e.ctrlKey || e.metaKey) return;
|
|
2283
|
-
|
|
2326
|
+
e.preventDefault();
|
|
2327
|
+
// TUI apps with mouse tracking handle scroll themselves — forward the event to xterm.js
|
|
2284
2328
|
try {
|
|
2285
2329
|
const core = term._core;
|
|
2286
|
-
if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive())
|
|
2330
|
+
if (core && core.coreMouseService && core.coreMouseService.isMouseTrackingActive()) {
|
|
2331
|
+
const vp = tab.wrapper.querySelector('.xterm-viewport');
|
|
2332
|
+
if (vp) vp.dispatchEvent(new WheelEvent('wheel', e));
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2287
2335
|
} catch (_) {}
|
|
2288
2336
|
const atTop = term.buffer.active.baseY === 0;
|
|
2289
2337
|
const atBottom = term.buffer.active.baseY + term.rows >= term.buffer.active.length;
|
|
2290
2338
|
if (tilesMode && ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom))) return;
|
|
2291
|
-
e.preventDefault();
|
|
2292
2339
|
let delta = e.deltaY;
|
|
2293
2340
|
if (e.deltaMode === 1) delta *= 16;
|
|
2294
2341
|
else if (e.deltaMode === 2) delta *= 16 * term.rows;
|
|
@@ -2965,12 +3012,20 @@ document.addEventListener('click', () => {
|
|
|
2965
3012
|
|
|
2966
3013
|
document.getElementById('ctx-open').onclick = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
|
|
2967
3014
|
document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
|
|
2968
|
-
document.getElementById('ctx-download').onclick = () =>
|
|
3015
|
+
document.getElementById('ctx-download').onclick = () => {
|
|
3016
|
+
if (selectedFiles.length > 0) { downloadSelected(); return; }
|
|
3017
|
+
if (ctxTarget) downloadFile(ctxTarget.path);
|
|
3018
|
+
};
|
|
2969
3019
|
document.getElementById('ctx-zip').onclick = async () => {
|
|
2970
|
-
|
|
3020
|
+
const files = selectedFiles.length > 0 ? selectedFiles : (ctxTarget ? [ctxTarget.path] : []);
|
|
3021
|
+
if (!files.length) return;
|
|
2971
3022
|
const t = toast('Zipping…', 'info');
|
|
2972
|
-
|
|
3023
|
+
for (const p of files) {
|
|
3024
|
+
const name = p.split(/[\\/]/).pop();
|
|
3025
|
+
await zipFile(p, name);
|
|
3026
|
+
}
|
|
2973
3027
|
t.remove();
|
|
3028
|
+
clearSelection();
|
|
2974
3029
|
};
|
|
2975
3030
|
document.getElementById('ctx-extract').onclick = async () => {
|
|
2976
3031
|
if (!ctxTarget) return;
|
|
@@ -2995,10 +3050,29 @@ document.getElementById('ctx-folder-size').onclick = async () => {
|
|
|
2995
3050
|
};
|
|
2996
3051
|
document.getElementById('ctx-rename').onclick = () => ctxTarget && startRename(ctxTarget.path, ctxTarget.name);
|
|
2997
3052
|
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 = () =>
|
|
3053
|
+
document.getElementById('ctx-delete').onclick = () => {
|
|
3054
|
+
if (selectedFiles.length > 0) { deleteSelected(); return; }
|
|
3055
|
+
if (ctxTarget) deleteFile(ctxTarget);
|
|
3056
|
+
};
|
|
2999
3057
|
document.getElementById('ctx-open-term').onclick = () => { if (!ctxTarget) return; newTab(`Term @ ${ctxTarget.name}`, null, ctxTarget.path); };
|
|
3000
|
-
document.getElementById('ctx-copy').onclick = () => {
|
|
3001
|
-
|
|
3058
|
+
document.getElementById('ctx-copy').onclick = () => {
|
|
3059
|
+
const files = selectedFiles.length > 0
|
|
3060
|
+
? selectedFiles.map(p => ({ path: p, name: p.split(/[\\/]/).pop() }))
|
|
3061
|
+
: (ctxTarget ? [{ path: ctxTarget.path, name: ctxTarget.name }] : []);
|
|
3062
|
+
if (!files.length) return;
|
|
3063
|
+
fsClipboard = { action: 'copy', files };
|
|
3064
|
+
updatePasteUI();
|
|
3065
|
+
toast(`Copied ${files.length} item(s)`, 'success');
|
|
3066
|
+
};
|
|
3067
|
+
document.getElementById('ctx-cut').onclick = () => {
|
|
3068
|
+
const files = selectedFiles.length > 0
|
|
3069
|
+
? selectedFiles.map(p => ({ path: p, name: p.split(/[\\/]/).pop() }))
|
|
3070
|
+
: (ctxTarget ? [{ path: ctxTarget.path, name: ctxTarget.name }] : []);
|
|
3071
|
+
if (!files.length) return;
|
|
3072
|
+
fsClipboard = { action: 'cut', files };
|
|
3073
|
+
updatePasteUI();
|
|
3074
|
+
toast(`Cut ${files.length} item(s)`, 'success');
|
|
3075
|
+
};
|
|
3002
3076
|
document.getElementById('ctx-paste').onclick = () => { if (fsClipboard) pasteFile(currentPath); };
|
|
3003
3077
|
|
|
3004
3078
|
function updatePasteUI() {
|
|
@@ -3013,38 +3087,44 @@ let _pasteBusy = false;
|
|
|
3013
3087
|
async function pasteFile(destDir, conflictMode) {
|
|
3014
3088
|
if (!fsClipboard || _pasteBusy) return;
|
|
3015
3089
|
_pasteBusy = true;
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3090
|
+
try {
|
|
3091
|
+
// Support both old single-file and new multi-file format
|
|
3092
|
+
const files = fsClipboard.files || [{ path: fsClipboard.path, name: fsClipboard.name }];
|
|
3093
|
+
const label = fsClipboard.action === 'copy' ? 'Copying' : 'Moving';
|
|
3094
|
+
const toastEl = toast(label + '…', 'info');
|
|
3095
|
+
const ep = fsClipboard.action === 'copy' ? '/api/files/copy' : '/api/files/move';
|
|
3096
|
+
let succeeded = 0, failed = 0;
|
|
3097
|
+
for (const file of files) {
|
|
3098
|
+
const dest = joinPath(destDir, file.name);
|
|
3099
|
+
const body = { source: file.path, destination: dest };
|
|
3100
|
+
if (conflictMode) body.conflict = conflictMode;
|
|
3101
|
+
const r = await api(ep, {
|
|
3102
|
+
method: 'POST',
|
|
3103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3104
|
+
body: JSON.stringify(body)
|
|
3105
|
+
});
|
|
3106
|
+
if (r.conflict) {
|
|
3107
|
+
toastEl.remove();
|
|
3108
|
+
_conflictResolve = { destDir, conflictMode: null };
|
|
3109
|
+
document.getElementById('conflict-name').textContent = r.name;
|
|
3110
|
+
document.getElementById('conflict-dir-hint').style.display = r.isDir ? '' : 'none';
|
|
3111
|
+
document.getElementById('conflict-merge').style.display = r.isDir ? '' : 'none';
|
|
3112
|
+
openOverlay('conflict-overlay');
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
if (r.success) succeeded++;
|
|
3116
|
+
else failed++;
|
|
3117
|
+
}
|
|
3037
3118
|
toastEl.remove();
|
|
3038
|
-
const
|
|
3039
|
-
if (
|
|
3040
|
-
else toast(
|
|
3119
|
+
const opLabel = fsClipboard.action === 'copy' ? 'Pasted' : 'Moved';
|
|
3120
|
+
if (failed === 0) toast(opLabel + ` (${succeeded} item(s))`, 'success');
|
|
3121
|
+
else if (succeeded === 0) toast(opLabel + ' failed', 'error');
|
|
3122
|
+
else toast(`${opLabel} ${succeeded}, ${failed} failed`, 'warning');
|
|
3041
3123
|
if (fsClipboard.action === 'cut') { fsClipboard = null; updatePasteUI(); }
|
|
3042
3124
|
refreshFiles();
|
|
3043
|
-
}
|
|
3044
|
-
|
|
3045
|
-
toast(r.error || 'Operation failed', 'error');
|
|
3125
|
+
} finally {
|
|
3126
|
+
_pasteBusy = false;
|
|
3046
3127
|
}
|
|
3047
|
-
_pasteBusy = false;
|
|
3048
3128
|
}
|
|
3049
3129
|
|
|
3050
3130
|
function resolveConflict(mode) {
|
|
@@ -3102,18 +3182,24 @@ async function deleteFile(file) {
|
|
|
3102
3182
|
else toast(r.error, 'error');
|
|
3103
3183
|
}
|
|
3104
3184
|
|
|
3105
|
-
async function downloadFile(
|
|
3185
|
+
async function downloadFile(filePath) {
|
|
3106
3186
|
try {
|
|
3107
|
-
const r = await fetch(`/api/files/download?path=${encodeURIComponent(
|
|
3187
|
+
const r = await fetch(`/api/files/download?path=${encodeURIComponent(filePath)}`, {
|
|
3108
3188
|
headers: { 'x-pin-token': authToken }
|
|
3109
3189
|
});
|
|
3110
3190
|
if (!r.ok) { toast('Download failed', 'error'); return; }
|
|
3191
|
+
const ct = r.headers.get('content-type') || '';
|
|
3111
3192
|
const blob = await r.blob();
|
|
3112
3193
|
const url = URL.createObjectURL(blob);
|
|
3113
3194
|
const a = document.createElement('a');
|
|
3114
3195
|
a.href = url;
|
|
3115
|
-
|
|
3196
|
+
let name = filePath.split(/[\\/]/).pop() || 'download';
|
|
3197
|
+
// Server returns application/zip for directories — append .zip if missing
|
|
3198
|
+
if (ct.includes('zip') && !name.endsWith('.zip')) name += '.zip';
|
|
3199
|
+
a.download = name;
|
|
3200
|
+
document.body.appendChild(a);
|
|
3116
3201
|
a.click();
|
|
3202
|
+
a.remove();
|
|
3117
3203
|
URL.revokeObjectURL(url);
|
|
3118
3204
|
} catch(e) { console.warn('Download failed:', e); toast('Download failed', 'error'); }
|
|
3119
3205
|
}
|
|
@@ -3219,10 +3305,14 @@ async function deleteSelected() {
|
|
|
3219
3305
|
}
|
|
3220
3306
|
|
|
3221
3307
|
async function downloadSelected() {
|
|
3222
|
-
|
|
3223
|
-
|
|
3308
|
+
const files = [...selectedFiles];
|
|
3309
|
+
if (!files.length) return;
|
|
3310
|
+
toast(`Downloading ${files.length} item(s)…`, 'info');
|
|
3311
|
+
for (let i = 0; i < files.length; i++) {
|
|
3312
|
+
await downloadFile(files[i]);
|
|
3313
|
+
// Small delay between downloads to avoid browser popup blocking
|
|
3314
|
+
if (i < files.length - 1) await new Promise(r => setTimeout(r, 300));
|
|
3224
3315
|
}
|
|
3225
|
-
toast('Downloads started', 'success');
|
|
3226
3316
|
clearSelection();
|
|
3227
3317
|
}
|
|
3228
3318
|
|
|
@@ -3863,7 +3953,7 @@ function loadSettings() {
|
|
|
3863
3953
|
document.getElementById('s-scrollback').value = settings.scrollback;
|
|
3864
3954
|
document.getElementById('s-font').value = settings.font;
|
|
3865
3955
|
document.getElementById('s-cursor').value = settings.cursor;
|
|
3866
|
-
syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver');
|
|
3956
|
+
syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver'); syncToggle('termRightClick');
|
|
3867
3957
|
syncKeepAwakeUI();
|
|
3868
3958
|
if (settings.keepAwake && hasWakeLock) { requestWakeLock(); }
|
|
3869
3959
|
if (isElectron) {
|
|
@@ -5494,6 +5584,31 @@ async function refreshSystemStats(showLoading) {
|
|
|
5494
5584
|
document.getElementById('sys-uptime-val').textContent = fmtUptime(data.uptime);
|
|
5495
5585
|
document.getElementById('sys-uptime-sub').textContent = data.hostname + ' · ' + data.platform;
|
|
5496
5586
|
|
|
5587
|
+
const gpuContainer = document.getElementById('sys-gpu-container');
|
|
5588
|
+
gpuContainer.innerHTML = '';
|
|
5589
|
+
const gpuList = data.gpus || (data.gpu ? [data.gpu] : []);
|
|
5590
|
+
if (gpuList.length) {
|
|
5591
|
+
gpuList.forEach((g, i) => {
|
|
5592
|
+
const card = document.createElement('div');
|
|
5593
|
+
card.className = 'sys-card';
|
|
5594
|
+
const label = gpuList.length > 1 ? `GPU ${i + 1}` : 'GPU';
|
|
5595
|
+
let sub = '';
|
|
5596
|
+
if (g.memTotal > 0) sub += formatSize(g.memTotal * 1024 * 1024) + ' VRAM';
|
|
5597
|
+
else if (g.memTotal === 0 && g.driver !== 'lspci') sub += 'Shared memory';
|
|
5598
|
+
if (g.utilization != null) sub += (sub ? ' · ' : '') + g.utilization + '% util';
|
|
5599
|
+
if (g.temp) sub += (sub ? ' · ' : '') + g.temp + '°C';
|
|
5600
|
+
if (g.driver && g.driver !== 'nvidia' && g.driver !== 'macos' && g.driver !== 'lspci') sub += (sub ? ' · ' : '') + 'Driver: ' + g.driver;
|
|
5601
|
+
let barHtml = '';
|
|
5602
|
+
if (g.memTotal > 0 && g.memUsed) {
|
|
5603
|
+
const pct = Math.round((g.memUsed / g.memTotal) * 100);
|
|
5604
|
+
const cls = pct > 80 ? ' danger' : pct > 50 ? ' warn' : '';
|
|
5605
|
+
barHtml = `<div class="sys-bar"><div class="sys-bar-fill${cls}" style="width:${Math.min(pct, 100)}%"></div></div>`;
|
|
5606
|
+
}
|
|
5607
|
+
card.innerHTML = `<h3>${label}</h3><div class="sys-val">${g.name}</div><div class="sys-sub">${sub || 'No details available'}</div>${barHtml}`;
|
|
5608
|
+
gpuContainer.appendChild(card);
|
|
5609
|
+
});
|
|
5610
|
+
}
|
|
5611
|
+
|
|
5497
5612
|
const tbody = document.getElementById('sys-proc-body');
|
|
5498
5613
|
tbody.innerHTML = '';
|
|
5499
5614
|
if (data.processes) {
|
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
|
});
|