Feature: Web remote terminal (xterm.js + mobile UX polish)
This commit is contained in:
Binary file not shown.
@@ -4587,6 +4587,12 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
|
||||
if (!g_2015RemoteDlg || g_2015RemoteDlg->isClosed)
|
||||
return FALSE;
|
||||
|
||||
// Web 终端的 shell 子上下文断开:通知 WebService 清理 session(含通知前端)。
|
||||
// 在 RemoveFromHostList 之前做,避免 ctx 被释放后 WebService 还持有悬空指针。
|
||||
if (WebService().IsRunning() && WebService().IsTerminalContext(ContextObject)) {
|
||||
WebService().OnTerminalClosed(ContextObject);
|
||||
}
|
||||
|
||||
SOCKET nSocket = ContextObject->sClientSocket;
|
||||
|
||||
CDialogBase* p = (CDialogBase*)ContextObject->hDlg;
|
||||
@@ -4918,6 +4924,19 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
unsigned cmd = ContextObject->InDeCompressedBuffer.GetBYTE(0);
|
||||
LPBYTE szBuffer = ContextObject->InDeCompressedBuffer.GetBuffer();
|
||||
unsigned len = ContextObject->InDeCompressedBuffer.GetBufferLen();
|
||||
|
||||
// ===== Web 终端的 shell 子上下文:被 WebService 接管时,所有数据走 OnTerminalData =====
|
||||
// 这里覆盖一个特殊路径:Web 接管的 shell 子上下文不开 MFC 对话框,hDlg 一直为 NULL,
|
||||
// 因此每个数据包都会走到这个 MessageHandle。我们把字节直接转发给 WebService。
|
||||
if (WebService().IsRunning() && WebService().IsTerminalContext(ContextObject)) {
|
||||
if (len == 1 && cmd == TOKEN_TERMINAL_CLOSE) {
|
||||
WebService().OnTerminalClosed(ContextObject);
|
||||
} else {
|
||||
WebService().OnTerminalData(ContextObject, szBuffer, len);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 【L】:主机上下线和授权
|
||||
// 【x】:对话框相关功能
|
||||
switch (cmd) {
|
||||
@@ -5676,11 +5695,25 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
|
||||
g_2015RemoteDlg->SendMessage(WM_OPENTALKDIALOG, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
}
|
||||
case TOKEN_SHELL_START: { // Windows 远程终端
|
||||
case TOKEN_SHELL_START: { // Windows 老 cmd 管道终端
|
||||
// 如果是 Web 触发的 term_open,把 shell 子上下文交给 WebService 而不是开 MFC dialog
|
||||
uint64_t devId = ContextObject->GetClientID();
|
||||
if (WebService().IsRunning() && WebService().IsTermPending(devId)) {
|
||||
WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/false);
|
||||
// hDlg 留 NULL:后续数据继续走 MessageHandle 顶部的 IsTerminalContext 分支
|
||||
break;
|
||||
}
|
||||
g_2015RemoteDlg->SendMessage(WM_OPENSHELLDIALOG, 0, (LPARAM)ContextObject);
|
||||
break;
|
||||
}
|
||||
case TOKEN_TERMINAL_START: { // Linux PTY 终端 (WebView2 + xterm.js)
|
||||
case TOKEN_TERMINAL_START: { // 现代 PTY 终端 (Linux/macOS/Windows ConPTY)
|
||||
// 同上:Web 触发优先级最高,直接 WebService 接管
|
||||
uint64_t devId = ContextObject->GetClientID();
|
||||
if (WebService().IsRunning() && WebService().IsTermPending(devId)) {
|
||||
WebService().RegisterTerminalContext(devId, ContextObject, /*isPty*/true);
|
||||
break;
|
||||
}
|
||||
|
||||
// 三个前置条件,缺任何一个都回退到经典终端,并把原因贴到信息列表。
|
||||
// SYSTEM 场景:WebView2 不支持 LocalSystem token,会出现"窗口能弹但页面空白",
|
||||
// 显式拦截一次,避免用户误以为是 bug。
|
||||
|
||||
@@ -601,6 +601,11 @@
|
||||
<Image Include="res\update.bmp" />
|
||||
<Image Include="res\webcam.ico" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
|
||||
@@ -340,4 +340,9 @@
|
||||
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="res\web\xterm.min.js" />
|
||||
<None Include="res\web\xterm.css" />
|
||||
<None Include="res\web\fit.min.js" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "stdafx.h"
|
||||
#include "2015Remote.h"
|
||||
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
|
||||
#include "WebService.h"
|
||||
#include "WebServiceAuth.h"
|
||||
#include "2015RemoteDlg.h"
|
||||
@@ -18,6 +19,21 @@
|
||||
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
|
||||
// Load a Win32 BINARY resource by ID as std::string (raw bytes).
|
||||
// Returns empty string on failure. The std::string is OK to hold binary data
|
||||
// (we only treat it as bytes; size is from .length()).
|
||||
static std::string LoadBinaryResourceAsString(int resourceId) {
|
||||
HRSRC hRes = FindResourceA(NULL, MAKEINTRESOURCEA(resourceId), "BINARY");
|
||||
if (!hRes) return {};
|
||||
DWORD size = SizeofResource(NULL, hRes);
|
||||
if (!size) return {};
|
||||
HGLOBAL hData = LoadResource(NULL, hRes);
|
||||
if (!hData) return {};
|
||||
LPVOID p = LockResource(hData);
|
||||
if (!p) return {};
|
||||
return std::string(static_cast<const char*>(p), size);
|
||||
}
|
||||
|
||||
// Challenge-response nonce storage (prevents replay attacks)
|
||||
static std::map<void*, std::string> s_ClientNonces;
|
||||
static std::mutex s_NonceMutex;
|
||||
@@ -241,9 +257,30 @@ void CWebService::ServerThread(int port) {
|
||||
static std::string cachedHtml = GetWebPageHTML();
|
||||
std::string payloadsDir = m_PayloadsDir; // Capture for lambda
|
||||
|
||||
wsServer.onHttp([payloadsDir](const std::string& path) -> ws::HttpResponse {
|
||||
// 静态资源缓存:xterm.js / xterm.css / fit-addon。RC binary 资源加载一次缓存到内存,
|
||||
// 避免每个浏览器请求都去 LockResource。Cache-Control 给浏览器侧缓存友好。
|
||||
static std::string cachedXtermJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_JS);
|
||||
static std::string cachedXtermCss = LoadBinaryResourceAsString(IDR_WEB_XTERM_CSS);
|
||||
static std::string cachedXtermFitJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_FIT_JS);
|
||||
|
||||
auto buildStatic = [](const std::string& body, const std::string& mime) {
|
||||
ws::HttpResponse r = ws::HttpResponse::OK(body, mime);
|
||||
r.headers["Cache-Control"] = "public, max-age=86400"; // 1 day
|
||||
return r;
|
||||
};
|
||||
|
||||
wsServer.onHttp([payloadsDir, buildStatic](const std::string& path) -> ws::HttpResponse {
|
||||
if (path == "/" || path == "/index.html") {
|
||||
return ws::HttpResponse::OK(cachedHtml);
|
||||
} else if (path == "/static/xterm.js") {
|
||||
if (cachedXtermJs.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermJs, "application/javascript; charset=utf-8");
|
||||
} else if (path == "/static/xterm.css") {
|
||||
if (cachedXtermCss.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermCss, "text/css; charset=utf-8");
|
||||
} else if (path == "/static/xterm-fit.js") {
|
||||
if (cachedXtermFitJs.empty()) return ws::HttpResponse::NotFound();
|
||||
return buildStatic(cachedXtermFitJs, "application/javascript; charset=utf-8");
|
||||
} else if (path == "/health") {
|
||||
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
|
||||
} else if (path == "/manifest.json") {
|
||||
@@ -366,6 +403,14 @@ void CWebService::ServerThread(int port) {
|
||||
HandleListUsers(ws_ptr, token);
|
||||
} else if (cmd == "get_groups") {
|
||||
HandleGetGroups(ws_ptr, token);
|
||||
} else if (cmd == "term_open") {
|
||||
HandleTermOpen(ws_ptr, msg);
|
||||
} else if (cmd == "term_input") {
|
||||
HandleTermInput(ws_ptr, msg);
|
||||
} else if (cmd == "term_resize") {
|
||||
HandleTermResize(ws_ptr, msg);
|
||||
} else if (cmd == "term_close") {
|
||||
HandleTermClose(ws_ptr, msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1163,6 +1208,16 @@ void CWebService::UnregisterClient(void* ws_ptr) {
|
||||
if (device_id > 0) {
|
||||
StopRemoteDesktop(device_id);
|
||||
}
|
||||
|
||||
// 关闭这个 web client 持有的所有终端会话(MVP 一个用户一台主机一个,但兜底全扫描)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
std::vector<uint64_t> to_close;
|
||||
for (auto& kv : m_TermSessions) {
|
||||
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
|
||||
}
|
||||
for (uint64_t did : to_close) CloseTermSessionLocked(did);
|
||||
}
|
||||
}
|
||||
|
||||
WebClient* CWebService::FindClient(void* ws_ptr) {
|
||||
@@ -1716,6 +1771,255 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) {
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Web Terminal Session
|
||||
//
|
||||
// 数据流向:
|
||||
// 浏览器 ── term_open ──► HandleTermOpen ── COMMAND_SHELL ──► 客户端
|
||||
// 客户端 ── shell 子上下文 ──► MessageHandle TOKEN_TERMINAL_START
|
||||
// ── RegisterTerminalContext ──► WebService
|
||||
// 客户端 shell 输出 ── TOKEN_TERMINAL_DATA ──► MessageHandle ──► OnTerminalData
|
||||
// ──► term_output 给浏览器
|
||||
// 浏览器 keystrokes ── term_input ──► HandleTermInput ──► shell_ctx->Send2Client
|
||||
//
|
||||
// 一台主机最多一个 web 终端会话(MVP)。
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
static std::string BuildTermJson(const std::string& cmd, std::initializer_list<std::pair<const char*, std::string>> kv) {
|
||||
Json::Value v;
|
||||
v["cmd"] = cmd;
|
||||
for (auto& p : kv) v[p.first] = p.second;
|
||||
Json::StreamWriterBuilder b; b["indentation"] = "";
|
||||
return Json::writeString(b, v);
|
||||
}
|
||||
|
||||
void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Invalid token"));
|
||||
return;
|
||||
}
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
if (!device_id || !m_pParentDlg) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Bad request"));
|
||||
return;
|
||||
}
|
||||
context* ctx = m_pParentDlg->FindHost(device_id);
|
||||
if (!ctx) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Group permission check (admin 全部可见)
|
||||
if (username != "admin") {
|
||||
std::string g = ctx->GetGroupName(); if (g.empty()) g = "default";
|
||||
bool ok = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_UsersMutex);
|
||||
for (auto& u : m_Users) if (u.username == username) {
|
||||
for (auto& ag : u.allowed_groups) if (ag == g) { ok = true; break; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 占用:MVP 阶段单设备单 web 终端
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
|
||||
m_TermPending.find(device_id) != m_TermPending.end()) {
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
|
||||
"Terminal already open by another viewer"));
|
||||
return;
|
||||
}
|
||||
WebTermSession s; s.ws_ptr = ws_ptr; s.device_id = device_id;
|
||||
s.shell_ctx = nullptr; s.is_pty = false;
|
||||
m_TermSessions[device_id] = s;
|
||||
m_TermPending.insert(device_id);
|
||||
}
|
||||
|
||||
// 触发客户端:发 COMMAND_SHELL
|
||||
BYTE cmd = COMMAND_SHELL;
|
||||
if (!ctx->Send2Client(&cmd, 1)) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
m_TermSessions.erase(device_id);
|
||||
m_TermPending.erase(device_id);
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
|
||||
return;
|
||||
}
|
||||
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
|
||||
}
|
||||
|
||||
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
std::string data = root.get("data", "").asString();
|
||||
if (!device_id || data.empty()) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
shellCtx = it->second.shell_ctx;
|
||||
}
|
||||
if (!shellCtx) return; // shell 子上下文还没就绪
|
||||
shellCtx->Send2Client((BYTE*)data.data(), (ULONG)data.size());
|
||||
}
|
||||
|
||||
void CWebService::HandleTermResize(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string token = root.get("token", "").asString();
|
||||
std::string username, role;
|
||||
if (!ValidateToken(token, username, role)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
int cols = root.get("cols", 0).asInt();
|
||||
int rows = root.get("rows", 0).asInt();
|
||||
if (!device_id || cols <= 0 || rows <= 0) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = nullptr;
|
||||
bool isPty = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
shellCtx = it->second.shell_ctx;
|
||||
isPty = it->second.is_pty;
|
||||
}
|
||||
if (!shellCtx || !isPty) return; // 老 cmd 模式不支持 resize
|
||||
|
||||
BYTE buf[5];
|
||||
buf[0] = CMD_TERMINAL_RESIZE;
|
||||
*(short*)(buf + 1) = (short)cols;
|
||||
*(short*)(buf + 3) = (short)rows;
|
||||
shellCtx->Send2Client(buf, 5);
|
||||
}
|
||||
|
||||
void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
|
||||
Json::Value root; Json::Reader rdr;
|
||||
if (!rdr.parse(msg, root)) return;
|
||||
std::string id_str = root.get("id", "").asString();
|
||||
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
|
||||
if (!device_id) return;
|
||||
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
|
||||
CloseTermSessionLocked(device_id);
|
||||
}
|
||||
|
||||
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end()) return;
|
||||
|
||||
CONTEXT_OBJECT* shellCtx = it->second.shell_ctx;
|
||||
void* ws_ptr = it->second.ws_ptr;
|
||||
m_TermSessions.erase(it);
|
||||
m_TermPending.erase(device_id);
|
||||
if (shellCtx) {
|
||||
m_TermContextToDevice.erase(shellCtx);
|
||||
// 触发客户端 shell 退出:直接断该子上下文
|
||||
// (老 ShellDlg 是直接 Send TOKEN_BYE 之类,但断开更可靠)
|
||||
shellCtx->CancelIO();
|
||||
}
|
||||
// 通知前端
|
||||
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
|
||||
Mprintf("[WebService] term_closed device=%llu\n", device_id);
|
||||
}
|
||||
|
||||
bool CWebService::IsTermPending(uint64_t device_id) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
return m_TermPending.find(device_id) != m_TermPending.end();
|
||||
}
|
||||
|
||||
void CWebService::RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty) {
|
||||
void* ws_ptr_to_notify = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermSessions.find(device_id);
|
||||
if (it == m_TermSessions.end()) return; // 没有等的 web session 了
|
||||
it->second.shell_ctx = ctx;
|
||||
it->second.is_pty = isPty;
|
||||
m_TermContextToDevice[ctx] = device_id;
|
||||
m_TermPending.erase(device_id);
|
||||
ws_ptr_to_notify = it->second.ws_ptr;
|
||||
}
|
||||
|
||||
// 关键步骤:告知客户端"启动 shell 输出回流"。
|
||||
// PTY 模式:客户端 PTYHandler 已 fork 了 shell 子进程,但读线程要靠 COMMAND_NEXT 才启动
|
||||
// (参考 TerminalDlg::OnTerminalReady)。漏发会导致 shell 在跑但输出永远不送回。
|
||||
// PTY 还要先告知初始 cols/rows(默认 80x24),否则 shell 会按 PTY 默认尺寸渲染,
|
||||
// vim 等 TUI 在浏览器侧的 fit 调整前会乱。后续浏览器 term_resize 会再调整。
|
||||
if (isPty) {
|
||||
BYTE resizeBuf[5];
|
||||
resizeBuf[0] = CMD_TERMINAL_RESIZE;
|
||||
*(short*)(resizeBuf + 1) = (short)80;
|
||||
*(short*)(resizeBuf + 3) = (short)24;
|
||||
ctx->Send2Client(resizeBuf, 5);
|
||||
}
|
||||
BYTE startCmd = COMMAND_NEXT;
|
||||
ctx->Send2Client(&startCmd, 1);
|
||||
|
||||
// 通知前端 ready,告知模式(pty / legacy)
|
||||
if (ws_ptr_to_notify) {
|
||||
SendText(ws_ptr_to_notify, BuildTermJson("term_ready", {{"mode", isPty ? "pty" : "legacy"}}));
|
||||
}
|
||||
Mprintf("[WebService] term_ready device=%llu mode=%s\n",
|
||||
device_id, isPty ? "pty" : "legacy");
|
||||
}
|
||||
|
||||
bool CWebService::IsTerminalContext(CONTEXT_OBJECT* ctx) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
return m_TermContextToDevice.find(ctx) != m_TermContextToDevice.end();
|
||||
}
|
||||
|
||||
void CWebService::OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len) {
|
||||
void* ws_ptr = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermContextToDevice.find(ctx);
|
||||
if (it == m_TermContextToDevice.end()) return;
|
||||
auto sit = m_TermSessions.find(it->second);
|
||||
if (sit == m_TermSessions.end()) return;
|
||||
ws_ptr = sit->second.ws_ptr;
|
||||
}
|
||||
if (!ws_ptr || !data || !len) return;
|
||||
|
||||
// 用 binary frame 透传字节流(避免 JSON 二进制不可见字符 / 编码膨胀)。
|
||||
// 帧格式:[4B magic 'TRM1'][N=payload]
|
||||
// 4 字节 magic = 0x54 0x52 0x4D 0x31 —— 视频帧首 4 字节是 deviceID(uint32 LE),
|
||||
// 撞这个具体值 (0x314D5254) 的概率极低,浏览器侧据此安全分流。
|
||||
std::vector<uint8_t> packet;
|
||||
packet.reserve(len + 4);
|
||||
packet.push_back('T'); packet.push_back('R'); packet.push_back('M'); packet.push_back('1');
|
||||
packet.insert(packet.end(), data, data + len);
|
||||
SendBinary(ws_ptr, packet.data(), packet.size());
|
||||
}
|
||||
|
||||
void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
|
||||
std::lock_guard<std::mutex> lk(m_TermMutex);
|
||||
auto it = m_TermContextToDevice.find(ctx);
|
||||
if (it == m_TermContextToDevice.end()) return;
|
||||
uint64_t device_id = it->second;
|
||||
CloseTermSessionLocked(device_id);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// Screen Context Registry (for mouse/keyboard control)
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -247,6 +247,27 @@ public:
|
||||
void UnregisterScreenContext(uint64_t device_id);
|
||||
CONTEXT_OBJECT* GetScreenContext(uint64_t device_id);
|
||||
|
||||
// ========== Web Terminal (Phase 1: 1 user per device) ==========
|
||||
// Web 终端会话桥:把浏览器端 xterm.js ↔ 客户端 shell 子上下文连起来。
|
||||
// 设计:每台主机最多一个 Web 终端会话;如果别的浏览器请求同一台主机的终端,
|
||||
// 拒绝(UX 上后续可改成共享只读)。
|
||||
// 生命周期:term_open → COMMAND_SHELL → 客户端建子上下文 → MessageHandle
|
||||
// 看到 TOKEN_TERMINAL_START / TOKEN_SHELL_START + IsTermPending(d) →
|
||||
// 调 RegisterTerminalContext 接管,跳过 MFC dialog 打开。
|
||||
|
||||
// 浏览器侧入口
|
||||
void HandleTermOpen(void* ws_ptr, const std::string& msg);
|
||||
void HandleTermInput(void* ws_ptr, const std::string& msg);
|
||||
void HandleTermResize(void* ws_ptr, const std::string& msg);
|
||||
void HandleTermClose(void* ws_ptr, const std::string& msg);
|
||||
|
||||
// MessageHandle 向 WebService 询问 / 移交的钩子
|
||||
bool IsTermPending(uint64_t device_id); // 决定是否要拦截 dialog 打开
|
||||
void RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty);
|
||||
bool IsTerminalContext(CONTEXT_OBJECT* ctx); // 是否是 Web 终端持有的上下文
|
||||
void OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len);// 把 shell 输出泵到对应 web client
|
||||
void OnTerminalClosed(CONTEXT_OBJECT* ctx); // shell 子上下文断开时清理
|
||||
|
||||
private:
|
||||
// Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT
|
||||
std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts;
|
||||
@@ -255,6 +276,21 @@ private:
|
||||
// MFC triggered devices: dialogs created by MFC should always be visible
|
||||
std::set<uint64_t> m_MfcTriggeredDevices;
|
||||
std::mutex m_MfcTriggeredMutex;
|
||||
|
||||
// Web 终端会话状态
|
||||
struct WebTermSession {
|
||||
void* ws_ptr; // browser WebSocket
|
||||
uint64_t device_id;
|
||||
CONTEXT_OBJECT* shell_ctx; // shell 子上下文(首条消息抵达后才填)
|
||||
bool is_pty; // true=TOKEN_TERMINAL(现代 PTY), false=TOKEN_SHELL(老 cmd 管道)
|
||||
};
|
||||
std::map<uint64_t, WebTermSession> m_TermSessions; // by device_id
|
||||
std::map<CONTEXT_OBJECT*, uint64_t> m_TermContextToDevice; // 反查 ctx → device_id
|
||||
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
|
||||
std::mutex m_TermMutex;
|
||||
|
||||
// 内部清理(已持锁版本)
|
||||
void CloseTermSessionLocked(uint64_t device_id);
|
||||
};
|
||||
|
||||
// Global accessor
|
||||
|
||||
8
server/2015Remote/res/web/fit.min.js
vendored
Normal file
8
server/2015Remote/res/web/fit.min.js
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
209
server/2015Remote/res/web/xterm.css
Normal file
209
server/2015Remote/res/web/xterm.css
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
8
server/2015Remote/res/web/xterm.min.js
vendored
Normal file
8
server/2015Remote/res/web/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -259,6 +259,9 @@
|
||||
#define IDI_ICON_SNAPSHOT 376
|
||||
#define IDR_LANG_EN_US 380
|
||||
#define IDR_LANG_ZH_TW 381
|
||||
#define IDR_WEB_XTERM_JS 382
|
||||
#define IDR_WEB_XTERM_CSS 383
|
||||
#define IDR_WEB_XTERM_FIT_JS 384
|
||||
#define IDC_MESSAGE 1000
|
||||
#define IDC_ONLINE 1001
|
||||
#define IDC_STATIC_TIPS 1002
|
||||
@@ -985,7 +988,7 @@
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NEXT_RESOURCE_VALUE 377
|
||||
#define _APS_NEXT_RESOURCE_VALUE 385
|
||||
#define _APS_NEXT_COMMAND_VALUE 33051
|
||||
#define _APS_NEXT_CONTROL_VALUE 2542
|
||||
#define _APS_NEXT_SYMED_VALUE 105
|
||||
|
||||
Reference in New Issue
Block a user