Feature: Web remote terminal (xterm.js + mobile UX polish)

This commit is contained in:
yuanyuanxiang
2026-05-14 23:57:48 +02:00
parent 5d9554780f
commit 5a92c3306f
11 changed files with 953 additions and 8 deletions

View File

@@ -22,6 +22,8 @@ inline std::string GetWebPageHTML() {
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="/manifest.json">
<!-- xterm.js (). Files served by WebService from RC binary resources. -->
<link rel="stylesheet" href="/static/xterm.css">
<link rel="icon" type="image/png" href="data:image/png;base64,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">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -470,6 +472,104 @@ inline std::string GetWebPageHTML() {
border-color: rgba(233, 69, 96, 0.3);
}
.device-card:hover::before { opacity: 1; }
/* 终端图标按钮右上角独立于卡片本体点击事件onclick stopPropagation */
.device-card .card-term-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: #aaa;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 1;
}
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
.device-card .card-term-btn:active { transform: scale(0.92); }
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
/* ====== 终端页面 ====== */
#term-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #000;
padding: 0;
display: none;
flex-direction: column;
overflow: hidden; /* 防 iOS 软键盘弹出时 body 偷偷加 scroll 顶起整页 */
overscroll-behavior: contain;
}
#term-page.active { display: flex !important; }
/* 顶部 toolbar 加 safe-area-inset-top避免 iPhone 刘海 / 灵动岛压住 Back 按钮。
position: sticky + top:0 兜底:万一发生页面 scrollBack 按钮也始终钉在顶部 */
#term-page .screen-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(0,0,0,0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.term-host {
flex: 1;
background: #000;
padding: 6px;
overflow: hidden;
min-height: 0; /* flex-item 在容器里 overflow 才生效 */
}
.term-host .xterm { height: 100% !important; width: 100% !important; }
.term-host .xterm .xterm-viewport { background-color: #000 !important; }
/* 始终可见的滚动条(覆盖 xterm.js 默认 + 浏览器 autohide
Firefox 用 scrollbar-width / scrollbar-color其它浏览器用 ::-webkit-scrollbar */
.term-host .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.35) transparent;
}
.term-host .xterm-viewport::-webkit-scrollbar { width: 8px; background: rgba(255,255,255,0.04); }
.term-host .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.35);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.55); background-clip: padding-box; }
/* 移动辅助按钮栏:底部固定一行,覆盖手机软键盘上方常用键
Tab / Esc / Ctrl+C / 历史 ↑ —— 90% 应急场景够用) */
.term-aux-bar {
display: none; /* 默认隐藏JS 在窄屏 / 触屏环境下显示 */
gap: 6px;
padding: 6px calc(8px + env(safe-area-inset-right))
calc(6px + env(safe-area-inset-bottom))
calc(8px + env(safe-area-inset-left));
background: rgba(0,0,0,0.85);
border-top: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
#term-page.active .term-aux-bar.visible { display: flex; }
.term-aux-bar button {
flex: 1;
min-width: 0;
height: 36px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #ddd;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.term-aux-bar button:active { background: rgba(76,175,80,0.35); transform: scale(0.96); }
.device-card h3 {
color: #fff;
font-size: 16px;
@@ -1082,6 +1182,26 @@ inline std::string GetWebPageHTML() {
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
</div>
<!-- Terminal Page (xterm.js) -->
<div id="term-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="closeTerminal()">Back</button>
<div class="toolbar-info">
<div id="term-title" class="device-name">Terminal</div>
<div id="term-status-info" class="conn-info">Connecting...</div>
</div>
</div>
<div id="term-host" class="term-host"></div>
<!-- / JS .visible -->
<div class="term-aux-bar" id="term-aux-bar">
<button onclick="termSendSpecial('tab')">Tab</button>
<button onclick="termSendSpecial('esc')">Esc</button>
<button onclick="termSendSpecial('ctrlc')">Ctrl+C</button>
<button onclick="termSendSpecial('up')">&uarr;</button>
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">&#x25BC;</button>
</div>
</div>
<!-- User Management Modal -->
<div class="modal-overlay" id="users-modal">
<div class="modal-content">
@@ -1131,6 +1251,12 @@ inline std::string GetWebPageHTML() {
</div>
)HTML";
// 加载 xterm.js + FitAddon终端。放在 app script 前,保证 Terminal/FitAddon 全局可用。
html += R"HTML(
<script src="/static/xterm.js"></script>
<script src="/static/xterm-fit.js"></script>
)HTML";
// Part 7: JavaScript - State and WebSocket
html += R"HTML(
<script>
@@ -1344,6 +1470,24 @@ inline std::string GetWebPageHTML() {
setTimeout(() => showPage('devices-page'), 2000);
}
break;
case 'term_ready':
termState.ready = true;
document.getElementById('term-status-info').textContent =
'Connected (' + (msg.mode === 'pty' ? 'PTY' : 'Legacy shell') + ')';
// 通知 server 当前 cols/rowsPTY 模式下 host 才知道窗口尺寸
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
notifyTerminalResize();
break;
case 'term_closed':
if (termState.deviceId) {
if (termState.term) {
termState.term.write('\r\n\x1b[33m[Session closed' +
(msg.msg ? ': ' + msg.msg : '') + ']\x1b[0m\r\n');
}
termState.ready = false;
// 不立刻跳回 devices-page让用户看到提示。点 Back 才真返回。
}
break;
case 'disconnect_result':
// disconnect() already handles navigation, this is just server acknowledgment
// No action needed - prevents race conditions when switching devices
@@ -1478,6 +1622,15 @@ inline std::string GetWebPageHTML() {
let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder
function handleBinaryFrame(data) {
// 终端输出帧4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
// 视频帧首 4 字节是 deviceID (uint32 LE)撞这个具体值的概率极低4 字节 magic
// 比单字节前缀安全得多,无需额外的状态校验。
const u8 = new Uint8Array(data);
if (u8.length >= 4 &&
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
if (termState && termState.term) termState.term.write(u8.subarray(4));
return;
}
const view = new DataView(data);
const deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4);
@@ -1658,6 +1811,12 @@ inline std::string GetWebPageHTML() {
const ver = d.version || '-';
const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' +
'<button class="card-term-btn" title="Open Terminal" aria-label="Terminal" ' +
'onclick="event.stopPropagation();openTerminal(\'' + d.id + '\')">' +
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
'</svg>' +
'</button>' +
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
'<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
@@ -1895,7 +2054,10 @@ inline std::string GetWebPageHTML() {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
ws.send(JSON.stringify({ cmd: 'get_devices', token }));
}
)HTML";
// Part 8b1: Device connect / terminal session (split to avoid MSVC string literal length limit)
html += R"HTML(
function connectDevice(id) {
const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
@@ -1911,6 +2073,176 @@ inline std::string GetWebPageHTML() {
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
}
// ====== Web 终端xterm.js======
// 单设备单 web 终端:本地状态保留 deviceId、xterm 实例、fit-addon。
let termState = { deviceId: null, term: null, fit: null, ready: false };
function openTerminal(id) {
if (typeof Terminal === 'undefined') {
alert('Terminal library not loaded yet, please retry');
return;
}
const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) { alert('Device is offline'); return; }
// 已经有终端在跑:直接 show重连同设备视为重置
if (termState.deviceId && termState.deviceId !== String(id)) {
closeTerminal();
}
termState.deviceId = String(id);
termState.ready = false;
document.getElementById('term-title').textContent = dev.name + ' Terminal';
document.getElementById('term-status-info').textContent = 'Connecting...';
// 先 showPage 让 term-host 拿到真实尺寸xterm.open() 必须在容器有 size 时调用,
// 否则首次 fit 会算成 0 列 0 行,渲染异常 + 输入捕获也不灵。
showPage('term-page');
// 触屏 / 窄屏显示辅助按钮栏Tab/Esc/Ctrl+C/↑)
// 桌面浏览器有物理键盘,不需要这一行,节省屏幕空间
const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
const auxBar = document.getElementById('term-aux-bar');
if (isTouch || window.innerWidth <= 768) {
auxBar.classList.add('visible');
} else {
auxBar.classList.remove('visible');
}
// 用 requestAnimationFrame + 50ms 双重保险,确保 reflow 完成
requestAnimationFrame(() => setTimeout(() => {
if (!termState.term) {
termState.term = new Terminal({
cursorBlink: true,
fontFamily: 'Menlo, Consolas, "DejaVu Sans Mono", monospace',
fontSize: 13,
theme: { background: '#000000', foreground: '#e0e0e0' },
convertEol: true, // 将 \n 视为 \r\n兼容只发 LF 的程序)
scrollback: 5000
});
if (typeof FitAddon !== 'undefined') {
termState.fit = new FitAddon.FitAddon();
termState.term.loadAddon(termState.fit);
}
termState.term.open(document.getElementById('term-host'));
// 用户键入 → 发给 server
termState.term.onData(data => {
if (!termState.ready || !termState.deviceId) return;
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data, token }));
});
// 移动端:点击容器任意位置都把焦点拉回 xterm 的隐藏输入元素
document.getElementById('term-host').addEventListener('click', () => {
if (termState.term) termState.term.focus();
});
} else {
termState.term.clear();
}
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
termState.term.focus();
}, 30));
ws.send(JSON.stringify({ cmd: 'term_open', id: String(id), token }));
}
// 主动收起 iOS 软键盘blur xterm 的隐藏 textarea。iOS 没有原生关闭键盘按钮,
// 必须由我们提供一个,否则用户在终端里只能下拉浏览器关键盘。
function termHideKeyboard() {
if (termState.term) {
try { termState.term.blur(); } catch (e) {}
}
// 兜底:直接 blur 活动元素
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
}
// visualViewport 适配iOS 软键盘弹出时 layout viewport 不变,但 visualViewport.height 缩小。
// 把 term-page 的 padding-bottom 加大 = 键盘高度,挤压内容上移,辅助栏跟着浮在键盘正上方。
// 桌面浏览器 visualViewport 永远 = innerHeightbottomInset = 0这段是 no-op。
function adjustTermViewport() {
if (!window.visualViewport) return;
const page = document.getElementById('term-page');
if (!page || !page.classList.contains('active')) return;
const layoutH = window.innerHeight;
const visualH = window.visualViewport.height;
const offsetTop = window.visualViewport.offsetTop || 0;
const bottomInset = Math.max(0, Math.round(layoutH - visualH - offsetTop));
page.style.paddingBottom = bottomInset + 'px';
// 内容大小变了xterm 重 fit + 通知 server 调 PTY 尺寸
if (termState.fit) try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
}
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', adjustTermViewport);
window.visualViewport.addEventListener('scroll', adjustTermViewport);
}
// iOS 双指手势会缩放 / 平移 visual viewport把页面顶起 → Back 按钮跑出视野。
// viewport meta 的 user-scalable=no 在 iOS 10+ 已被忽略(无障碍考虑),必须用 JS
// 主动阻止双指 touchmove 和 gesture 事件。仅 term-page 激活时拦截screen-page 上
// 的双指 pinch-to-zoom 不受影响(既有交互保留)。
const __isTermActive = () => {
const p = document.getElementById('term-page');
return p && p.classList.contains('active');
};
document.addEventListener('touchmove', function(e) {
if (e.touches.length > 1 && __isTermActive()) {
e.preventDefault();
}
}, { passive: false });
['gesturestart', 'gesturechange', 'gestureend'].forEach(ev => {
document.addEventListener(ev, function(e) {
if (__isTermActive()) e.preventDefault();
}, { passive: false });
});
// 发送特殊按键到终端(手机辅助栏 onclick 调用)。直接走 ws不经 xterm避免 focus 抢夺)。
function termSendSpecial(name) {
if (!termState.ready || !termState.deviceId) return;
const seq = ({
tab: '\t',
esc: '\x1b',
ctrlc: '\x03', // ETX = Ctrl+C 信号
up: '\x1b[A', // ANSI 上方向键 = 历史命令
})[name];
if (!seq) return;
ws.send(JSON.stringify({ cmd: 'term_input', id: termState.deviceId, data: seq, token }));
if (termState.term) termState.term.focus(); // 按完辅助键自动把焦点拉回 xterm
}
function closeTerminal() {
if (termState.deviceId) {
ws.send(JSON.stringify({ cmd: 'term_close', id: termState.deviceId, token }));
}
termState.deviceId = null;
termState.ready = false;
// 离开终端页前清掉 visualViewport 留下的 padding-bottom避免下次切回时 stale
const page = document.getElementById('term-page');
if (page) page.style.paddingBottom = '';
showPage('devices-page');
}
)HTML";
// Part 8c: Terminal resize / pinch suppression (split to avoid MSVC string literal length limit)
html += R"HTML(
// 终端窗口大小变化 → 通知 server 调 PTY 尺寸(仅 PTY 模式有效,老 cmd 服务端会忽略)
function notifyTerminalResize() {
if (!termState.ready || !termState.deviceId || !termState.term) return;
const cols = termState.term.cols, rows = termState.term.rows;
ws.send(JSON.stringify({ cmd: 'term_resize', id: termState.deviceId, cols, rows, token }));
}
window.addEventListener('resize', () => {
if (termState.fit && document.getElementById('term-page').classList.contains('active')) {
try { termState.fit.fit(); notifyTerminalResize(); } catch (e) {}
}
});
)HTML";
// Part 9b: Fullscreen + control state (split to avoid MSVC string literal length limit)
html += R"HTML(
function toggleFullscreen() {
const el = document.getElementById('screen-page');
const isFs = document.fullscreenElement || document.webkitFullscreenElement;
@@ -3046,16 +3378,18 @@ inline std::string GetWebPageHTML() {
if (document.hidden) {
// Page going to background
const screenPage = document.getElementById('screen-page');
const termPage = document.getElementById('term-page');
const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice;
const onTermPage = termPage && termPage.classList.contains('active') && termState.deviceId;
if (onScreenPage) {
// Mobile/tablet: delay disconnect 30s
// Desktop: keep connection alive (no timer)
if (onScreenPage || onTermPage) {
// 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续;
// 桌面:保持长连,靠 ping 心跳
if (isTouchDevice) {
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
}
} else {
// Other pages - disconnect immediately
// 其它页面 - 立即断开省流量
doBackgroundDisconnect();
}
} else {