termbeam 0.1.0 → 1.0.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 +30 -20
- package/package.json +14 -5
- package/public/index.html +35 -13
- package/public/sw.js +16 -2
- package/public/terminal.html +310 -33
- package/src/auth.js +2 -0
- package/src/cli.js +58 -18
- package/src/logger.js +32 -0
- package/src/routes.js +101 -5
- package/src/server.js +150 -112
- package/src/sessions.js +5 -3
- package/src/tunnel.js +27 -25
- package/src/websocket.js +26 -4
package/public/terminal.html
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
7
7
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
8
8
|
<meta name="theme-color" content="#1e1e1e" />
|
|
9
|
+
<meta name="description" content="TermBeam terminal session — access your terminal remotely from any browser with a mobile-optimized touch interface." />
|
|
9
10
|
<link rel="manifest" href="/manifest.json" />
|
|
10
11
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
11
12
|
<title>TermBeam — Terminal</title>
|
|
@@ -309,7 +310,8 @@
|
|
|
309
310
|
#paste-overlay {
|
|
310
311
|
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
311
312
|
background: var(--overlay-bg); z-index: 150;
|
|
312
|
-
flex-direction: column; align-items: center; justify-content:
|
|
313
|
+
flex-direction: column; align-items: center; justify-content: flex-start;
|
|
314
|
+
padding-top: calc(80px + env(safe-area-inset-top, 0px)); gap: 12px;
|
|
313
315
|
}
|
|
314
316
|
#paste-overlay.visible { display: flex; }
|
|
315
317
|
#paste-overlay label { font-size: 15px; color: #fff; font-weight: 600; }
|
|
@@ -319,6 +321,38 @@
|
|
|
319
321
|
padding: 10px; font-size: 14px; font-family: 'NerdFont', 'JetBrains Mono', monospace; resize: vertical;
|
|
320
322
|
}
|
|
321
323
|
|
|
324
|
+
#select-overlay {
|
|
325
|
+
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
326
|
+
background: var(--bg); z-index: 160;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
}
|
|
329
|
+
#select-overlay.visible { display: flex; }
|
|
330
|
+
.select-overlay-header {
|
|
331
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
332
|
+
padding: 12px 12px; border-bottom: 1px solid var(--border);
|
|
333
|
+
font-size: 15px; font-weight: 600; color: var(--text);
|
|
334
|
+
padding-top: calc(12px + env(safe-area-inset-top, 0px));
|
|
335
|
+
min-height: 48px; box-sizing: border-box;
|
|
336
|
+
}
|
|
337
|
+
.select-overlay-header button {
|
|
338
|
+
padding: 6px 12px; border: none; border-radius: 8px;
|
|
339
|
+
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
340
|
+
white-space: nowrap; flex-shrink: 0;
|
|
341
|
+
transition: background 0.15s, transform 0.1s;
|
|
342
|
+
}
|
|
343
|
+
.select-overlay-header button:active { transform: scale(0.95); }
|
|
344
|
+
#select-copy { background: var(--accent); color: #fff; }
|
|
345
|
+
#select-close { background: var(--border); color: var(--text); }
|
|
346
|
+
#select-content {
|
|
347
|
+
flex: 1; overflow: auto; padding: 12px 16px;
|
|
348
|
+
font-family: 'NerdFont', 'JetBrains Mono', monospace;
|
|
349
|
+
font-size: 13px; line-height: 1.4; color: var(--text);
|
|
350
|
+
white-space: pre; word-break: normal;
|
|
351
|
+
-webkit-user-select: text; user-select: text;
|
|
352
|
+
margin: 0;
|
|
353
|
+
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
|
354
|
+
}
|
|
355
|
+
|
|
322
356
|
#reconnect-overlay {
|
|
323
357
|
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
324
358
|
background: var(--overlay-bg); z-index: 100;
|
|
@@ -714,6 +748,7 @@
|
|
|
714
748
|
<button class="key-btn" data-key="[H" title="Home">Home</button>
|
|
715
749
|
<button class="key-btn" data-key="[F" title="End">End</button>
|
|
716
750
|
<div class="key-sep"></div>
|
|
751
|
+
<button class="key-btn" id="select-btn" title="Copy text">Copy</button>
|
|
717
752
|
<button class="key-btn" id="paste-btn" title="Paste from clipboard">Paste</button>
|
|
718
753
|
<div class="key-sep"></div>
|
|
719
754
|
<button class="key-btn wide" data-key="	" title="Autocomplete">Tab</button>
|
|
@@ -740,6 +775,18 @@
|
|
|
740
775
|
</div>
|
|
741
776
|
</div>
|
|
742
777
|
|
|
778
|
+
<div id="select-overlay">
|
|
779
|
+
<div class="select-overlay-header">
|
|
780
|
+
<span id="select-title">Copy Text</span>
|
|
781
|
+
<div style="display:flex;gap:8px">
|
|
782
|
+
<button id="select-copy">Copy</button>
|
|
783
|
+
<button id="select-close">Done</button>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
<button id="select-load-more" style="display:none;width:100%;padding:8px;background:var(--surface);color:var(--accent);border:1px solid var(--border);border-radius:6px;font-size:13px;cursor:pointer;">▲ Load more</button>
|
|
787
|
+
<pre id="select-content"></pre>
|
|
788
|
+
</div>
|
|
789
|
+
|
|
743
790
|
<!-- New Session Modal -->
|
|
744
791
|
<div class="modal-overlay" id="new-session-modal">
|
|
745
792
|
<div class="modal">
|
|
@@ -804,8 +851,20 @@
|
|
|
804
851
|
const managed = new Map(); // sessionId -> ManagedSession
|
|
805
852
|
let activeId = null;
|
|
806
853
|
let splitMode = false;
|
|
854
|
+
|
|
807
855
|
let splitSecondId = null;
|
|
808
856
|
|
|
857
|
+
// Clipboard copy fallback for non-secure contexts (HTTP over LAN)
|
|
858
|
+
function copyFallback(text) {
|
|
859
|
+
const ta = document.createElement('textarea');
|
|
860
|
+
ta.value = text;
|
|
861
|
+
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
|
862
|
+
document.body.appendChild(ta);
|
|
863
|
+
ta.select();
|
|
864
|
+
try { document.execCommand('copy'); showToast('Copied!'); } catch {}
|
|
865
|
+
document.body.removeChild(ta);
|
|
866
|
+
}
|
|
867
|
+
|
|
809
868
|
// ===== DOM Refs =====
|
|
810
869
|
const statusDot = document.getElementById('status-dot');
|
|
811
870
|
const statusText = document.getElementById('status-text');
|
|
@@ -937,7 +996,10 @@
|
|
|
937
996
|
renderTabs();
|
|
938
997
|
setupKeyBar();
|
|
939
998
|
setupPaste();
|
|
999
|
+
setupImagePaste();
|
|
1000
|
+
setupSelectMode();
|
|
940
1001
|
setupNewSessionModal();
|
|
1002
|
+
loadShellsForModal();
|
|
941
1003
|
startPolling();
|
|
942
1004
|
|
|
943
1005
|
// Zoom
|
|
@@ -1120,8 +1182,14 @@
|
|
|
1120
1182
|
// Copy on selection
|
|
1121
1183
|
term.onSelectionChange(() => {
|
|
1122
1184
|
const sel = term.getSelection();
|
|
1123
|
-
if (sel
|
|
1124
|
-
|
|
1185
|
+
if (!sel) return;
|
|
1186
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1187
|
+
navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {
|
|
1188
|
+
// Fallback for non-secure contexts (HTTP over LAN)
|
|
1189
|
+
copyFallback(sel);
|
|
1190
|
+
});
|
|
1191
|
+
} else {
|
|
1192
|
+
copyFallback(sel);
|
|
1125
1193
|
}
|
|
1126
1194
|
});
|
|
1127
1195
|
|
|
@@ -1203,6 +1271,11 @@
|
|
|
1203
1271
|
reconnectOverlay.classList.add('visible');
|
|
1204
1272
|
}
|
|
1205
1273
|
} else if (msg.type === 'error') {
|
|
1274
|
+
if (msg.message === 'Session not found') {
|
|
1275
|
+
ms.exited = true;
|
|
1276
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1277
|
+
renderTabs();
|
|
1278
|
+
}
|
|
1206
1279
|
if (ms.id === activeId) {
|
|
1207
1280
|
statusText.textContent = msg.message;
|
|
1208
1281
|
reconnectOverlay.querySelector('.msg').textContent = msg.message;
|
|
@@ -1277,12 +1350,16 @@
|
|
|
1277
1350
|
}
|
|
1278
1351
|
|
|
1279
1352
|
async function removeSession(id) {
|
|
1280
|
-
try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
|
|
1281
|
-
|
|
1282
1353
|
const ms = managed.get(id);
|
|
1283
1354
|
if (ms) {
|
|
1355
|
+
ms.exited = true;
|
|
1356
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1284
1357
|
if (ms.ws) try { ms.ws.close(); } catch {}
|
|
1285
|
-
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
|
|
1361
|
+
|
|
1362
|
+
if (ms) {
|
|
1286
1363
|
ms.term.dispose();
|
|
1287
1364
|
ms.container.remove();
|
|
1288
1365
|
managed.delete(id);
|
|
@@ -1655,18 +1732,25 @@
|
|
|
1655
1732
|
}, 400);
|
|
1656
1733
|
}
|
|
1657
1734
|
|
|
1735
|
+
let keyBarTouched = false;
|
|
1658
1736
|
keyBar.addEventListener('mousedown', e => {
|
|
1737
|
+
if (keyBarTouched) { keyBarTouched = false; return; }
|
|
1659
1738
|
const btn = e.target.closest('.key-btn');
|
|
1660
|
-
if (btn) { e.preventDefault(); startRepeat(btn); }
|
|
1739
|
+
if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
|
|
1661
1740
|
});
|
|
1662
1741
|
keyBar.addEventListener('mouseup', stopRepeat);
|
|
1663
1742
|
keyBar.addEventListener('mouseleave', stopRepeat);
|
|
1664
1743
|
|
|
1665
1744
|
keyBar.addEventListener('touchstart', e => {
|
|
1745
|
+
keyBarTouched = true;
|
|
1746
|
+
const btn = e.target.closest('.key-btn');
|
|
1747
|
+
if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
|
|
1748
|
+
}, { passive: false });
|
|
1749
|
+
keyBar.addEventListener('touchend', (e) => {
|
|
1666
1750
|
const btn = e.target.closest('.key-btn');
|
|
1667
|
-
if (btn)
|
|
1668
|
-
|
|
1669
|
-
|
|
1751
|
+
if (btn && btn.dataset.key) e.preventDefault();
|
|
1752
|
+
stopRepeat();
|
|
1753
|
+
});
|
|
1670
1754
|
keyBar.addEventListener('touchcancel', stopRepeat);
|
|
1671
1755
|
|
|
1672
1756
|
keyBar.addEventListener('click', e => {
|
|
@@ -1679,6 +1763,7 @@
|
|
|
1679
1763
|
function setupPaste() {
|
|
1680
1764
|
const pasteOverlay = document.getElementById('paste-overlay');
|
|
1681
1765
|
const pasteInput = document.getElementById('paste-input');
|
|
1766
|
+
const pasteBtn = document.getElementById('paste-btn');
|
|
1682
1767
|
|
|
1683
1768
|
function openPasteModal() {
|
|
1684
1769
|
pasteInput.value = '';
|
|
@@ -1686,19 +1771,65 @@
|
|
|
1686
1771
|
pasteInput.focus();
|
|
1687
1772
|
}
|
|
1688
1773
|
|
|
1689
|
-
|
|
1690
|
-
|
|
1774
|
+
async function handlePaste() {
|
|
1775
|
+
// Try clipboard API for text first (most common), then images, then fallback to modal
|
|
1691
1776
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
1692
|
-
|
|
1693
|
-
const
|
|
1694
|
-
if (text
|
|
1695
|
-
ms.
|
|
1696
|
-
|
|
1777
|
+
try {
|
|
1778
|
+
const text = await navigator.clipboard.readText();
|
|
1779
|
+
if (text) {
|
|
1780
|
+
const ms = managed.get(activeId);
|
|
1781
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1782
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
1783
|
+
showToast('Pasted!');
|
|
1784
|
+
}
|
|
1785
|
+
return;
|
|
1697
1786
|
}
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
console.warn('clipboard.readText failed:', err.message);
|
|
1789
|
+
}
|
|
1701
1790
|
}
|
|
1791
|
+
// Image paste: try clipboard.read() only if readText didn't work
|
|
1792
|
+
if (navigator.clipboard && navigator.clipboard.read) {
|
|
1793
|
+
try {
|
|
1794
|
+
const items = await navigator.clipboard.read();
|
|
1795
|
+
for (const item of items) {
|
|
1796
|
+
const imageType = item.types.find(t => t.startsWith('image/'));
|
|
1797
|
+
if (imageType) {
|
|
1798
|
+
const blob = await item.getType(imageType);
|
|
1799
|
+
const res = await fetch('/api/upload', {
|
|
1800
|
+
method: 'POST',
|
|
1801
|
+
headers: { 'Content-Type': imageType },
|
|
1802
|
+
body: blob,
|
|
1803
|
+
credentials: 'same-origin',
|
|
1804
|
+
});
|
|
1805
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
1806
|
+
const data = await res.json();
|
|
1807
|
+
const ms = managed.get(activeId);
|
|
1808
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1809
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
|
|
1810
|
+
showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
|
|
1811
|
+
}
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
console.warn('clipboard.read failed:', err.message);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
openPasteModal();
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// Use touchend directly for iOS Safari (click may not fire reliably)
|
|
1823
|
+
let pasteTouched = false;
|
|
1824
|
+
pasteBtn.addEventListener('touchend', (e) => {
|
|
1825
|
+
e.preventDefault();
|
|
1826
|
+
pasteTouched = true;
|
|
1827
|
+
handlePaste();
|
|
1828
|
+
});
|
|
1829
|
+
pasteBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
1830
|
+
pasteBtn.addEventListener('click', () => {
|
|
1831
|
+
if (pasteTouched) { pasteTouched = false; return; }
|
|
1832
|
+
handlePaste();
|
|
1702
1833
|
});
|
|
1703
1834
|
|
|
1704
1835
|
document.getElementById('paste-send').addEventListener('click', () => {
|
|
@@ -1717,6 +1848,130 @@
|
|
|
1717
1848
|
});
|
|
1718
1849
|
}
|
|
1719
1850
|
|
|
1851
|
+
// ===== Select Text Overlay =====
|
|
1852
|
+
function setupSelectMode() {
|
|
1853
|
+
const selectBtn = document.getElementById('select-btn');
|
|
1854
|
+
const selectOverlay = document.getElementById('select-overlay');
|
|
1855
|
+
const selectContent = document.getElementById('select-content');
|
|
1856
|
+
const PAGE_SIZE = 200;
|
|
1857
|
+
let allLines = [];
|
|
1858
|
+
let loadedFrom = 0;
|
|
1859
|
+
|
|
1860
|
+
function renderLines(from, to) {
|
|
1861
|
+
return allLines.slice(from, to).join('\n');
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function openSelectOverlay() {
|
|
1865
|
+
const ms = managed.get(activeId);
|
|
1866
|
+
if (!ms) return;
|
|
1867
|
+
const buf = ms.term.buffer.active;
|
|
1868
|
+
allLines = [];
|
|
1869
|
+
for (let i = 0; i < buf.length; i++) {
|
|
1870
|
+
const line = buf.getLine(i);
|
|
1871
|
+
if (line) allLines.push(line.translateToString(true));
|
|
1872
|
+
}
|
|
1873
|
+
// Trim trailing empty lines
|
|
1874
|
+
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') allLines.pop();
|
|
1875
|
+
|
|
1876
|
+
// Show last PAGE_SIZE lines
|
|
1877
|
+
loadedFrom = Math.max(0, allLines.length - PAGE_SIZE);
|
|
1878
|
+
selectContent.textContent = renderLines(loadedFrom, allLines.length);
|
|
1879
|
+
|
|
1880
|
+
// Show/hide "Load more" button
|
|
1881
|
+
const loadMoreBtn = document.getElementById('select-load-more');
|
|
1882
|
+
loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
|
|
1883
|
+
loadMoreBtn.textContent = `▲ Load more (${loadedFrom} lines above)`;
|
|
1884
|
+
|
|
1885
|
+
// Show line count in title
|
|
1886
|
+
const title = document.getElementById('select-title');
|
|
1887
|
+
const shown = allLines.length - loadedFrom;
|
|
1888
|
+
title.textContent = allLines.length <= PAGE_SIZE
|
|
1889
|
+
? `Copy Text (${allLines.length} lines)`
|
|
1890
|
+
: `Copy Text (${shown}/${allLines.length} lines)`;
|
|
1891
|
+
|
|
1892
|
+
selectBtn.style.display = 'none';
|
|
1893
|
+
selectOverlay.classList.add('visible');
|
|
1894
|
+
selectContent.scrollTop = selectContent.scrollHeight;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
document.getElementById('select-load-more').addEventListener('click', () => {
|
|
1898
|
+
const prevHeight = selectContent.scrollHeight;
|
|
1899
|
+
const newFrom = Math.max(0, loadedFrom - PAGE_SIZE);
|
|
1900
|
+
const chunk = renderLines(newFrom, loadedFrom);
|
|
1901
|
+
selectContent.textContent = chunk + '\n' + selectContent.textContent;
|
|
1902
|
+
loadedFrom = newFrom;
|
|
1903
|
+
// Keep scroll position stable
|
|
1904
|
+
selectContent.scrollTop = selectContent.scrollHeight - prevHeight;
|
|
1905
|
+
const loadMoreBtn = document.getElementById('select-load-more');
|
|
1906
|
+
loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
|
|
1907
|
+
loadMoreBtn.textContent = loadedFrom > 0 ? `▲ Load more (${loadedFrom} lines above)` : '';
|
|
1908
|
+
// Update title
|
|
1909
|
+
const title = document.getElementById('select-title');
|
|
1910
|
+
const shown = allLines.length - loadedFrom;
|
|
1911
|
+
title.textContent = `Copy Text (${shown}/${allLines.length} lines)`;
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
selectBtn.addEventListener('mousedown', e => e.preventDefault());
|
|
1915
|
+
selectBtn.addEventListener('click', openSelectOverlay);
|
|
1916
|
+
|
|
1917
|
+
document.getElementById('select-copy').addEventListener('click', () => {
|
|
1918
|
+
// Copy finger selection if any, otherwise copy all loaded text
|
|
1919
|
+
const sel = window.getSelection();
|
|
1920
|
+
const text = (sel && sel.toString()) ? sel.toString() : selectContent.textContent;
|
|
1921
|
+
if (!text) return;
|
|
1922
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1923
|
+
navigator.clipboard.writeText(text).then(() => showToast('Copied!')).catch(() => {
|
|
1924
|
+
copyFallback(text);
|
|
1925
|
+
});
|
|
1926
|
+
} else {
|
|
1927
|
+
copyFallback(text);
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1931
|
+
document.getElementById('select-close').addEventListener('click', () => {
|
|
1932
|
+
selectOverlay.classList.remove('visible');
|
|
1933
|
+
selectContent.textContent = '';
|
|
1934
|
+
allLines = [];
|
|
1935
|
+
selectBtn.style.display = '';
|
|
1936
|
+
const ms = managed.get(activeId);
|
|
1937
|
+
if (ms) ms.term.focus();
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// ===== Image Paste =====
|
|
1942
|
+
function setupImagePaste() {
|
|
1943
|
+
document.addEventListener('paste', async (e) => {
|
|
1944
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
1945
|
+
if (!items) return;
|
|
1946
|
+
|
|
1947
|
+
for (const item of items) {
|
|
1948
|
+
if (item.type.startsWith('image/')) {
|
|
1949
|
+
e.preventDefault();
|
|
1950
|
+
const blob = item.getAsFile();
|
|
1951
|
+
if (!blob) return;
|
|
1952
|
+
|
|
1953
|
+
try {
|
|
1954
|
+
const res = await fetch('/api/upload', {
|
|
1955
|
+
method: 'POST',
|
|
1956
|
+
headers: { 'Content-Type': item.type },
|
|
1957
|
+
body: blob,
|
|
1958
|
+
});
|
|
1959
|
+
if (!res.ok) throw new Error('Upload failed');
|
|
1960
|
+
const data = await res.json();
|
|
1961
|
+
const ms = managed.get(activeId);
|
|
1962
|
+
if (ms && ms.ws && ms.ws.readyState === 1) {
|
|
1963
|
+
ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
|
|
1964
|
+
showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
|
|
1965
|
+
}
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
showToast('Image paste failed');
|
|
1968
|
+
}
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1720
1975
|
// ===== New Session Modal =====
|
|
1721
1976
|
let shellsLoaded = false;
|
|
1722
1977
|
|
|
@@ -1755,9 +2010,16 @@
|
|
|
1755
2010
|
const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
|
|
1756
2011
|
const nsBrowserPath = document.getElementById('ns-browser-path');
|
|
1757
2012
|
let nsBrowsePath = '/';
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
2013
|
+
let serverCwd = '/';
|
|
2014
|
+
|
|
2015
|
+
document.getElementById('ns-browse-btn').addEventListener('click', async () => {
|
|
2016
|
+
if (serverCwd === '/') {
|
|
2017
|
+
try {
|
|
2018
|
+
const data = await fetch('/api/shells').then(r => r.json());
|
|
2019
|
+
if (data.cwd) serverCwd = data.cwd;
|
|
2020
|
+
} catch {}
|
|
2021
|
+
}
|
|
2022
|
+
const initial = nsCwdInput.value.trim() || serverCwd;
|
|
1761
2023
|
nsBrowseNavigate(initial);
|
|
1762
2024
|
nsBrowserOverlay.classList.add('visible');
|
|
1763
2025
|
});
|
|
@@ -1782,11 +2044,17 @@
|
|
|
1782
2044
|
try {
|
|
1783
2045
|
const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
|
|
1784
2046
|
const data = await res.json();
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
2047
|
+
let items = '';
|
|
2048
|
+
// Add parent (..) entry unless at root
|
|
2049
|
+
const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
|
|
2050
|
+
if (parent && parent !== dir) {
|
|
2051
|
+
items += `<div class="folder-item" data-path="${escAttr(parent)}">
|
|
2052
|
+
<span class="folder-icon">📁</span>
|
|
2053
|
+
<span class="folder-name">..</span>
|
|
2054
|
+
<span class="folder-arrow">›</span>
|
|
2055
|
+
</div>`;
|
|
1788
2056
|
}
|
|
1789
|
-
|
|
2057
|
+
items += data.dirs.map(d => {
|
|
1790
2058
|
const name = d.split(/[/\\]/).pop();
|
|
1791
2059
|
return `<div class="folder-item" data-path="${escAttr(d)}">
|
|
1792
2060
|
<span class="folder-icon">📁</span>
|
|
@@ -1794,6 +2062,7 @@
|
|
|
1794
2062
|
<span class="folder-arrow">›</span>
|
|
1795
2063
|
</div>`;
|
|
1796
2064
|
}).join('');
|
|
2065
|
+
nsBrowserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
|
|
1797
2066
|
nsBrowserList.querySelectorAll('.folder-item').forEach(el => {
|
|
1798
2067
|
el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
|
|
1799
2068
|
});
|
|
@@ -1804,13 +2073,15 @@
|
|
|
1804
2073
|
}
|
|
1805
2074
|
|
|
1806
2075
|
function nsBrowseRenderBreadcrumb(dir) {
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
2076
|
+
const sep = dir.includes('\\') ? '\\' : '/';
|
|
2077
|
+
const parts = dir.split(/[/\\]/).filter(Boolean);
|
|
2078
|
+
const isWindows = /^[A-Za-z]:/.test(dir);
|
|
2079
|
+
let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
|
|
2080
|
+
let accumulated = isWindows ? '' : '';
|
|
1810
2081
|
parts.forEach((part, i) => {
|
|
1811
|
-
accumulated += '
|
|
2082
|
+
accumulated += (i === 0 && isWindows ? '' : sep) + part;
|
|
1812
2083
|
const isCurrent = i === parts.length - 1;
|
|
1813
|
-
html += `<span class="crumb-sep">›</span>`;
|
|
2084
|
+
if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
|
|
1814
2085
|
html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
|
|
1815
2086
|
});
|
|
1816
2087
|
nsBrowserBreadcrumb.innerHTML = html;
|
|
@@ -1826,6 +2097,10 @@
|
|
|
1826
2097
|
const sel = document.getElementById('ns-shell');
|
|
1827
2098
|
try {
|
|
1828
2099
|
const data = await fetch('/api/shells').then(r => r.json());
|
|
2100
|
+
if (data.cwd) {
|
|
2101
|
+
serverCwd = data.cwd;
|
|
2102
|
+
document.getElementById('ns-cwd').placeholder = data.cwd;
|
|
2103
|
+
}
|
|
1829
2104
|
sel.innerHTML = data.shells.map(s =>
|
|
1830
2105
|
'<option value="' + escAttr(s.cmd) + '"' + (s.cmd === data.default ? ' selected' : '') + '>'
|
|
1831
2106
|
+ esc(s.name) + ' (' + esc(s.cmd) + ')</option>'
|
|
@@ -1898,8 +2173,9 @@
|
|
|
1898
2173
|
for (const id of [...managed.keys()]) {
|
|
1899
2174
|
if (!serverIds.has(id)) {
|
|
1900
2175
|
const ms = managed.get(id);
|
|
2176
|
+
ms.exited = true;
|
|
2177
|
+
if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
|
|
1901
2178
|
if (ms.ws) try { ms.ws.close(); } catch {}
|
|
1902
|
-
if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
|
|
1903
2179
|
ms.term.dispose();
|
|
1904
2180
|
ms.container.remove();
|
|
1905
2181
|
managed.delete(id);
|
|
@@ -1913,6 +2189,7 @@
|
|
|
1913
2189
|
statusText.textContent = '';
|
|
1914
2190
|
statusDot.className = '';
|
|
1915
2191
|
document.getElementById('stop-btn').style.display = 'none';
|
|
2192
|
+
reconnectOverlay.classList.remove('visible');
|
|
1916
2193
|
}
|
|
1917
2194
|
}
|
|
1918
2195
|
}
|
package/src/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
|
+
const log = require('./logger');
|
|
2
3
|
|
|
3
4
|
const LOGIN_HTML = `<!DOCTYPE html>
|
|
4
5
|
<html lang="en">
|
|
@@ -107,6 +108,7 @@ function createAuth(password) {
|
|
|
107
108
|
const attempts = authAttempts.get(ip) || [];
|
|
108
109
|
const recent = attempts.filter((t) => now - t < window);
|
|
109
110
|
if (recent.length >= maxAttempts) {
|
|
111
|
+
log.warn(`Auth: rate limit exceeded for ${ip}`);
|
|
110
112
|
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
111
113
|
}
|
|
112
114
|
recent.push(now);
|