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

Binary file not shown.

View File

@@ -4587,6 +4587,12 @@ BOOL CALLBACK CMy2015RemoteDlg::OfflineProc(CONTEXT_OBJECT* ContextObject)
if (!g_2015RemoteDlg || g_2015RemoteDlg->isClosed) if (!g_2015RemoteDlg || g_2015RemoteDlg->isClosed)
return FALSE; return FALSE;
// Web 终端的 shell 子上下文断开:通知 WebService 清理 session含通知前端
// 在 RemoveFromHostList 之前做,避免 ctx 被释放后 WebService 还持有悬空指针。
if (WebService().IsRunning() && WebService().IsTerminalContext(ContextObject)) {
WebService().OnTerminalClosed(ContextObject);
}
SOCKET nSocket = ContextObject->sClientSocket; SOCKET nSocket = ContextObject->sClientSocket;
CDialogBase* p = (CDialogBase*)ContextObject->hDlg; CDialogBase* p = (CDialogBase*)ContextObject->hDlg;
@@ -4918,6 +4924,19 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
unsigned cmd = ContextObject->InDeCompressedBuffer.GetBYTE(0); unsigned cmd = ContextObject->InDeCompressedBuffer.GetBYTE(0);
LPBYTE szBuffer = ContextObject->InDeCompressedBuffer.GetBuffer(); LPBYTE szBuffer = ContextObject->InDeCompressedBuffer.GetBuffer();
unsigned len = ContextObject->InDeCompressedBuffer.GetBufferLen(); 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】主机上下线和授权 // 【L】主机上下线和授权
// 【x】对话框相关功能 // 【x】对话框相关功能
switch (cmd) { switch (cmd) {
@@ -5676,11 +5695,25 @@ VOID CMy2015RemoteDlg::MessageHandle(CONTEXT_OBJECT* ContextObject)
g_2015RemoteDlg->SendMessage(WM_OPENTALKDIALOG, 0, (LPARAM)ContextObject); g_2015RemoteDlg->SendMessage(WM_OPENTALKDIALOG, 0, (LPARAM)ContextObject);
break; 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); g_2015RemoteDlg->SendMessage(WM_OPENSHELLDIALOG, 0, (LPARAM)ContextObject);
break; 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会出现"窗口能弹但页面空白" // SYSTEM 场景WebView2 不支持 LocalSystem token会出现"窗口能弹但页面空白"
// 显式拦截一次,避免用户误以为是 bug。 // 显式拦截一次,避免用户误以为是 bug。

View File

@@ -601,6 +601,11 @@
<Image Include="res\update.bmp" /> <Image Include="res\update.bmp" />
<Image Include="res\webcam.ico" /> <Image Include="res\webcam.ico" />
</ItemGroup> </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" /> <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets"> <ImportGroup Label="ExtensionTargets">
</ImportGroup> </ImportGroup>

View File

@@ -340,4 +340,9 @@
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier> <UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
</Filter> </Filter>
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="res\web\xterm.min.js" />
<None Include="res\web\xterm.css" />
<None Include="res\web\fit.min.js" />
</ItemGroup>
</Project> </Project>

View File

@@ -22,6 +22,8 @@ inline std::string GetWebPageHTML() {
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e"> <meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="/manifest.json"> <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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"> <link rel="icon" type="image/png" href="data:image/png;base64,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">
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -470,6 +472,104 @@ inline std::string GetWebPageHTML() {
border-color: rgba(233, 69, 96, 0.3); border-color: rgba(233, 69, 96, 0.3);
} }
.device-card:hover::before { opacity: 1; } .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 { .device-card h3 {
color: #fff; color: #fff;
font-size: 16px; 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"> <input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
</div> </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 --> <!-- User Management Modal -->
<div class="modal-overlay" id="users-modal"> <div class="modal-overlay" id="users-modal">
<div class="modal-content"> <div class="modal-content">
@@ -1131,6 +1251,12 @@ inline std::string GetWebPageHTML() {
</div> </div>
)HTML"; )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 // Part 7: JavaScript - State and WebSocket
html += R"HTML( html += R"HTML(
<script> <script>
@@ -1344,6 +1470,24 @@ inline std::string GetWebPageHTML() {
setTimeout(() => showPage('devices-page'), 2000); setTimeout(() => showPage('devices-page'), 2000);
} }
break; 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': case 'disconnect_result':
// disconnect() already handles navigation, this is just server acknowledgment // disconnect() already handles navigation, this is just server acknowledgment
// No action needed - prevents race conditions when switching devices // 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 let decodeTimestamp = 0; // Monotonically increasing timestamp for decoder
function handleBinaryFrame(data) { 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 view = new DataView(data);
const deviceId = view.getUint32(0, true); const deviceId = view.getUint32(0, true);
const frameType = view.getUint8(4); const frameType = view.getUint8(4);
@@ -1658,6 +1811,12 @@ inline std::string GetWebPageHTML() {
const ver = d.version || '-'; const ver = d.version || '-';
const activeWin = d.activeWindow || ''; const activeWin = d.activeWindow || '';
return '<div class="device-card" onclick="connectDevice(\'' + d.id + '\')">' + 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>' + '<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
'<div class="info-row">' + '<div class="info-row">' +
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' + '<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; if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
ws.send(JSON.stringify({ cmd: 'get_devices', token })); 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) { function connectDevice(id) {
const compat = checkWebCodecs(); const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; } 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 })); 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() { function toggleFullscreen() {
const el = document.getElementById('screen-page'); const el = document.getElementById('screen-page');
const isFs = document.fullscreenElement || document.webkitFullscreenElement; const isFs = document.fullscreenElement || document.webkitFullscreenElement;
@@ -3046,16 +3378,18 @@ inline std::string GetWebPageHTML() {
if (document.hidden) { if (document.hidden) {
// Page going to background // Page going to background
const screenPage = document.getElementById('screen-page'); const screenPage = document.getElementById('screen-page');
const termPage = document.getElementById('term-page');
const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice; const onScreenPage = screenPage && screenPage.classList.contains('active') && currentDevice;
const onTermPage = termPage && termPage.classList.contains('active') && termState.deviceId;
if (onScreenPage) { if (onScreenPage || onTermPage) {
// Mobile/tablet: delay disconnect 30s // 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续;
// Desktop: keep connection alive (no timer) // 桌面:保持长连,靠 ping 心跳
if (isTouchDevice) { if (isTouchDevice) {
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE); backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
} }
} else { } else {
// Other pages - disconnect immediately // 其它页面 - 立即断开省流量
doBackgroundDisconnect(); doBackgroundDisconnect();
} }
} else { } else {

View File

@@ -1,5 +1,6 @@
#include "stdafx.h" #include "stdafx.h"
#include "2015Remote.h" #include "2015Remote.h"
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
#include "WebService.h" #include "WebService.h"
#include "WebServiceAuth.h" #include "WebServiceAuth.h"
#include "2015RemoteDlg.h" #include "2015RemoteDlg.h"
@@ -18,6 +19,21 @@
#pragma comment(lib, "ws2_32.lib") #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) // Challenge-response nonce storage (prevents replay attacks)
static std::map<void*, std::string> s_ClientNonces; static std::map<void*, std::string> s_ClientNonces;
static std::mutex s_NonceMutex; static std::mutex s_NonceMutex;
@@ -241,9 +257,30 @@ void CWebService::ServerThread(int port) {
static std::string cachedHtml = GetWebPageHTML(); static std::string cachedHtml = GetWebPageHTML();
std::string payloadsDir = m_PayloadsDir; // Capture for lambda 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") { if (path == "/" || path == "/index.html") {
return ws::HttpResponse::OK(cachedHtml); 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") { } else if (path == "/health") {
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json"); return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
} else if (path == "/manifest.json") { } else if (path == "/manifest.json") {
@@ -366,6 +403,14 @@ void CWebService::ServerThread(int port) {
HandleListUsers(ws_ptr, token); HandleListUsers(ws_ptr, token);
} else if (cmd == "get_groups") { } else if (cmd == "get_groups") {
HandleGetGroups(ws_ptr, token); 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) { if (device_id > 0) {
StopRemoteDesktop(device_id); 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) { 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 字节是 deviceIDuint32 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) // Screen Context Registry (for mouse/keyboard control)
////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////

View File

@@ -247,6 +247,27 @@ public:
void UnregisterScreenContext(uint64_t device_id); void UnregisterScreenContext(uint64_t device_id);
CONTEXT_OBJECT* GetScreenContext(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: private:
// Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT // Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT
std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts; std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts;
@@ -255,6 +276,21 @@ private:
// MFC triggered devices: dialogs created by MFC should always be visible // MFC triggered devices: dialogs created by MFC should always be visible
std::set<uint64_t> m_MfcTriggeredDevices; std::set<uint64_t> m_MfcTriggeredDevices;
std::mutex m_MfcTriggeredMutex; 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 // Global accessor

8
server/2015Remote/res/web/fit.min.js vendored Normal file
View 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

View 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;
}

File diff suppressed because one or more lines are too long

View File

@@ -259,6 +259,9 @@
#define IDI_ICON_SNAPSHOT 376 #define IDI_ICON_SNAPSHOT 376
#define IDR_LANG_EN_US 380 #define IDR_LANG_EN_US 380
#define IDR_LANG_ZH_TW 381 #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_MESSAGE 1000
#define IDC_ONLINE 1001 #define IDC_ONLINE 1001
#define IDC_STATIC_TIPS 1002 #define IDC_STATIC_TIPS 1002
@@ -985,7 +988,7 @@
// //
#ifdef APSTUDIO_INVOKED #ifdef APSTUDIO_INVOKED
#ifndef APSTUDIO_READONLY_SYMBOLS #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_COMMAND_VALUE 33051
#define _APS_NEXT_CONTROL_VALUE 2542 #define _APS_NEXT_CONTROL_VALUE 2542
#define _APS_NEXT_SYMED_VALUE 105 #define _APS_NEXT_SYMED_VALUE 105