termbeam 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,6 +65,7 @@ termbeam --no-password # disable password protection
65
65
  - **QR code on startup** for instant phone connection
66
66
  - **Light/dark theme** with persistent preference
67
67
  - **Adjustable font size** via status bar controls, saved across sessions
68
+ - **Port preview** — reverse-proxy a single local web server port and preview it in the browser (HTTP only; no WebSocket/HMR; best with server-rendered apps)
68
69
  - **Remote access via [DevTunnel](#remote-access)** — ephemeral or persisted public URLs
69
70
 
70
71
  ## Remote Access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.1.1",
3
+ "version": "1.2.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": {
@@ -36,9 +36,10 @@
36
36
  --danger: #f14c4c;
37
37
  --danger-hover: #d73a3a;
38
38
  --success: #89d185;
39
- --key-bg: #2d2d2d;
40
- --key-border: #404040;
41
- --key-shadow: rgba(0, 0, 0, 0.4);
39
+ --key-bg: #4a4a4c;
40
+ --key-border: #5a5a5c;
41
+ --key-shadow: rgba(0, 0, 0, 0.5);
42
+ --key-special-bg: #333335;
42
43
  --overlay-bg: rgba(0, 0, 0, 0.85);
43
44
  }
44
45
  [data-theme='light'] {
@@ -56,9 +57,10 @@
56
57
  --danger: #e51400;
57
58
  --danger-hover: #c20000;
58
59
  --success: #16825d;
59
- --key-bg: #e8e8e8;
60
- --key-border: #d0d0d0;
61
- --key-shadow: rgba(0, 0, 0, 0.08);
60
+ --key-bg: #ffffff;
61
+ --key-border: #b5b5b5;
62
+ --key-shadow: rgba(0, 0, 0, 0.12);
63
+ --key-special-bg: #adb5bd;
62
64
  --overlay-bg: rgba(0, 0, 0, 0.5);
63
65
  }
64
66
  @font-face {
@@ -416,7 +418,7 @@
416
418
  top: calc(41px + env(safe-area-inset-top, 0px));
417
419
  left: env(safe-area-inset-left, 0px);
418
420
  right: env(safe-area-inset-right, 0px);
419
- bottom: calc(52px + env(safe-area-inset-bottom, 0px));
421
+ bottom: calc(80px + env(safe-area-inset-bottom, 0px));
420
422
  display: flex;
421
423
  overflow: hidden;
422
424
  }
@@ -449,76 +451,124 @@
449
451
  bottom: 0;
450
452
  left: 0;
451
453
  right: 0;
452
- height: calc(52px + env(safe-area-inset-bottom, 0px));
454
+ height: calc(80px + env(safe-area-inset-bottom, 0px));
453
455
  display: flex;
454
- align-items: center;
455
- background: var(--surface);
456
+ flex-direction: column;
457
+ background: #1c1c1e;
456
458
  border-top: 1px solid var(--border);
457
- padding: 0 calc(4px + env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px)
458
- calc(4px + env(safe-area-inset-left, 0px));
459
- gap: 4px;
459
+ padding: 4px calc(3px + env(safe-area-inset-right, 0px)) env(safe-area-inset-bottom, 0px)
460
+ calc(3px + env(safe-area-inset-left, 0px));
461
+ gap: 6px;
460
462
  z-index: 50;
461
463
  transition:
462
464
  background 0.3s,
463
465
  border-color 0.3s;
464
466
  }
467
+ [data-theme='light'] #key-bar {
468
+ background: #d1d3d9;
469
+ }
470
+ .key-row {
471
+ display: flex;
472
+ align-items: center;
473
+ gap: 4px;
474
+ flex: 1;
475
+ }
465
476
  .key-btn {
466
477
  min-width: 0;
467
- height: 40px;
478
+ height: 34px;
468
479
  background: var(--key-bg);
469
- color: var(--text);
470
- border: 1px solid var(--key-border);
471
- border-radius: 8px;
472
- font-size: 12px;
473
- font-weight: 600;
480
+ color: #fff;
481
+ border: none;
482
+ border-radius: 6px;
483
+ font-size: 13px;
484
+ font-weight: 500;
474
485
  cursor: pointer;
475
486
  display: flex;
476
- flex-direction: column;
477
487
  align-items: center;
478
488
  justify-content: center;
479
489
  -webkit-tap-highlight-color: transparent;
480
490
  user-select: none;
481
491
  white-space: nowrap;
482
- padding: 2px 8px;
492
+ padding: 0 6px;
483
493
  flex: 1 1 0;
484
- gap: 0;
485
494
  line-height: 1;
486
495
  transition:
487
- background 0.15s,
488
- color 0.15s,
489
- border-color 0.15s,
490
- transform 0.1s,
491
- box-shadow 0.15s;
496
+ background 0.1s,
497
+ transform 0.08s,
498
+ box-shadow 0.1s;
499
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
500
+ }
501
+ [data-theme='light'] .key-btn {
502
+ color: #000;
503
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
504
+ }
505
+ .key-btn:active {
506
+ background: #6e6e72;
507
+ transform: scale(0.95);
508
+ box-shadow: none;
509
+ }
510
+ [data-theme='light'] .key-btn:active {
511
+ background: #c8c8cc;
512
+ }
513
+ .key-btn.flash {
514
+ background: #fff !important;
515
+ color: #000 !important;
516
+ transition: none;
517
+ }
518
+ [data-theme='light'] .key-btn.flash {
519
+ background: #333 !important;
520
+ color: #fff !important;
521
+ }
522
+ .key-btn.modifier,
523
+ .key-btn.special {
524
+ background: var(--key-special-bg);
525
+ font-size: 12px;
526
+ font-weight: 600;
527
+ }
528
+ [data-theme='light'] .key-btn.modifier,
529
+ [data-theme='light'] .key-btn.special {
530
+ background: var(--key-special-bg);
531
+ color: #000;
532
+ }
533
+ .key-btn.modifier.active {
534
+ background: var(--accent);
535
+ color: #fff;
492
536
  box-shadow:
493
- 0 1px 3px var(--key-shadow),
494
- inset 0 1px 0 rgba(255, 255, 255, 0.05);
537
+ 0 1px 0 rgba(0, 0, 0, 0.2),
538
+ 0 0 10px rgba(0, 120, 212, 0.5);
495
539
  }
496
- .key-btn .hint {
497
- font-size: 8px;
498
- font-weight: 400;
499
- opacity: 0.5;
500
- margin-top: 1px;
501
- letter-spacing: 0.02em;
540
+ [data-theme='light'] .key-btn.modifier.active {
541
+ background: var(--accent);
542
+ color: #fff;
543
+ box-shadow:
544
+ 0 1px 0 rgba(0, 0, 0, 0.2),
545
+ 0 0 10px rgba(0, 120, 212, 0.3);
502
546
  }
503
- .key-btn:hover {
504
- background: var(--border);
505
- border-color: var(--accent);
506
- box-shadow: 0 2px 6px var(--key-shadow);
547
+ .key-btn.icon-btn {
548
+ font-size: 18px;
507
549
  }
508
- .key-btn:active {
550
+ .key-btn.key-enter {
509
551
  background: var(--accent);
510
552
  color: #fff;
511
- border-color: var(--accent);
512
- transform: scale(0.93);
513
- box-shadow: none;
553
+ font-size: 20px;
554
+ }
555
+ .key-btn.key-enter:active {
556
+ background: var(--accent-active);
557
+ }
558
+ .key-btn.key-danger {
559
+ background: #5c2222;
560
+ color: #f87171;
561
+ }
562
+ [data-theme='light'] .key-btn.key-danger {
563
+ background: #fee2e2;
564
+ color: #dc2626;
514
565
  }
515
- .key-btn.wide {
516
- flex: 1.4 1 0;
566
+ .key-btn.key-danger:active {
567
+ background: var(--danger);
568
+ color: #fff;
517
569
  }
518
570
  .key-sep {
519
- width: 1px;
520
- height: 20px;
521
- background: var(--border);
571
+ width: 0;
522
572
  flex-shrink: 0;
523
573
  }
524
574
 
@@ -939,7 +989,8 @@
939
989
  background: var(--overlay-bg);
940
990
  z-index: 200;
941
991
  justify-content: center;
942
- align-items: center;
992
+ align-items: flex-start;
993
+ padding-top: 15vh;
943
994
  }
944
995
  .modal-overlay.visible {
945
996
  display: flex;
@@ -1121,14 +1172,41 @@
1121
1172
 
1122
1173
  .side-panel-header {
1123
1174
  display: flex;
1124
- align-items: center;
1125
- justify-content: space-between;
1126
- padding: 12px 14px;
1175
+ flex-direction: column;
1176
+ padding: 16px 14px 12px;
1127
1177
  border-bottom: 1px solid var(--border);
1128
- font-size: 15px;
1178
+ position: relative;
1179
+ }
1180
+ .side-panel-brand {
1181
+ display: flex;
1182
+ align-items: center;
1183
+ gap: 8px;
1184
+ font-size: 18px;
1129
1185
  font-weight: 700;
1186
+ letter-spacing: -0.02em;
1187
+ }
1188
+ .side-panel-brand svg {
1189
+ flex-shrink: 0;
1190
+ }
1191
+ .side-panel-version {
1192
+ font-size: 11px;
1193
+ color: var(--text-muted);
1194
+ font-weight: 400;
1195
+ margin-top: 2px;
1196
+ padding-left: 28px;
1197
+ }
1198
+ .side-panel-section-title {
1199
+ font-size: 11px;
1200
+ font-weight: 600;
1201
+ text-transform: uppercase;
1202
+ letter-spacing: 0.06em;
1203
+ color: var(--text-dim);
1204
+ padding: 10px 14px 4px;
1130
1205
  }
1131
1206
  .side-panel-close {
1207
+ position: absolute;
1208
+ top: 14px;
1209
+ right: 10px;
1132
1210
  background: none;
1133
1211
  border: none;
1134
1212
  color: var(--text-dim);
@@ -1298,9 +1376,26 @@
1298
1376
  <div id="side-panel-backdrop"></div>
1299
1377
  <div id="side-panel">
1300
1378
  <div class="side-panel-header">
1301
- <span>Sessions</span>
1379
+ <div class="side-panel-brand">
1380
+ <svg
1381
+ width="20"
1382
+ height="20"
1383
+ viewBox="0 0 24 24"
1384
+ fill="none"
1385
+ stroke="currentColor"
1386
+ stroke-width="2"
1387
+ stroke-linecap="round"
1388
+ stroke-linejoin="round"
1389
+ >
1390
+ <polyline points="4 17 10 11 4 5"></polyline>
1391
+ <line x1="12" y1="19" x2="20" y2="19"></line>
1392
+ </svg>
1393
+ TermBeam
1394
+ </div>
1395
+ <div class="side-panel-version" id="side-panel-version"></div>
1302
1396
  <button class="side-panel-close" id="side-panel-close" title="Close">×</button>
1303
1397
  </div>
1398
+ <div class="side-panel-section-title">Sessions</div>
1304
1399
  <div class="side-panel-list" id="side-panel-list"></div>
1305
1400
  <div style="padding: 8px; border-top: 1px solid var(--border)">
1306
1401
  <button
@@ -1405,6 +1500,14 @@
1405
1500
  <button class="bar-btn" id="zoom-out" title="Decrease font size">−</button>
1406
1501
  <button class="bar-btn" id="zoom-in" title="Increase font size">+</button>
1407
1502
  </div>
1503
+ <button
1504
+ class="bar-btn"
1505
+ id="preview-btn"
1506
+ title="Preview local port"
1507
+ onclick="openPreviewModal()"
1508
+ >
1509
+ 🌐
1510
+ </button>
1408
1511
  <button class="bar-btn" id="share-btn" title="Share link">
1409
1512
  <svg
1410
1513
  width="16"
@@ -1484,29 +1587,28 @@
1484
1587
  <div id="copy-toast">Copied!</div>
1485
1588
 
1486
1589
  <div id="key-bar">
1487
- <button class="key-btn" data-key="&#x1b;[A" title="Previous command">
1488
- ↑<span class="hint">prev</span>
1489
- </button>
1490
- <button class="key-btn" data-key="&#x1b;[B" title="Next command">
1491
- ↓<span class="hint">next</span>
1492
- </button>
1493
- <button class="key-btn" data-key="&#x1b;[D" title="Left">←</button>
1494
- <button class="key-btn" data-key="&#x1b;[C" title="Right">→</button>
1495
- <button class="key-btn" data-key="&#x1b;[H" title="Home">Home</button>
1496
- <button class="key-btn" data-key="&#x1b;[F" title="End">End</button>
1497
- <div class="key-sep"></div>
1498
- <button class="key-btn" id="select-btn" title="Copy text">Copy</button>
1499
- <button class="key-btn" id="paste-btn" title="Paste from clipboard">Paste</button>
1500
- <div class="key-sep"></div>
1501
- <button class="key-btn wide" data-key="&#x09;" title="Autocomplete">Tab</button>
1502
- <div class="key-sep"></div>
1503
- <button class="key-btn" data-key="&#x03;" title="Interrupt process">
1504
- ^C<span class="hint">stop</span>
1505
- </button>
1506
- <div class="key-sep"></div>
1507
- <button class="key-btn" data-key="enter" title="Enter / Return">
1508
- ↵<span class="hint">enter</span>
1509
- </button>
1590
+ <div class="key-row">
1591
+ <button class="key-btn special" data-key="&#x1b;" title="Escape">Esc</button>
1592
+ <button class="key-btn special" id="select-btn" title="Copy text">Copy</button>
1593
+ <button class="key-btn special" id="paste-btn" title="Paste from clipboard">Paste</button>
1594
+ <button class="key-btn special" data-key="&#x1b;OH" title="Home">Home</button>
1595
+ <button class="key-btn special" data-key="&#x1b;OF" title="End">End</button>
1596
+ <button class="key-btn icon-btn" data-key="&#x1b;[A" title="Up">↑</button>
1597
+ <button class="key-btn icon-btn key-enter" data-key="enter" title="Enter / Return">
1598
+
1599
+ </button>
1600
+ </div>
1601
+ <div class="key-row">
1602
+ <button class="key-btn modifier" id="ctrl-btn" title="Toggle Ctrl modifier">Ctrl</button>
1603
+ <button class="key-btn modifier" id="shift-btn" title="Toggle Shift modifier">Shift</button>
1604
+ <button class="key-btn special" data-key="&#x09;" title="Autocomplete">Tab</button>
1605
+ <button class="key-btn special key-danger" data-key="&#x03;" title="Interrupt process">
1606
+ ^C
1607
+ </button>
1608
+ <button class="key-btn icon-btn" data-key="&#x1b;[D" title="Left">←</button>
1609
+ <button class="key-btn icon-btn" data-key="&#x1b;[B" title="Down">↓</button>
1610
+ <button class="key-btn icon-btn" data-key="&#x1b;[C" title="Right">→</button>
1611
+ </div>
1510
1612
  </div>
1511
1613
 
1512
1614
  <div id="reconnect-overlay">
@@ -1659,6 +1761,61 @@
1659
1761
  </div>
1660
1762
  </div>
1661
1763
 
1764
+ <!-- Preview Port Modal -->
1765
+ <div class="modal-overlay" id="preview-modal">
1766
+ <div class="modal">
1767
+ <h2>🌐 Preview Local Port</h2>
1768
+ <p
1769
+ style="
1770
+ color: var(--text-secondary);
1771
+ font-size: 13px;
1772
+ margin: -8px 0 16px;
1773
+ line-height: 1.4;
1774
+ "
1775
+ >
1776
+ Open a local server running on your machine in a new tab — works through the tunnel.
1777
+ </p>
1778
+ <label for="preview-port-input">Port</label>
1779
+ <div style="position: relative">
1780
+ <input
1781
+ type="number"
1782
+ id="preview-port-input"
1783
+ placeholder="e.g. 3000"
1784
+ min="1"
1785
+ max="65535"
1786
+ />
1787
+ <span
1788
+ id="preview-detect-status"
1789
+ style="
1790
+ position: absolute;
1791
+ right: 12px;
1792
+ top: 50%;
1793
+ transform: translateY(-50%);
1794
+ font-size: 12px;
1795
+ color: var(--text-secondary);
1796
+ "
1797
+ ></span>
1798
+ </div>
1799
+ <div
1800
+ id="preview-hint"
1801
+ style="
1802
+ font-size: 12px;
1803
+ color: var(--success);
1804
+ margin-top: 6px;
1805
+ display: none;
1806
+ align-items: center;
1807
+ gap: 4px;
1808
+ "
1809
+ >
1810
+ <span>✓</span> <span id="preview-hint-text"></span>
1811
+ </div>
1812
+ <div class="modal-actions">
1813
+ <button class="btn-cancel" id="preview-cancel">Cancel</button>
1814
+ <button class="btn-create" id="preview-open">Open Preview ↗</button>
1815
+ </div>
1816
+ </div>
1817
+ </div>
1818
+
1662
1819
  <!-- Folder Browser -->
1663
1820
  <div class="browser-overlay" id="ns-browser-overlay">
1664
1821
  <div class="browser-sheet">
@@ -1929,6 +2086,7 @@
1929
2086
  setupImagePaste();
1930
2087
  setupSelectMode();
1931
2088
  setupNewSessionModal();
2089
+ setupPreviewModal();
1932
2090
  loadShellsForModal();
1933
2091
  startPolling();
1934
2092
 
@@ -1965,9 +2123,9 @@
1965
2123
  const keyboardOpen = keyboardHeight > 50;
1966
2124
  if (keyboardOpen) {
1967
2125
  keyBar.style.bottom = keyboardHeight + 'px';
1968
- keyBar.style.height = '52px';
2126
+ keyBar.style.height = '80px';
1969
2127
  keyBar.style.paddingBottom = '0px';
1970
- terminalsWrapper.style.bottom = 52 + keyboardHeight + 'px';
2128
+ terminalsWrapper.style.bottom = 80 + keyboardHeight + 'px';
1971
2129
  } else {
1972
2130
  keyBar.style.bottom = '0px';
1973
2131
  keyBar.style.height = '';
@@ -2047,6 +2205,7 @@
2047
2205
  .then((r) => r.json())
2048
2206
  .then((d) => {
2049
2207
  document.getElementById('version-text').textContent = 'v' + d.version;
2208
+ document.getElementById('side-panel-version').textContent = 'v' + d.version;
2050
2209
  })
2051
2210
  .catch(() => {});
2052
2211
  }
@@ -2200,7 +2359,18 @@
2200
2359
  // Terminal input → WebSocket
2201
2360
  term.onData((input) => {
2202
2361
  if (ms.ws && ms.ws.readyState === 1) {
2203
- ms.ws.send(JSON.stringify({ type: 'input', data: input }));
2362
+ let data = input;
2363
+ if (ctrlActive && input.length === 1) {
2364
+ const code = input.toLowerCase().charCodeAt(0);
2365
+ // a-z → Ctrl+letter (0x01-0x1a)
2366
+ if (code >= 97 && code <= 122) {
2367
+ data = String.fromCharCode(code - 96);
2368
+ }
2369
+ clearModifiers();
2370
+ } else if (shiftActive && !ctrlActive) {
2371
+ clearModifiers();
2372
+ }
2373
+ ms.ws.send(JSON.stringify({ type: 'input', data }));
2204
2374
  }
2205
2375
  });
2206
2376
 
@@ -2765,18 +2935,65 @@
2765
2935
  }
2766
2936
 
2767
2937
  // ===== Key Bar =====
2938
+ // Modifier state (shared with terminal onData)
2939
+ let ctrlActive = false;
2940
+ let shiftActive = false;
2941
+ function clearModifiers() {
2942
+ ctrlActive = false;
2943
+ shiftActive = false;
2944
+ const ctrlBtn = document.getElementById('ctrl-btn');
2945
+ const shiftBtn = document.getElementById('shift-btn');
2946
+ if (ctrlBtn) ctrlBtn.classList.remove('active');
2947
+ if (shiftBtn) shiftBtn.classList.remove('active');
2948
+ }
2949
+
2768
2950
  function setupKeyBar() {
2769
2951
  const keyBar = document.getElementById('key-bar');
2952
+ const ctrlBtn = document.getElementById('ctrl-btn');
2953
+ const shiftBtn = document.getElementById('shift-btn');
2770
2954
  let repeatTimer = null;
2771
2955
  let repeatInterval = null;
2772
2956
 
2957
+ function toggleModifier(which) {
2958
+ if (which === 'ctrl') {
2959
+ ctrlActive = !ctrlActive;
2960
+ ctrlBtn.classList.toggle('active', ctrlActive);
2961
+ } else {
2962
+ shiftActive = !shiftActive;
2963
+ shiftBtn.classList.toggle('active', shiftActive);
2964
+ }
2965
+ }
2966
+
2967
+ function applyModifiers(key) {
2968
+ if (!ctrlActive && !shiftActive) return key;
2969
+ // Modifier param: Shift=2, Ctrl=5, Ctrl+Shift=6
2970
+ const mod = ctrlActive && shiftActive ? 6 : ctrlActive ? 5 : 2;
2971
+ // Arrow keys: \x1b[X → \x1b[1;{mod}X
2972
+ const csiMatch = key.match(/^\x1b\[([ABCD])$/);
2973
+ if (csiMatch) return '\x1b[1;' + mod + csiMatch[1];
2974
+ // Home/End: \x1bOH/\x1bOF → \x1b[1;{mod}H/F
2975
+ const ssMatch = key.match(/^\x1bO([HF])$/);
2976
+ if (ssMatch) return '\x1b[1;' + mod + ssMatch[1];
2977
+ // Tab with Shift → reverse tab
2978
+ if (key === '\x09' && shiftActive && !ctrlActive) return '\x1b[Z';
2979
+ return key;
2980
+ }
2981
+
2982
+ function flashBtn(btn) {
2983
+ btn.classList.add('flash');
2984
+ setTimeout(() => btn.classList.remove('flash'), 120);
2985
+ }
2986
+
2773
2987
  function sendKey(btn) {
2774
2988
  if (!btn || !btn.dataset.key) return;
2775
- const data = btn.dataset.key === 'enter' ? '\r' : btn.dataset.key;
2989
+ flashBtn(btn);
2990
+ let data = btn.dataset.key === 'enter' ? '\r' : btn.dataset.key;
2991
+ data = applyModifiers(data);
2776
2992
  const ms = managed.get(activeId);
2777
2993
  if (ms && ms.ws && ms.ws.readyState === 1) {
2778
2994
  ms.ws.send(JSON.stringify({ type: 'input', data }));
2779
2995
  }
2996
+ clearModifiers();
2780
2997
  }
2781
2998
 
2782
2999
  function stopRepeat() {
@@ -2794,6 +3011,37 @@
2794
3011
  }, 400);
2795
3012
  }
2796
3013
 
3014
+ ctrlBtn.addEventListener('click', (e) => {
3015
+ e.preventDefault();
3016
+ e.stopPropagation();
3017
+ toggleModifier('ctrl');
3018
+ });
3019
+ shiftBtn.addEventListener('click', (e) => {
3020
+ e.preventDefault();
3021
+ e.stopPropagation();
3022
+ toggleModifier('shift');
3023
+ });
3024
+ ctrlBtn.addEventListener('mousedown', (e) => e.preventDefault());
3025
+ shiftBtn.addEventListener('mousedown', (e) => e.preventDefault());
3026
+ ctrlBtn.addEventListener(
3027
+ 'touchstart',
3028
+ (e) => {
3029
+ e.preventDefault();
3030
+ keyBarTouched = true;
3031
+ toggleModifier('ctrl');
3032
+ },
3033
+ { passive: false },
3034
+ );
3035
+ shiftBtn.addEventListener(
3036
+ 'touchstart',
3037
+ (e) => {
3038
+ e.preventDefault();
3039
+ keyBarTouched = true;
3040
+ toggleModifier('shift');
3041
+ },
3042
+ { passive: false },
3043
+ );
3044
+
2797
3045
  let keyBarTouched = false;
2798
3046
  keyBar.addEventListener('mousedown', (e) => {
2799
3047
  if (keyBarTouched) {
@@ -2809,24 +3057,45 @@
2809
3057
  keyBar.addEventListener('mouseup', stopRepeat);
2810
3058
  keyBar.addEventListener('mouseleave', stopRepeat);
2811
3059
 
3060
+ // Touch handling: allow native scroll when swiping, fire key only on tap
3061
+ const SWIPE_THRESHOLD = 10;
3062
+ let touchStartX = 0;
3063
+ let touchBtn = null;
3064
+ let touchMoved = false;
3065
+
2812
3066
  keyBar.addEventListener(
2813
3067
  'touchstart',
2814
3068
  (e) => {
2815
3069
  keyBarTouched = true;
2816
- const btn = e.target.closest('.key-btn');
2817
- if (btn && btn.dataset.key) {
2818
- e.preventDefault();
2819
- startRepeat(btn);
3070
+ touchBtn = e.target.closest('.key-btn');
3071
+ touchMoved = false;
3072
+ touchStartX = e.touches[0].clientX;
3073
+ // Don't preventDefault — let the browser handle scroll
3074
+ },
3075
+ { passive: true },
3076
+ );
3077
+ keyBar.addEventListener(
3078
+ 'touchmove',
3079
+ (e) => {
3080
+ if (Math.abs(e.touches[0].clientX - touchStartX) > SWIPE_THRESHOLD) {
3081
+ touchMoved = true;
3082
+ stopRepeat();
2820
3083
  }
2821
3084
  },
2822
- { passive: false },
3085
+ { passive: true },
2823
3086
  );
2824
3087
  keyBar.addEventListener('touchend', (e) => {
2825
- const btn = e.target.closest('.key-btn');
2826
- if (btn && btn.dataset.key) e.preventDefault();
3088
+ if (!touchMoved && touchBtn && touchBtn.dataset.key) {
3089
+ e.preventDefault();
3090
+ sendKey(touchBtn);
3091
+ }
2827
3092
  stopRepeat();
3093
+ touchBtn = null;
3094
+ });
3095
+ keyBar.addEventListener('touchcancel', () => {
3096
+ stopRepeat();
3097
+ touchBtn = null;
2828
3098
  });
2829
- keyBar.addEventListener('touchcancel', stopRepeat);
2830
3099
 
2831
3100
  keyBar.addEventListener('click', (e) => {
2832
3101
  const btn = e.target.closest('.key-btn');
@@ -2994,7 +3263,21 @@
2994
3263
  });
2995
3264
 
2996
3265
  selectBtn.addEventListener('mousedown', (e) => e.preventDefault());
2997
- selectBtn.addEventListener('click', openSelectOverlay);
3266
+ selectBtn.addEventListener(
3267
+ 'touchend',
3268
+ (e) => {
3269
+ e.preventDefault();
3270
+ const ms = managed.get(activeId);
3271
+ if (ms) ms.term.blur();
3272
+ openSelectOverlay();
3273
+ },
3274
+ { passive: false },
3275
+ );
3276
+ selectBtn.addEventListener('click', () => {
3277
+ const ms = managed.get(activeId);
3278
+ if (ms) ms.term.blur();
3279
+ openSelectOverlay();
3280
+ });
2998
3281
 
2999
3282
  document.getElementById('select-copy').addEventListener('click', () => {
3000
3283
  // Copy finger selection if any, otherwise copy all loaded text
@@ -3357,6 +3640,64 @@
3357
3640
  input.select();
3358
3641
  }
3359
3642
 
3643
+ function openPreviewModal() {
3644
+ const modal = document.getElementById('preview-modal');
3645
+ const input = document.getElementById('preview-port-input');
3646
+ const status = document.getElementById('preview-detect-status');
3647
+ const hint = document.getElementById('preview-hint');
3648
+ const hintText = document.getElementById('preview-hint-text');
3649
+ input.value = '';
3650
+ hint.style.display = 'none';
3651
+ status.textContent = '';
3652
+ modal.classList.add('visible');
3653
+ input.focus();
3654
+
3655
+ if (activeId) {
3656
+ status.textContent = 'detecting…';
3657
+ fetch('/api/sessions/' + activeId + '/detect-port')
3658
+ .then((r) => (r.ok ? r.json() : null))
3659
+ .then((data) => {
3660
+ status.textContent = '';
3661
+ if (data && data.detected) {
3662
+ input.value = data.port;
3663
+ input.select();
3664
+ hintText.textContent = 'Detected port ' + data.port + ' from terminal output';
3665
+ hint.style.display = 'flex';
3666
+ }
3667
+ })
3668
+ .catch(() => {
3669
+ status.textContent = '';
3670
+ });
3671
+ }
3672
+ }
3673
+
3674
+ function submitPreview() {
3675
+ const input = document.getElementById('preview-port-input');
3676
+ const port = parseInt(input.value, 10);
3677
+ if (isNaN(port) || port < 1 || port > 65535) {
3678
+ input.style.borderColor = '#f87171';
3679
+ input.focus();
3680
+ setTimeout(() => (input.style.borderColor = ''), 1500);
3681
+ return;
3682
+ }
3683
+ window.open('/preview/' + port + '/', '_blank');
3684
+ document.getElementById('preview-modal').classList.remove('visible');
3685
+ }
3686
+
3687
+ function setupPreviewModal() {
3688
+ document.getElementById('preview-cancel').addEventListener('click', () => {
3689
+ document.getElementById('preview-modal').classList.remove('visible');
3690
+ });
3691
+ document.getElementById('preview-open').addEventListener('click', submitPreview);
3692
+ document.getElementById('preview-port-input').addEventListener('keydown', (e) => {
3693
+ if (e.key === 'Enter') submitPreview();
3694
+ });
3695
+ document.getElementById('preview-modal').addEventListener('click', (e) => {
3696
+ if (e.target.id === 'preview-modal')
3697
+ document.getElementById('preview-modal').classList.remove('visible');
3698
+ });
3699
+ }
3700
+
3360
3701
  document.getElementById('share-btn').addEventListener('click', async () => {
3361
3702
  const urlPromise = fetch('/api/share-token')
3362
3703
  .then((r) => (r.ok ? r.json() : null))
package/src/preview.js ADDED
@@ -0,0 +1,112 @@
1
+ const http = require('http');
2
+ const express = require('express');
3
+ const log = require('./logger');
4
+
5
+ const PROXY_TIMEOUT = 10_000;
6
+
7
+ // Rewrite absolute paths in HTML/CSS so they route through the proxy
8
+ function rewriteAbsolutePaths(body, prefix, isHtml) {
9
+ if (isHtml) {
10
+ // Rewrite HTML attributes: href="/...", src="/...", action="/...", etc.
11
+ body = body.replace(
12
+ /((?:href|src|action|srcset|poster|data|formaction)\s*=\s*["'])\/(?!\/|preview\/)/gi,
13
+ `$1${prefix}/`,
14
+ );
15
+ // Rewrite meta content URLs: content="/..."
16
+ body = body.replace(/(content\s*=\s*["'])\/(?!\/|preview\/)/gi, `$1${prefix}/`);
17
+ }
18
+ // Rewrite CSS url() references: url("/...") or url('/...') or url(/...)
19
+ body = body.replace(/(url\(\s*["']?)\/(?!\/|preview\/)/gi, `$1${prefix}/`);
20
+ return body;
21
+ }
22
+
23
+ function createPreviewProxy() {
24
+ const router = express.Router();
25
+
26
+ function proxyRequest(req, res) {
27
+ const port = Number(req.params.port);
28
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
29
+ return res
30
+ .status(400)
31
+ .json({ error: 'Invalid port: must be an integer between 1 and 65535' });
32
+ }
33
+
34
+ // Strip /preview/:port prefix, keep the rest (or default to /)
35
+ // Express 5 *path returns an array of segments — join them back
36
+ const segments = req.params.path;
37
+ const forwardPath = segments ? `/${[].concat(segments).join('/')}` : '/';
38
+ const search = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
39
+
40
+ const fwdHeaders = { ...req.headers, host: `127.0.0.1:${port}` };
41
+ // Request uncompressed so we can rewrite HTML content
42
+ delete fwdHeaders['accept-encoding'];
43
+
44
+ const options = {
45
+ hostname: '127.0.0.1',
46
+ port,
47
+ path: forwardPath + search,
48
+ method: req.method,
49
+ headers: fwdHeaders,
50
+ };
51
+
52
+ log.debug(`Preview proxy: ${req.method} ${forwardPath}${search} → 127.0.0.1:${port}`);
53
+
54
+ const prefix = `/preview/${port}`;
55
+
56
+ const proxyReq = http.request(options, (proxyRes) => {
57
+ const headers = { ...proxyRes.headers };
58
+
59
+ // Rewrite Location headers so redirects stay inside the proxy
60
+ if (headers.location) {
61
+ const loc = headers.location;
62
+ if (loc.startsWith('/') && !loc.startsWith(prefix)) {
63
+ headers.location = prefix + loc;
64
+ }
65
+ }
66
+
67
+ const contentType = (headers['content-type'] || '').toLowerCase();
68
+ const isHtml = contentType.includes('text/html');
69
+ const isCss = contentType.includes('text/css');
70
+
71
+ if (isHtml || isCss) {
72
+ // Buffer response to rewrite absolute paths
73
+ const chunks = [];
74
+ proxyRes.on('data', (chunk) => chunks.push(chunk));
75
+ proxyRes.on('end', () => {
76
+ let body = Buffer.concat(chunks).toString();
77
+ body = rewriteAbsolutePaths(body, prefix, isHtml);
78
+ delete headers['content-length'];
79
+ headers['transfer-encoding'] = 'chunked';
80
+ res.writeHead(proxyRes.statusCode, headers);
81
+ res.end(body);
82
+ });
83
+ } else {
84
+ res.writeHead(proxyRes.statusCode, headers);
85
+ proxyRes.pipe(res);
86
+ }
87
+ });
88
+
89
+ proxyReq.setTimeout(PROXY_TIMEOUT, () => {
90
+ proxyReq.destroy();
91
+ if (!res.headersSent) {
92
+ res.status(504).json({ error: 'Gateway timeout: upstream server did not respond in time' });
93
+ }
94
+ });
95
+
96
+ proxyReq.on('error', (err) => {
97
+ log.warn(`Preview proxy error (port ${port}): ${err.message}`);
98
+ if (!res.headersSent) {
99
+ res.status(502).json({ error: `Bad gateway: ${err.message}` });
100
+ }
101
+ });
102
+
103
+ req.pipe(proxyReq);
104
+ }
105
+
106
+ router.all('/:port', proxyRequest);
107
+ router.all('/:port/*path', proxyRequest);
108
+
109
+ return router;
110
+ }
111
+
112
+ module.exports = { createPreviewProxy };
package/src/routes.js CHANGED
@@ -132,6 +132,26 @@ function setupRoutes(app, { auth, sessions, config, state }) {
132
132
  res.json({ shells, default: config.defaultShell, cwd: config.cwd });
133
133
  });
134
134
 
135
+ app.get('/api/sessions/:id/detect-port', auth.middleware, (req, res) => {
136
+ const session = sessions.get(req.params.id);
137
+ if (!session) return res.status(404).json({ error: 'not found' });
138
+
139
+ const buf = session.scrollbackBuf || '';
140
+ const regex = /https?:\/\/(?:localhost|127\.0\.0\.1):(\d+)/g;
141
+ let lastPort = null;
142
+ let match;
143
+ while ((match = regex.exec(buf)) !== null) {
144
+ const port = parseInt(match[1], 10);
145
+ if (port >= 1 && port <= 65535) lastPort = port;
146
+ }
147
+
148
+ if (lastPort !== null) {
149
+ res.json({ detected: true, port: lastPort });
150
+ } else {
151
+ res.json({ detected: false });
152
+ }
153
+ });
154
+
135
155
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
136
156
  if (sessions.delete(req.params.id)) {
137
157
  res.json({ ok: true });
package/src/server.js CHANGED
@@ -13,6 +13,7 @@ const { SessionManager } = require('./sessions');
13
13
  const { setupRoutes, cleanupUploadedFiles } = require('./routes');
14
14
  const { setupWebSocket } = require('./websocket');
15
15
  const { startTunnel, cleanupTunnel, findDevtunnel } = require('./tunnel');
16
+ const { createPreviewProxy } = require('./preview');
16
17
 
17
18
  // --- Helpers ---
18
19
  function getLocalIP() {
@@ -43,7 +44,9 @@ function createTermBeamServer(overrides = {}) {
43
44
  app.set('trust proxy', 'loopback');
44
45
  app.use(express.json());
45
46
  app.use(cookieParser());
46
- app.use((_req, res, next) => {
47
+ app.use((req, res, next) => {
48
+ // Don't apply TermBeam's security headers to proxied preview content
49
+ if (req.path.startsWith('/preview/')) return next();
47
50
  res.setHeader('X-Content-Type-Options', 'nosniff');
48
51
  res.setHeader('X-Frame-Options', 'DENY');
49
52
  res.setHeader('Referrer-Policy', 'no-referrer');
@@ -59,6 +62,7 @@ function createTermBeamServer(overrides = {}) {
59
62
  const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
60
63
 
61
64
  const state = { shareBaseUrl: null };
65
+ app.use('/preview', auth.middleware, createPreviewProxy());
62
66
  setupRoutes(app, { auth, sessions, config, state });
63
67
  setupWebSocket(wss, { auth, sessions });
64
68