Feature: Web remote terminal (xterm.js + mobile UX polish)
This commit is contained in:
@@ -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 兜底:万一发生页面 scroll,Back 按钮也始终钉在顶部 */
|
||||
#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')">↑</button>
|
||||
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">▼</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/rows,PTY 模式下 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 永远 = innerHeight,bottomInset = 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 {
|
||||
|
||||
Reference in New Issue
Block a user