termbeam 1.10.3 โ 1.11.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/package.json +2 -2
- package/public/index.html +88 -1
- package/public/sw.js +1 -1
- package/public/terminal.html +189 -37
- package/src/routes.js +23 -0
- package/src/server.js +37 -1
- package/src/sessions.js +9 -3
- package/src/update-check.js +240 -0
- package/src/version.js +23 -5
- package/src/websocket.js +47 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termbeam",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Beam your terminal to any device โ mobile-optimized web terminal with multi-session support",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"url": "https://github.com/dorlugasigal/TermBeam/issues"
|
|
48
48
|
},
|
|
49
49
|
"engines": {
|
|
50
|
-
"node": ">=
|
|
50
|
+
"node": ">=20.0.0"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"bin/",
|
package/public/index.html
CHANGED
|
@@ -197,6 +197,45 @@
|
|
|
197
197
|
border: 1px solid rgba(128, 128, 128, 0.3);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
.update-banner {
|
|
201
|
+
margin: 12px 16px 0;
|
|
202
|
+
padding: 10px 14px;
|
|
203
|
+
background: var(--surface);
|
|
204
|
+
border: 1px solid var(--accent);
|
|
205
|
+
border-radius: 10px;
|
|
206
|
+
display: none;
|
|
207
|
+
align-items: center;
|
|
208
|
+
gap: 10px;
|
|
209
|
+
font-size: 13px;
|
|
210
|
+
color: var(--text);
|
|
211
|
+
}
|
|
212
|
+
.update-banner.visible {
|
|
213
|
+
display: flex;
|
|
214
|
+
}
|
|
215
|
+
.update-banner-text {
|
|
216
|
+
flex: 1;
|
|
217
|
+
}
|
|
218
|
+
.update-banner-text code {
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
background: var(--border);
|
|
221
|
+
padding: 2px 6px;
|
|
222
|
+
border-radius: 4px;
|
|
223
|
+
word-break: break-all;
|
|
224
|
+
}
|
|
225
|
+
.update-banner-dismiss {
|
|
226
|
+
background: none;
|
|
227
|
+
border: none;
|
|
228
|
+
color: var(--text-dim);
|
|
229
|
+
cursor: pointer;
|
|
230
|
+
font-size: 18px;
|
|
231
|
+
line-height: 1;
|
|
232
|
+
padding: 0 2px;
|
|
233
|
+
flex-shrink: 0;
|
|
234
|
+
}
|
|
235
|
+
.update-banner-dismiss:hover {
|
|
236
|
+
color: var(--text);
|
|
237
|
+
}
|
|
238
|
+
|
|
200
239
|
.sessions-list {
|
|
201
240
|
padding: 16px;
|
|
202
241
|
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
@@ -730,7 +769,8 @@
|
|
|
730
769
|
<div class="header">
|
|
731
770
|
<h1>๐ก Term<span>Beam</span></h1>
|
|
732
771
|
<p>
|
|
733
|
-
Beam your terminal to any device ยท
|
|
772
|
+
Beam your terminal to any device ยท
|
|
773
|
+
<span id="version" style="color: var(--accent)"></span>
|
|
734
774
|
</p>
|
|
735
775
|
<button class="header-btn" id="share-btn" style="right: 96px; top: 16px" title="Share link">
|
|
736
776
|
<svg
|
|
@@ -833,6 +873,23 @@
|
|
|
833
873
|
</div>
|
|
834
874
|
</div>
|
|
835
875
|
|
|
876
|
+
<div class="update-banner" id="update-banner">
|
|
877
|
+
<div class="update-banner-text">
|
|
878
|
+
<strong>Update available:</strong> <span id="update-versions"></span><br />
|
|
879
|
+
<span id="update-command-text"
|
|
880
|
+
>Run: <code id="update-command">npm install -g termbeam@latest</code></span
|
|
881
|
+
>
|
|
882
|
+
</div>
|
|
883
|
+
<button
|
|
884
|
+
class="update-banner-dismiss"
|
|
885
|
+
id="update-dismiss"
|
|
886
|
+
title="Dismiss"
|
|
887
|
+
aria-label="Dismiss update notification"
|
|
888
|
+
>
|
|
889
|
+
×
|
|
890
|
+
</button>
|
|
891
|
+
</div>
|
|
892
|
+
|
|
836
893
|
<div class="sessions-list" id="sessions-list"></div>
|
|
837
894
|
<button class="new-session" id="new-session-btn">+ New Session</button>
|
|
838
895
|
|
|
@@ -981,6 +1038,36 @@
|
|
|
981
1038
|
const listEl = document.getElementById('sessions-list');
|
|
982
1039
|
const modal = document.getElementById('modal');
|
|
983
1040
|
|
|
1041
|
+
// Update notification
|
|
1042
|
+
(async function checkUpdate() {
|
|
1043
|
+
if (sessionStorage.getItem('update-dismissed')) return;
|
|
1044
|
+
try {
|
|
1045
|
+
const res = await fetch('/api/update-check');
|
|
1046
|
+
if (!res.ok) return;
|
|
1047
|
+
const info = await res.json();
|
|
1048
|
+
if (info.updateAvailable && info.latest) {
|
|
1049
|
+
const banner = document.getElementById('update-banner');
|
|
1050
|
+
document.getElementById('update-versions').textContent =
|
|
1051
|
+
'v' + info.current + ' \u2192 v' + info.latest;
|
|
1052
|
+
if (info.command) {
|
|
1053
|
+
const cmdEl = document.getElementById('update-command');
|
|
1054
|
+
cmdEl.textContent = info.command;
|
|
1055
|
+
if (info.method === 'npx') {
|
|
1056
|
+
document.getElementById('update-command-text').textContent = 'Next time, run: ';
|
|
1057
|
+
document.getElementById('update-command-text').appendChild(cmdEl);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
banner.classList.add('visible');
|
|
1061
|
+
}
|
|
1062
|
+
} catch {
|
|
1063
|
+
// Silent โ update check is non-critical
|
|
1064
|
+
}
|
|
1065
|
+
})();
|
|
1066
|
+
document.getElementById('update-dismiss').addEventListener('click', () => {
|
|
1067
|
+
document.getElementById('update-banner').classList.remove('visible');
|
|
1068
|
+
sessionStorage.setItem('update-dismissed', '1');
|
|
1069
|
+
});
|
|
1070
|
+
|
|
984
1071
|
function getActivityLabel(ts) {
|
|
985
1072
|
if (!ts) return '';
|
|
986
1073
|
const diff = (Date.now() - ts) / 1000;
|
package/public/sw.js
CHANGED
package/public/terminal.html
CHANGED
|
@@ -2293,6 +2293,7 @@
|
|
|
2293
2293
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
2294
2294
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
2295
2295
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
|
|
2296
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-canvas@0.7.0/lib/addon-canvas.min.js"></script>
|
|
2296
2297
|
<script src="/js/shared.js"></script>
|
|
2297
2298
|
<script src="/js/themes.js"></script>
|
|
2298
2299
|
<script src="/js/terminal-themes.js"></script>
|
|
@@ -2376,6 +2377,9 @@
|
|
|
2376
2377
|
const terminalsWrapper = document.getElementById('terminals-wrapper');
|
|
2377
2378
|
|
|
2378
2379
|
// ===== Font Loading (non-blocking) =====
|
|
2380
|
+
// Hook for font-load refit (set inside init, called by font loader)
|
|
2381
|
+
let onFontReady = null;
|
|
2382
|
+
|
|
2379
2383
|
const nerdFont = new FontFace(
|
|
2380
2384
|
'NerdFont',
|
|
2381
2385
|
"url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')",
|
|
@@ -2387,6 +2391,10 @@
|
|
|
2387
2391
|
})
|
|
2388
2392
|
.catch(() => {
|
|
2389
2393
|
console.warn('Nerd Font failed to load, using fallback');
|
|
2394
|
+
})
|
|
2395
|
+
.finally(() => {
|
|
2396
|
+
// Refit all terminals once font metrics are stable
|
|
2397
|
+
if (onFontReady) onFontReady();
|
|
2390
2398
|
});
|
|
2391
2399
|
|
|
2392
2400
|
// Start immediately โ don't wait for font
|
|
@@ -2497,9 +2505,8 @@
|
|
|
2497
2505
|
|
|
2498
2506
|
if (startId) activateSession(startId);
|
|
2499
2507
|
else {
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
document.getElementById('stop-btn').style.display = 'none';
|
|
2508
|
+
window.location.replace('/');
|
|
2509
|
+
return;
|
|
2503
2510
|
}
|
|
2504
2511
|
|
|
2505
2512
|
renderTabs();
|
|
@@ -2512,6 +2519,7 @@
|
|
|
2512
2519
|
setupPreviewModal();
|
|
2513
2520
|
loadShellsForModal();
|
|
2514
2521
|
startPolling();
|
|
2522
|
+
setTimeout(requestWakeLock, 0);
|
|
2515
2523
|
|
|
2516
2524
|
// Pinch-to-zoom
|
|
2517
2525
|
(function setupPinchZoom() {
|
|
@@ -2578,9 +2586,22 @@
|
|
|
2578
2586
|
}
|
|
2579
2587
|
}
|
|
2580
2588
|
}
|
|
2589
|
+
onFontReady = doResize;
|
|
2581
2590
|
window.addEventListener('resize', doResize);
|
|
2582
2591
|
screen.orientation?.addEventListener('change', () => setTimeout(doResize, 150));
|
|
2583
2592
|
|
|
2593
|
+
// ResizeObserver โ catches container size changes that don't trigger window resize
|
|
2594
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
2595
|
+
let resizeTimer = null;
|
|
2596
|
+
new ResizeObserver(() => {
|
|
2597
|
+
clearTimeout(resizeTimer);
|
|
2598
|
+
resizeTimer = setTimeout(doResize, 100);
|
|
2599
|
+
}).observe(terminalsWrapper);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
// Refit after all fonts finish loading (catches bold variant, system fonts)
|
|
2603
|
+
document.fonts.ready.then(() => doResize());
|
|
2604
|
+
|
|
2584
2605
|
// Mobile soft keyboard
|
|
2585
2606
|
if (window.visualViewport) {
|
|
2586
2607
|
const keyBar = document.getElementById('key-bar');
|
|
@@ -2640,24 +2661,70 @@
|
|
|
2640
2661
|
);
|
|
2641
2662
|
}
|
|
2642
2663
|
|
|
2643
|
-
//
|
|
2664
|
+
// Wake Lock โ helps keep mobile browser tab alive
|
|
2665
|
+
let wakeLock = null;
|
|
2666
|
+
|
|
2667
|
+
async function requestWakeLock() {
|
|
2668
|
+
if (!('wakeLock' in navigator)) return;
|
|
2669
|
+
try {
|
|
2670
|
+
wakeLock = await navigator.wakeLock.request('screen');
|
|
2671
|
+
wakeLock.addEventListener('release', () => {
|
|
2672
|
+
wakeLock = null;
|
|
2673
|
+
});
|
|
2674
|
+
} catch {
|
|
2675
|
+
// Wake Lock request failed (e.g. low battery, not visible)
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
function releaseWakeLock() {
|
|
2680
|
+
if (wakeLock) {
|
|
2681
|
+
wakeLock.release().catch(() => {});
|
|
2682
|
+
wakeLock = null;
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
// Reconnect & refit when returning from idle / tab switch
|
|
2644
2687
|
document.addEventListener('visibilitychange', () => {
|
|
2645
|
-
if (!document.hidden
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
ms
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2688
|
+
if (!document.hidden) {
|
|
2689
|
+
if (activeId) {
|
|
2690
|
+
clearUnreadIndicator();
|
|
2691
|
+
const ms = managed.get(activeId);
|
|
2692
|
+
if (ms) {
|
|
2693
|
+
// Reconnect immediately if WebSocket died in background
|
|
2694
|
+
if (!ms.exited && (!ms.ws || ms.ws.readyState !== WebSocket.OPEN)) {
|
|
2695
|
+
if (ms.reconnectTimer) {
|
|
2696
|
+
clearTimeout(ms.reconnectTimer);
|
|
2697
|
+
ms.reconnectTimer = null;
|
|
2698
|
+
}
|
|
2699
|
+
ms.reconnectDelay = 3000;
|
|
2700
|
+
connectSession(ms);
|
|
2701
|
+
} else {
|
|
2702
|
+
ms.term.scrollToBottom();
|
|
2703
|
+
ms.fitAddon.fit();
|
|
2704
|
+
sendResize(ms);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
if (splitMode && splitSecondId) {
|
|
2708
|
+
const ms2 = managed.get(splitSecondId);
|
|
2709
|
+
if (ms2) {
|
|
2710
|
+
if (!ms2.exited && (!ms2.ws || ms2.ws.readyState !== WebSocket.OPEN)) {
|
|
2711
|
+
if (ms2.reconnectTimer) {
|
|
2712
|
+
clearTimeout(ms2.reconnectTimer);
|
|
2713
|
+
ms2.reconnectTimer = null;
|
|
2714
|
+
}
|
|
2715
|
+
ms2.reconnectDelay = 3000;
|
|
2716
|
+
connectSession(ms2);
|
|
2717
|
+
} else {
|
|
2718
|
+
ms2.term.scrollToBottom();
|
|
2719
|
+
ms2.fitAddon.fit();
|
|
2720
|
+
sendResize(ms2);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2659
2723
|
}
|
|
2660
2724
|
}
|
|
2725
|
+
requestWakeLock();
|
|
2726
|
+
} else {
|
|
2727
|
+
releaseWakeLock();
|
|
2661
2728
|
}
|
|
2662
2729
|
});
|
|
2663
2730
|
|
|
@@ -2709,7 +2776,7 @@
|
|
|
2709
2776
|
fontWeight: 'normal',
|
|
2710
2777
|
fontWeightBold: 'bold',
|
|
2711
2778
|
letterSpacing: 0,
|
|
2712
|
-
lineHeight: 1.
|
|
2779
|
+
lineHeight: 1.0,
|
|
2713
2780
|
theme: TERM_THEMES[getTheme()] || darkTermTheme,
|
|
2714
2781
|
allowProposedApi: true,
|
|
2715
2782
|
scrollback: 10000,
|
|
@@ -2729,6 +2796,17 @@
|
|
|
2729
2796
|
terminalsWrapper.appendChild(container);
|
|
2730
2797
|
term.open(container);
|
|
2731
2798
|
|
|
2799
|
+
// Canvas renderer โ sharper text and more accurate selection.
|
|
2800
|
+
// Skip in automated browsers (navigator.webdriver) where canvas
|
|
2801
|
+
// rendering makes .xterm-rows inaccessible to DOM text queries.
|
|
2802
|
+
if (window.CanvasAddon && !navigator.webdriver) {
|
|
2803
|
+
try {
|
|
2804
|
+
term.loadAddon(new window.CanvasAddon.CanvasAddon());
|
|
2805
|
+
} catch {
|
|
2806
|
+
// Fall back to DOM renderer
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2732
2810
|
// Scroll-to-bottom button
|
|
2733
2811
|
const scrollBtn = document.createElement('button');
|
|
2734
2812
|
scrollBtn.className = 'scroll-bottom-btn';
|
|
@@ -2740,17 +2818,28 @@
|
|
|
2740
2818
|
});
|
|
2741
2819
|
container.appendChild(scrollBtn);
|
|
2742
2820
|
|
|
2743
|
-
// Write coalescer โ batch rapid term.write() calls
|
|
2744
|
-
//
|
|
2821
|
+
// Write coalescer โ batch rapid term.write() calls to reduce flicker.
|
|
2822
|
+
// Single RAF for interactive typing (low latency). During bursts
|
|
2823
|
+
// (>512 bytes buffered, e.g. Ctrl+O expand), waits one extra frame
|
|
2824
|
+
// so cursor-movement sequences arrive as one atomic write.
|
|
2745
2825
|
let writeBuf = '';
|
|
2746
2826
|
let writeRaf = null;
|
|
2827
|
+
function flushWrite() {
|
|
2828
|
+
writeRaf = null;
|
|
2829
|
+
if (writeBuf) {
|
|
2830
|
+
term.write(writeBuf);
|
|
2831
|
+
writeBuf = '';
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2747
2834
|
function coalescedWrite(data) {
|
|
2748
2835
|
writeBuf += data;
|
|
2749
2836
|
if (!writeRaf) {
|
|
2750
2837
|
writeRaf = requestAnimationFrame(() => {
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2838
|
+
if (writeBuf.length > 512) {
|
|
2839
|
+
writeRaf = requestAnimationFrame(flushWrite);
|
|
2840
|
+
} else {
|
|
2841
|
+
flushWrite();
|
|
2842
|
+
}
|
|
2754
2843
|
});
|
|
2755
2844
|
}
|
|
2756
2845
|
}
|
|
@@ -3043,6 +3132,9 @@
|
|
|
3043
3132
|
if (ms.id === activeId) {
|
|
3044
3133
|
statusDot.className = '';
|
|
3045
3134
|
statusText.textContent = 'Disconnected';
|
|
3135
|
+
reconnectOverlay.querySelector('.msg').textContent =
|
|
3136
|
+
'Disconnected. Attempting to reconnect\u2026';
|
|
3137
|
+
reconnectOverlay.classList.add('visible');
|
|
3046
3138
|
}
|
|
3047
3139
|
if (!ms.exited) {
|
|
3048
3140
|
ms.reconnectTimer = setTimeout(() => {
|
|
@@ -3139,11 +3231,8 @@
|
|
|
3139
3231
|
const remaining = [...managed.keys()];
|
|
3140
3232
|
if (remaining.length > 0) activateSession(remaining[0]);
|
|
3141
3233
|
else {
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
statusText.textContent = '';
|
|
3145
|
-
statusDot.className = '';
|
|
3146
|
-
document.getElementById('stop-btn').style.display = 'none';
|
|
3234
|
+
window.location.replace('/');
|
|
3235
|
+
return;
|
|
3147
3236
|
}
|
|
3148
3237
|
}
|
|
3149
3238
|
renderTabs();
|
|
@@ -4232,12 +4321,8 @@
|
|
|
4232
4321
|
const remaining = [...managed.keys()];
|
|
4233
4322
|
if (remaining.length > 0) activateSession(remaining[0]);
|
|
4234
4323
|
else {
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
statusText.textContent = '';
|
|
4238
|
-
statusDot.className = '';
|
|
4239
|
-
document.getElementById('stop-btn').style.display = 'none';
|
|
4240
|
-
reconnectOverlay.classList.remove('visible');
|
|
4324
|
+
window.location.replace('/');
|
|
4325
|
+
return;
|
|
4241
4326
|
}
|
|
4242
4327
|
}
|
|
4243
4328
|
}
|
|
@@ -4553,7 +4638,7 @@
|
|
|
4553
4638
|
box.innerHTML =
|
|
4554
4639
|
'<div style="font-size:24px;margin-bottom:8px;">โก</div>' +
|
|
4555
4640
|
'<div style="font-size:16px;font-weight:600;color:var(--text);margin-bottom:4px;">TermBeam</div>' +
|
|
4556
|
-
'<div style="font-size:13px;color:var(--text-secondary);margin-bottom:
|
|
4641
|
+
'<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">' +
|
|
4557
4642
|
esc(ver) +
|
|
4558
4643
|
'</div>' +
|
|
4559
4644
|
'<div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;">Terminal in your browser, optimized for mobile.</div>' +
|
|
@@ -4561,7 +4646,74 @@
|
|
|
4561
4646
|
'<a href="https://github.com/dorlugasigal/TermBeam" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">GitHub</a>' +
|
|
4562
4647
|
'<a href="https://dorlugasigal.github.io/TermBeam/" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Docs</a>' +
|
|
4563
4648
|
'<a href="https://termbeam.pages.dev" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Website</a>' +
|
|
4564
|
-
'</div>'
|
|
4649
|
+
'</div>' +
|
|
4650
|
+
'<div id="about-update-area" style="margin-bottom:12px;"></div>';
|
|
4651
|
+
const updateArea = box.querySelector('#about-update-area');
|
|
4652
|
+
const updateBtn = document.createElement('button');
|
|
4653
|
+
updateBtn.textContent = 'Check for updates';
|
|
4654
|
+
updateBtn.style.cssText =
|
|
4655
|
+
'padding:6px 16px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);font-size:12px;cursor:pointer;';
|
|
4656
|
+
updateBtn.onclick = async () => {
|
|
4657
|
+
updateBtn.textContent = 'Checking...';
|
|
4658
|
+
updateBtn.disabled = true;
|
|
4659
|
+
updateBtn.style.cursor = 'default';
|
|
4660
|
+
try {
|
|
4661
|
+
const res = await fetch('/api/update-check?force=true');
|
|
4662
|
+
if (!res.ok) throw new Error();
|
|
4663
|
+
const info = await res.json();
|
|
4664
|
+
if (info.updateAvailable && info.latest) {
|
|
4665
|
+
const cmd = info.command || 'npm install -g termbeam@latest';
|
|
4666
|
+
updateArea.innerHTML = '';
|
|
4667
|
+
const status = document.createElement('div');
|
|
4668
|
+
status.style.cssText = 'font-size:12px;color:var(--accent);margin-bottom:8px;';
|
|
4669
|
+
status.textContent = 'v' + info.latest + ' available';
|
|
4670
|
+
updateArea.appendChild(status);
|
|
4671
|
+
const cmdRow = document.createElement('div');
|
|
4672
|
+
cmdRow.style.cssText =
|
|
4673
|
+
'display:flex;align-items:center;justify-content:center;gap:6px;';
|
|
4674
|
+
const cmdText = document.createElement('code');
|
|
4675
|
+
cmdText.textContent = cmd;
|
|
4676
|
+
cmdText.style.cssText =
|
|
4677
|
+
'font-size:11px;color:var(--accent);background:var(--bg);padding:4px 8px;border-radius:4px;border:1px solid var(--border);';
|
|
4678
|
+
const copyBtn = document.createElement('button');
|
|
4679
|
+
copyBtn.textContent = 'Copy';
|
|
4680
|
+
copyBtn.style.cssText =
|
|
4681
|
+
'padding:4px 10px;border-radius:4px;border:1px solid var(--accent);background:transparent;color:var(--accent);font-size:11px;cursor:pointer;';
|
|
4682
|
+
copyBtn.onclick = () => {
|
|
4683
|
+
const onSuccess = () => {
|
|
4684
|
+
copyBtn.textContent = 'Copied!';
|
|
4685
|
+
setTimeout(() => {
|
|
4686
|
+
copyBtn.textContent = 'Copy';
|
|
4687
|
+
}, 2000);
|
|
4688
|
+
};
|
|
4689
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
4690
|
+
navigator.clipboard
|
|
4691
|
+
.writeText(cmd)
|
|
4692
|
+
.then(onSuccess)
|
|
4693
|
+
.catch(() => {
|
|
4694
|
+
copyFallback(cmd);
|
|
4695
|
+
onSuccess();
|
|
4696
|
+
});
|
|
4697
|
+
} else {
|
|
4698
|
+
copyFallback(cmd);
|
|
4699
|
+
onSuccess();
|
|
4700
|
+
}
|
|
4701
|
+
};
|
|
4702
|
+
cmdRow.appendChild(cmdText);
|
|
4703
|
+
cmdRow.appendChild(copyBtn);
|
|
4704
|
+
updateArea.appendChild(cmdRow);
|
|
4705
|
+
} else {
|
|
4706
|
+
updateBtn.textContent = 'Up to date';
|
|
4707
|
+
updateBtn.style.color = '#4ec9b0';
|
|
4708
|
+
updateBtn.style.borderColor = '#4ec9b0';
|
|
4709
|
+
}
|
|
4710
|
+
} catch {
|
|
4711
|
+
updateBtn.textContent = 'Check failed โ try again';
|
|
4712
|
+
updateBtn.disabled = false;
|
|
4713
|
+
updateBtn.style.cursor = 'pointer';
|
|
4714
|
+
}
|
|
4715
|
+
};
|
|
4716
|
+
updateArea.appendChild(updateBtn);
|
|
4565
4717
|
const btn = document.createElement('button');
|
|
4566
4718
|
btn.textContent = 'Close';
|
|
4567
4719
|
btn.style.cssText =
|
package/src/routes.js
CHANGED
|
@@ -87,6 +87,29 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
87
87
|
res.json({ version: getVersion() });
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
// Update check API
|
|
91
|
+
app.get('/api/update-check', apiRateLimit, auth.middleware, async (req, res) => {
|
|
92
|
+
const { checkForUpdate, detectInstallMethod } = require('./update-check');
|
|
93
|
+
const force = req.query.force === 'true';
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const info = await checkForUpdate({ currentVersion: config.version, force });
|
|
97
|
+
const installInfo = detectInstallMethod();
|
|
98
|
+
state.updateInfo = { ...info, ...installInfo };
|
|
99
|
+
res.json(state.updateInfo);
|
|
100
|
+
} catch {
|
|
101
|
+
const installInfo = detectInstallMethod();
|
|
102
|
+
const fallback = {
|
|
103
|
+
current: config.version,
|
|
104
|
+
latest: null,
|
|
105
|
+
updateAvailable: false,
|
|
106
|
+
...installInfo,
|
|
107
|
+
};
|
|
108
|
+
state.updateInfo = fallback;
|
|
109
|
+
res.json(fallback);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
90
113
|
// Share token auto-login middleware: validates ?ott= param, sets session cookie, redirects to clean URL
|
|
91
114
|
function autoLogin(req, res, next) {
|
|
92
115
|
const { ott } = req.query;
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,7 @@ const { setupWebSocket } = require('./websocket');
|
|
|
16
16
|
const { startTunnel, cleanupTunnel, findDevtunnel } = require('./tunnel');
|
|
17
17
|
const { createPreviewProxy } = require('./preview');
|
|
18
18
|
const { writeConnectionConfig, removeConnectionConfig } = require('./resume');
|
|
19
|
+
const { checkForUpdate, detectInstallMethod } = require('./update-check');
|
|
19
20
|
|
|
20
21
|
// --- Helpers ---
|
|
21
22
|
function getLocalIP() {
|
|
@@ -73,7 +74,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
73
74
|
const server = http.createServer(app);
|
|
74
75
|
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
|
|
75
76
|
|
|
76
|
-
const state = { shareBaseUrl: null };
|
|
77
|
+
const state = { shareBaseUrl: null, updateInfo: null };
|
|
77
78
|
app.use('/preview', auth.middleware, createPreviewProxy());
|
|
78
79
|
setupRoutes(app, { auth, sessions, config, state });
|
|
79
80
|
setupWebSocket(wss, { auth, sessions });
|
|
@@ -273,6 +274,41 @@ function createTermBeamServer(overrides = {}) {
|
|
|
273
274
|
);
|
|
274
275
|
console.log('');
|
|
275
276
|
|
|
277
|
+
// Non-blocking update check โ runs after banner, never delays startup.
|
|
278
|
+
// Skip under the Node test runner and CI to avoid network requests in tests.
|
|
279
|
+
// Accept any version containing a semver-like pattern (including dev builds).
|
|
280
|
+
const versionParts = config.version.match(/(\d{1,10})\.(\d{1,10})\.(\d{1,10})/);
|
|
281
|
+
if (
|
|
282
|
+
versionParts &&
|
|
283
|
+
!process.env.NODE_TEST_CONTEXT &&
|
|
284
|
+
!process.env.CI &&
|
|
285
|
+
!process.argv.includes('--test')
|
|
286
|
+
) {
|
|
287
|
+
const installInfo = detectInstallMethod();
|
|
288
|
+
checkForUpdate({ currentVersion: config.version })
|
|
289
|
+
.then((info) => {
|
|
290
|
+
state.updateInfo = { ...info, ...installInfo };
|
|
291
|
+
if (info.updateAvailable) {
|
|
292
|
+
const yl = '\x1b[33m';
|
|
293
|
+
const gn2 = '\x1b[38;5;114m';
|
|
294
|
+
const dm = '\x1b[2m';
|
|
295
|
+
console.log('');
|
|
296
|
+
console.log(
|
|
297
|
+
` ${yl}Update available:${rs} ${dm}${info.current}${rs} โ ${gn2}${info.latest}${rs}`,
|
|
298
|
+
);
|
|
299
|
+
if (installInfo.method === 'npx') {
|
|
300
|
+
console.log(` Next time, run: ${gn2}npx termbeam@latest${rs}`);
|
|
301
|
+
} else {
|
|
302
|
+
console.log(` Run: ${gn2}${installInfo.command}${rs}`);
|
|
303
|
+
}
|
|
304
|
+
console.log('');
|
|
305
|
+
}
|
|
306
|
+
})
|
|
307
|
+
.catch(() => {
|
|
308
|
+
// Silent failure โ update check is non-critical
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
276
312
|
resolve({ url: `http://localhost:${actualPort}`, defaultId });
|
|
277
313
|
});
|
|
278
314
|
});
|
package/src/sessions.js
CHANGED
|
@@ -144,9 +144,15 @@ class SessionManager {
|
|
|
144
144
|
ptyProcess.onData((data) => {
|
|
145
145
|
session.lastActivity = Date.now();
|
|
146
146
|
session.scrollbackBuf += data;
|
|
147
|
-
//
|
|
148
|
-
if (session.scrollbackBuf.length >
|
|
149
|
-
|
|
147
|
+
// High/low water scrollback cap: trim to 500k chars when buffer exceeds 1,000,000 chars
|
|
148
|
+
if (session.scrollbackBuf.length > 1000000) {
|
|
149
|
+
let buf = session.scrollbackBuf.slice(-500000);
|
|
150
|
+
// Advance to first newline to avoid starting mid-line
|
|
151
|
+
const nlIdx = buf.indexOf('\n');
|
|
152
|
+
if (nlIdx > 0 && nlIdx < 200) {
|
|
153
|
+
buf = buf.slice(nlIdx + 1);
|
|
154
|
+
}
|
|
155
|
+
session.scrollbackBuf = buf;
|
|
150
156
|
}
|
|
151
157
|
for (const ws of session.clients) {
|
|
152
158
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = 'termbeam';
|
|
8
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
9
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
11
|
+
const MAX_RESPONSE_SIZE = 100 * 1024; // 100 KB โ real npm responses are ~3-4 KB
|
|
12
|
+
|
|
13
|
+
function getCacheFilePath() {
|
|
14
|
+
const configDir = process.env.TERMBEAM_CONFIG_DIR || path.join(os.homedir(), '.termbeam');
|
|
15
|
+
return path.join(configDir, 'update-check.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readCache() {
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(fs.readFileSync(getCacheFilePath(), 'utf8'));
|
|
21
|
+
if (data && typeof data.latest === 'string' && typeof data.checkedAt === 'number') {
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Cache missing or corrupt โ will re-fetch
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeCache(latest) {
|
|
31
|
+
try {
|
|
32
|
+
const cacheFile = getCacheFilePath();
|
|
33
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
34
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ latest, checkedAt: Date.now() }) + '\n', {
|
|
35
|
+
mode: 0o600,
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
// Non-critical โ next check will just re-fetch
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a version string into a [major, minor, patch] numeric tuple.
|
|
44
|
+
* Strips leading "v", drops prerelease/build metadata.
|
|
45
|
+
* Returns null if the version cannot be safely parsed.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeVersion(version) {
|
|
48
|
+
if (typeof version !== 'string') return null;
|
|
49
|
+
let v = version.trim();
|
|
50
|
+
if (!v) return null;
|
|
51
|
+
if (v[0] === 'v' || v[0] === 'V') v = v.slice(1);
|
|
52
|
+
|
|
53
|
+
// Drop build metadata (+foo) and prerelease tags (-beta.1)
|
|
54
|
+
const plusIdx = v.indexOf('+');
|
|
55
|
+
if (plusIdx !== -1) v = v.slice(0, plusIdx);
|
|
56
|
+
const dashIdx = v.indexOf('-');
|
|
57
|
+
if (dashIdx !== -1) v = v.slice(0, dashIdx);
|
|
58
|
+
if (!v) return null;
|
|
59
|
+
|
|
60
|
+
const parts = v.split('.');
|
|
61
|
+
if (parts.length === 0 || parts.length > 3) return null;
|
|
62
|
+
|
|
63
|
+
const nums = [];
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (!/^\d+$/.test(part)) return null;
|
|
66
|
+
nums.push(Number(part));
|
|
67
|
+
}
|
|
68
|
+
while (nums.length < 3) nums.push(0);
|
|
69
|
+
return nums;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compare two semver version strings (e.g. "1.10.2" vs "1.11.0").
|
|
74
|
+
* Returns true if `latest` is newer than `current`.
|
|
75
|
+
* Returns false if either version cannot be parsed.
|
|
76
|
+
*/
|
|
77
|
+
function isNewerVersion(current, latest) {
|
|
78
|
+
const cur = normalizeVersion(current);
|
|
79
|
+
const lat = normalizeVersion(latest);
|
|
80
|
+
if (!cur || !lat) return false;
|
|
81
|
+
for (let i = 0; i < 3; i++) {
|
|
82
|
+
if (lat[i] > cur[i]) return true;
|
|
83
|
+
if (lat[i] < cur[i]) return false;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Strip ANSI escape sequences and control characters from a string.
|
|
90
|
+
* Prevents terminal injection if the registry returns malicious data.
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeVersion(v) {
|
|
93
|
+
if (typeof v !== 'string') return '';
|
|
94
|
+
return (
|
|
95
|
+
v
|
|
96
|
+
// CSI sequences: ESC [ ... command
|
|
97
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
98
|
+
// OSC sequences: ESC ] ... BEL or ESC ] ... ESC \
|
|
99
|
+
.replace(/\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)/g, '')
|
|
100
|
+
// DCS, SOS, PM, APC: ESC P/X/^/_ ... ESC \
|
|
101
|
+
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, '')
|
|
102
|
+
// Single-character ESC sequences
|
|
103
|
+
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
104
|
+
// Remaining C0 and C1 control characters
|
|
105
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch the latest version from the npm registry.
|
|
111
|
+
* Returns the version string or null on failure.
|
|
112
|
+
* @param {string} [registryUrl] - Override the registry URL (for testing).
|
|
113
|
+
*/
|
|
114
|
+
function fetchLatestVersion(registryUrl) {
|
|
115
|
+
const url = registryUrl || REGISTRY_URL;
|
|
116
|
+
const client = url.startsWith('https') ? https : http;
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const req = client.get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
119
|
+
if (res.statusCode !== 200) {
|
|
120
|
+
res.resume();
|
|
121
|
+
resolve(null);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let body = '';
|
|
125
|
+
let aborted = false;
|
|
126
|
+
res.setEncoding('utf8');
|
|
127
|
+
res.on('data', (chunk) => {
|
|
128
|
+
if (aborted) return;
|
|
129
|
+
body += chunk;
|
|
130
|
+
if (body.length > MAX_RESPONSE_SIZE) {
|
|
131
|
+
aborted = true;
|
|
132
|
+
req.destroy();
|
|
133
|
+
resolve(null);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
res.on('end', () => {
|
|
137
|
+
if (aborted) return;
|
|
138
|
+
try {
|
|
139
|
+
const data = JSON.parse(body);
|
|
140
|
+
const version = data.version;
|
|
141
|
+
if (!version || typeof version !== 'string') {
|
|
142
|
+
resolve(null);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
146
|
+
resolve(null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
resolve(sanitizeVersion(version));
|
|
150
|
+
} catch {
|
|
151
|
+
resolve(null);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
// Unref so a pending update check can't delay process exit
|
|
156
|
+
req.on('socket', (socket) => socket.unref());
|
|
157
|
+
req.on('error', () => resolve(null));
|
|
158
|
+
req.on('timeout', () => {
|
|
159
|
+
req.destroy();
|
|
160
|
+
resolve(null);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check for available updates.
|
|
167
|
+
* @param {object} options
|
|
168
|
+
* @param {string} options.currentVersion - The current version (e.g. "1.10.2")
|
|
169
|
+
* @param {boolean} [options.force=false] - Bypass cache and fetch fresh data
|
|
170
|
+
* @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean}>}
|
|
171
|
+
*/
|
|
172
|
+
async function checkForUpdate({ currentVersion, force = false } = {}) {
|
|
173
|
+
if (!currentVersion) {
|
|
174
|
+
return { current: 'unknown', latest: null, updateAvailable: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check cache first (unless forced)
|
|
178
|
+
if (!force) {
|
|
179
|
+
const cache = readCache();
|
|
180
|
+
if (cache && Date.now() - cache.checkedAt < CACHE_TTL_MS) {
|
|
181
|
+
const cachedLatest = typeof cache.latest === 'string' ? sanitizeVersion(cache.latest) : null;
|
|
182
|
+
if (cachedLatest && /^\d+\.\d+\.\d+$/.test(cachedLatest)) {
|
|
183
|
+
return {
|
|
184
|
+
current: currentVersion,
|
|
185
|
+
latest: cachedLatest,
|
|
186
|
+
updateAvailable: isNewerVersion(currentVersion, cachedLatest),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fetch from registry
|
|
193
|
+
const latest = await module.exports.fetchLatestVersion();
|
|
194
|
+
if (!latest) {
|
|
195
|
+
return { current: currentVersion, latest: null, updateAvailable: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Cache the result
|
|
199
|
+
writeCache(latest);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
current: currentVersion,
|
|
203
|
+
latest,
|
|
204
|
+
updateAvailable: isNewerVersion(currentVersion, latest),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Detect how TermBeam was installed and return the appropriate update command.
|
|
210
|
+
* @returns {{ method: string, command: string }}
|
|
211
|
+
*/
|
|
212
|
+
function detectInstallMethod() {
|
|
213
|
+
// npx / npm exec โ npm sets npm_command=exec
|
|
214
|
+
if (process.env.npm_command === 'exec') {
|
|
215
|
+
return { method: 'npx', command: 'npx termbeam@latest' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Detect package manager from npm_execpath (set during npm/yarn/pnpm lifecycle)
|
|
219
|
+
const execPath = process.env.npm_execpath || '';
|
|
220
|
+
if (execPath.includes('yarn')) {
|
|
221
|
+
return { method: 'yarn', command: 'yarn global add termbeam@latest' };
|
|
222
|
+
}
|
|
223
|
+
if (execPath.includes('pnpm')) {
|
|
224
|
+
return { method: 'pnpm', command: 'pnpm add -g termbeam@latest' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Default: npm global install
|
|
228
|
+
return { method: 'npm', command: 'npm install -g termbeam@latest' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
checkForUpdate,
|
|
233
|
+
isNewerVersion,
|
|
234
|
+
normalizeVersion,
|
|
235
|
+
fetchLatestVersion,
|
|
236
|
+
readCache,
|
|
237
|
+
writeCache,
|
|
238
|
+
sanitizeVersion,
|
|
239
|
+
detectInstallMethod,
|
|
240
|
+
};
|
package/src/version.js
CHANGED
|
@@ -10,17 +10,35 @@ function getVersion() {
|
|
|
10
10
|
return base;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
// Running from source โ
|
|
13
|
+
// Running from source โ git tags are the version source of truth.
|
|
14
|
+
// This avoids drift between package.json and tagged releases.
|
|
14
15
|
try {
|
|
15
16
|
const gitDesc = execSync('git describe --tags --always --dirty', {
|
|
16
17
|
cwd: path.join(__dirname, '..'),
|
|
17
18
|
encoding: 'utf-8',
|
|
18
19
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
20
|
}).trim();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
|
|
22
|
+
const tagMatch = gitDesc.match(/^v(\d+\.\d+\.\d+)(?:-(\d+)-g([0-9a-f]+))?(-dirty)?$/);
|
|
23
|
+
if (tagMatch) {
|
|
24
|
+
const gitVersion = tagMatch[1];
|
|
25
|
+
const commits = tagMatch[2];
|
|
26
|
+
const hash = tagMatch[3];
|
|
27
|
+
const dirty = tagMatch[4];
|
|
28
|
+
|
|
29
|
+
// Exactly on a clean tag โ return the tag version
|
|
30
|
+
if (!commits && !dirty) return gitVersion;
|
|
31
|
+
|
|
32
|
+
// Build a combined semver-style dev string
|
|
33
|
+
let ver = `${gitVersion}-dev`;
|
|
34
|
+
if (commits) ver += `.${commits}`;
|
|
35
|
+
const meta = [hash ? `g${hash}` : null, dirty ? 'dirty' : null].filter(Boolean).join('.');
|
|
36
|
+
if (meta) ver += `+${meta}`;
|
|
37
|
+
return ver;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// No semver tag found (e.g. bare commit hash) โ fall back to package.json
|
|
41
|
+
return `${base}-dev+${gitDesc}`;
|
|
24
42
|
} catch {
|
|
25
43
|
return `${base}-dev`;
|
|
26
44
|
}
|
package/src/websocket.js
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
const log = require('./logger');
|
|
2
2
|
|
|
3
|
+
const ACTIVE_THRESHOLD = 60000; // 60 seconds
|
|
4
|
+
|
|
5
|
+
// OSC color query/response sequences (OSC 4/10/11/12) cause garbled output
|
|
6
|
+
// on replay: color queries trigger xterm.js to generate responses that echo
|
|
7
|
+
// through the PTY as visible text, accumulating on each refresh.
|
|
8
|
+
const OSC_COLOR_RE = /\x1b\](?:4;\d+|10|11|12);[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
9
|
+
function sanitizeForReplay(buf) {
|
|
10
|
+
return buf.replace(OSC_COLOR_RE, '');
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
function recalcPtySize(session) {
|
|
4
|
-
|
|
5
|
-
let
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
let activeCols = Infinity;
|
|
16
|
+
let activeRows = Infinity;
|
|
17
|
+
let allCols = Infinity;
|
|
18
|
+
let allRows = Infinity;
|
|
19
|
+
let hasActive = false;
|
|
20
|
+
|
|
6
21
|
for (const client of session.clients) {
|
|
7
|
-
if (client._dims)
|
|
8
|
-
|
|
9
|
-
|
|
22
|
+
if (!client._dims) continue;
|
|
23
|
+
allCols = Math.min(allCols, client._dims.cols);
|
|
24
|
+
allRows = Math.min(allRows, client._dims.rows);
|
|
25
|
+
if (client._lastActivity && now - client._lastActivity < ACTIVE_THRESHOLD) {
|
|
26
|
+
activeCols = Math.min(activeCols, client._dims.cols);
|
|
27
|
+
activeRows = Math.min(activeRows, client._dims.rows);
|
|
28
|
+
hasActive = true;
|
|
10
29
|
}
|
|
11
30
|
}
|
|
31
|
+
|
|
32
|
+
const minCols = hasActive ? activeCols : allCols;
|
|
33
|
+
const minRows = hasActive ? activeRows : allRows;
|
|
34
|
+
|
|
12
35
|
if (minCols === Infinity || minRows === Infinity) return;
|
|
13
36
|
if (minCols === session._lastCols && minRows === session._lastRows) return;
|
|
14
37
|
session._lastCols = minCols;
|
|
@@ -39,6 +62,11 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
64
|
|
|
65
|
+
const pingInterval = setInterval(() => {
|
|
66
|
+
if (ws.readyState === 1) ws.ping();
|
|
67
|
+
}, 30000);
|
|
68
|
+
if (typeof pingInterval.unref === 'function') pingInterval.unref();
|
|
69
|
+
|
|
42
70
|
let authenticated = !auth.password;
|
|
43
71
|
let attached = null;
|
|
44
72
|
|
|
@@ -95,6 +123,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
95
123
|
log.warn(`WS: attach failed โ session ${msg.sessionId} not found`);
|
|
96
124
|
return;
|
|
97
125
|
}
|
|
126
|
+
ws._lastActivity = Date.now();
|
|
98
127
|
attached = session;
|
|
99
128
|
// First client: defer adding to session.clients until after the
|
|
100
129
|
// first resize so we can decide whether the PTY needs resizing.
|
|
@@ -104,7 +133,9 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
104
133
|
} else {
|
|
105
134
|
session.clients.add(ws);
|
|
106
135
|
if (session.scrollbackBuf.length > 0) {
|
|
107
|
-
ws.send(
|
|
136
|
+
ws.send(
|
|
137
|
+
JSON.stringify({ type: 'output', data: sanitizeForReplay(session.scrollbackBuf) }),
|
|
138
|
+
);
|
|
108
139
|
}
|
|
109
140
|
}
|
|
110
141
|
ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
|
|
@@ -115,12 +146,14 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
115
146
|
if (!attached) return;
|
|
116
147
|
|
|
117
148
|
if (msg.type === 'input') {
|
|
149
|
+
ws._lastActivity = Date.now();
|
|
118
150
|
attached.pty.write(msg.data);
|
|
119
151
|
} else if (msg.type === 'resize') {
|
|
120
152
|
const cols = Math.floor(msg.cols);
|
|
121
153
|
const rows = Math.floor(msg.rows);
|
|
122
154
|
if (cols > 0 && cols <= 500 && rows > 0 && rows <= 200) {
|
|
123
155
|
ws._dims = { cols, rows };
|
|
156
|
+
ws._lastActivity = Date.now();
|
|
124
157
|
if (ws._pendingResize) {
|
|
125
158
|
ws._pendingResize = false;
|
|
126
159
|
// Only discard scrollback and send SIGWINCH if the PTY was
|
|
@@ -136,7 +169,12 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
136
169
|
} else {
|
|
137
170
|
attached.clients.add(ws);
|
|
138
171
|
if (attached.scrollbackBuf.length > 0) {
|
|
139
|
-
ws.send(
|
|
172
|
+
ws.send(
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
type: 'output',
|
|
175
|
+
data: sanitizeForReplay(attached.scrollbackBuf),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
140
178
|
}
|
|
141
179
|
}
|
|
142
180
|
} else {
|
|
@@ -150,6 +188,7 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
150
188
|
});
|
|
151
189
|
|
|
152
190
|
ws.on('close', () => {
|
|
191
|
+
clearInterval(pingInterval);
|
|
153
192
|
if (attached) {
|
|
154
193
|
attached.clients.delete(ws);
|
|
155
194
|
recalcPtySize(attached);
|
|
@@ -159,4 +198,4 @@ function setupWebSocket(wss, { auth, sessions }) {
|
|
|
159
198
|
});
|
|
160
199
|
}
|
|
161
200
|
|
|
162
|
-
module.exports = { setupWebSocket };
|
|
201
|
+
module.exports = { setupWebSocket, ACTIVE_THRESHOLD, sanitizeForReplay };
|