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 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 download files
46
- - **Zip / Extract** — right-click any file or folder to zip; right-click `.zip` files to extract
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webtun",
3
- "version": "1.4.6",
3
+ "version": "1.5.0",
4
4
  "description": "Self-hosted web terminal with Cloudflare Tunnel, file explorer, and PWA support",
5
5
  "author": {
6
6
  "name": "Gaurang Patel",
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, read command from terminal screen buffer (avoids echo-back pollution)
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
- 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();
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
- if (text) addToCmdHist(text);
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; yield to TUI apps when mouse tracking is active
2323
+ // Scroll terminal on wheel
2281
2324
  tab.wrapper.addEventListener('wheel', e => {
2282
2325
  if (e.ctrlKey || e.metaKey) return;
2283
- // When a TUI app (vim, htop, tmux…) has mouse tracking on, let the app handle scroll
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()) return;
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 = () => ctxTarget && downloadFile(ctxTarget.path);
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
- if (!ctxTarget) return;
3020
+ const files = selectedFiles.length > 0 ? selectedFiles : (ctxTarget ? [ctxTarget.path] : []);
3021
+ if (!files.length) return;
2971
3022
  const t = toast('Zipping…', 'info');
2972
- await zipFile(ctxTarget.path, ctxTarget.name);
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 = () => ctxTarget && deleteFile(ctxTarget);
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 = () => { 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'); };
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
- 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) {
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 label = fsClipboard.action === 'copy' ? 'Pasted' : 'Moved';
3039
- if (r.skipped) toast(label + ' (skipped existing)', 'info');
3040
- else toast(label, 'success');
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
- } else {
3044
- toastEl.remove();
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(path) {
3185
+ async function downloadFile(filePath) {
3106
3186
  try {
3107
- const r = await fetch(`/api/files/download?path=${encodeURIComponent(path)}`, {
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
- a.download = path.split(/[\\/]/).pop() || 'download';
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
- for (const p of [...selectedFiles]) {
3223
- await downloadFile(p);
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 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
  });