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 +1 -0
- package/package.json +1 -1
- package/public/terminal.html +431 -90
- package/src/preview.js +112 -0
- package/src/routes.js +20 -0
- package/src/server.js +5 -1
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
package/public/terminal.html
CHANGED
|
@@ -36,9 +36,10 @@
|
|
|
36
36
|
--danger: #f14c4c;
|
|
37
37
|
--danger-hover: #d73a3a;
|
|
38
38
|
--success: #89d185;
|
|
39
|
-
--key-bg: #
|
|
40
|
-
--key-border: #
|
|
41
|
-
--key-shadow: rgba(0, 0, 0, 0.
|
|
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: #
|
|
60
|
-
--key-border: #
|
|
61
|
-
--key-shadow: rgba(0, 0, 0, 0.
|
|
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(
|
|
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(
|
|
454
|
+
height: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
453
455
|
display: flex;
|
|
454
|
-
|
|
455
|
-
background:
|
|
456
|
+
flex-direction: column;
|
|
457
|
+
background: #1c1c1e;
|
|
456
458
|
border-top: 1px solid var(--border);
|
|
457
|
-
padding:
|
|
458
|
-
calc(
|
|
459
|
-
gap:
|
|
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:
|
|
478
|
+
height: 34px;
|
|
468
479
|
background: var(--key-bg);
|
|
469
|
-
color:
|
|
470
|
-
border:
|
|
471
|
-
border-radius:
|
|
472
|
-
font-size:
|
|
473
|
-
font-weight:
|
|
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:
|
|
492
|
+
padding: 0 6px;
|
|
483
493
|
flex: 1 1 0;
|
|
484
|
-
gap: 0;
|
|
485
494
|
line-height: 1;
|
|
486
495
|
transition:
|
|
487
|
-
background 0.
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
494
|
-
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|
504
|
-
|
|
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
|
|
550
|
+
.key-btn.key-enter {
|
|
509
551
|
background: var(--accent);
|
|
510
552
|
color: #fff;
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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.
|
|
516
|
-
|
|
566
|
+
.key-btn.key-danger:active {
|
|
567
|
+
background: var(--danger);
|
|
568
|
+
color: #fff;
|
|
517
569
|
}
|
|
518
570
|
.key-sep {
|
|
519
|
-
width:
|
|
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:
|
|
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
|
-
|
|
1125
|
-
|
|
1126
|
-
padding: 12px 14px;
|
|
1175
|
+
flex-direction: column;
|
|
1176
|
+
padding: 16px 14px 12px;
|
|
1127
1177
|
border-bottom: 1px solid var(--border);
|
|
1128
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
<
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
</button>
|
|
1590
|
+
<div class="key-row">
|
|
1591
|
+
<button class="key-btn special" data-key="" 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="OH" title="Home">Home</button>
|
|
1595
|
+
<button class="key-btn special" data-key="OF" title="End">End</button>
|
|
1596
|
+
<button class="key-btn icon-btn" data-key="[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="	" title="Autocomplete">Tab</button>
|
|
1605
|
+
<button class="key-btn special key-danger" data-key="" title="Interrupt process">
|
|
1606
|
+
^C
|
|
1607
|
+
</button>
|
|
1608
|
+
<button class="key-btn icon-btn" data-key="[D" title="Left">←</button>
|
|
1609
|
+
<button class="key-btn icon-btn" data-key="[B" title="Down">↓</button>
|
|
1610
|
+
<button class="key-btn icon-btn" data-key="[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 = '
|
|
2126
|
+
keyBar.style.height = '80px';
|
|
1969
2127
|
keyBar.style.paddingBottom = '0px';
|
|
1970
|
-
terminalsWrapper.style.bottom =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
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:
|
|
3085
|
+
{ passive: true },
|
|
2823
3086
|
);
|
|
2824
3087
|
keyBar.addEventListener('touchend', (e) => {
|
|
2825
|
-
|
|
2826
|
-
|
|
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(
|
|
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((
|
|
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
|
|