Files
SimpleRemoter/server/web/index.html
yuanyuanxiang cd43caafb2 Fix: Web remote desktop reliability and UX
- Server: clamp web session adaptive quality to H264-only levels (>=Good) in EvaluateQuality and ApplyQualityLevel; Ultra/High (DIFF/RGB565) caused the browser to freeze ~1 min into a session
- Server: move session-type detection to the top of ScreenSpyDlg::OnInitDialog and skip SetWindowPlacement/EnterFullScreen for hidden web sessions, eliminating the MFC dialog flash on web-triggered opens
- Linux client: default QualityLevel from QUALITY_ADAPTIVE to QUALITY_GOOD to match Windows/macOS so the server's adaptive controller doesn't auto-upgrade to non-H264 algorithms
- Web: clear the floating quick-action toolbar on fullscreen exit so its row of buttons (RDP reset / Mouse / Close) doesn't stay pinned to the top of the page
- Web: route F11 to the remote in control mode instead of toggling local fullscreen
- Web: route Esc to the remote in control mode via the Keyboard Lock API instead of exiting native fullscreen
2026-05-19 18:39:16 +02:00

3610 lines
171 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>SimpleRemoter</title>
<!-- PWA / iOS Standalone App Support -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Remoter">
<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,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAA0MSURBVFhHNZfnV1TnFof5G7xeoyBKG6p0ELBSRMFoVGyRgBqMscVYEq+aoldIjCVivxq7JsYOYkOadUDq9MZ0GDpDr2qynrvmYD781nvWOh/2s/dv7/2e4/Tu3TtaW9vp6Oihp2eAvr5B+nuHGOhxqJ/e7i76urvp7eqiu6ODzhY7rXVNNFptdLV20GZrwaw10Giqx6o3Y601Y1TXopWrqVVo0EiV1Cq1KKqkSGQ1lFe+QSKXoNGp6R/sw6mttYPc3wt4eu8VWokRk9qKVWOhXluHUW7AIKlFVa5AW6lGVSqj/NEznt1+zIm9h7h3/gYFNx5y8eApdq/dSvbunzm8fQ8Htu9hW9p6vly8kq/SviR9/qfMj01mduwskhPmkJQwmzWfZ2DQ63Hqsvfw+FYJl49cp6ygEr3UhElpQi/Vo6vWoqtQoX2jQFpSjvx5FdX5Yoqv5XJ02z52pn/F9SPnuZh5nM3z0tm6+HO+TV3Pl0nLSJ05n0Uxs5nmF0FCyFRC3PzxHueO25gJjB01ltlxiShkcpy67b1UPpfy6PdCnt9/ja7agFlZh0lhxiAzYKjRYqjWohHLkL+oRl5SQXlOEfeOX+H7zzZxdHsm17JOkr3pR9JjF5Aev5CMhPmkTU9mXngs0/zCiPYKZrJHIKET/AhyEeE+2pkFickoHQD9PYNISxWUF0l49fANilI1BqkZs2IEwijRYazWCqotV6J+KaEy7xn5F+9wZsd+9qVu5syOLH7bmcWOpRksDI/j84RPWBoZR3LgVBIDowh19SHCLYBQV18BZNI4N5JmxKFRqnAa7B9CJ6+l5rWCktxSiu8+Q1WqweiAUJowyfSYarSYanQYqrRoXlVRlfuE/LNXyT3yGyc27uLIF1s4+PlGMj9by6fRs1iTsICMGcksmTyT2YERTPcJIeAjd4LGezPdO4gpoknMm5mARqHEabBvELPWgqZaz4s8MbkX7iN+VIbmTS31KjN1ajP6ajXalzWonouRPS5B8rCQN7fyeHzqIn/8cIAjazbzS/o6MlPXsiZ+ActjZrEhaRFrZiaT4BNMYtBkotz98RszkWjPAAEgZVYyKocFvZ29GJQGLKo6pC8U5F16QNHNYiqevkH3Ro2xSob2RRm1r6qwVCowVirQlStQiWsQ33lE7q+nufr9T/yyeiOZK9ezLimFOYFRrJo1n4yEj1kSMY3EgHBiAyLwGe1KoKtIAFg1b/GIBcN9g9S8rhI6X1dZS9GtEopuFPLy5iPEtx6hL5VhkeqokxuxqkyYFQZUYgk1RWWIbz8gL/s01/bs5/iG7fxnwaesnp7I3JBokoLCWRY9g0VhU5jlF0K0px/+H7nh8S9nwtzcSU2ah1atwWloYAiT0oC0VIa+xkBNSTVPL92lLKcYWXE5unLlSANWqFCXSdGWK5CVlFN29wHPrt3i3uFjnNv8LccyvmRH0idsiJ9L2tR4ZvsGE+8dQJJ/CDM8fIgc706QsxuiUWMJGOfGkoRkNKoPTdhktAnLxwHgCFp6t5BXtwuQFpShEUvRV6qFXaAtk6MplVJdWErR5dvkHDvLjT2HOLJ6E3sWLWNb4jwyps1iWeQM4n2DiHL1ZJqHL1ETvAj+aDwBY1zxGjUW91FjmReXiEqmwKm/d4AmUwONBhsWmRFFUTmywje8uP6QivvPUD2vpqZQzOvcQp79mUfRpZvknbjAxZ1ZHF+7lUNp69k2awGbYz9m44wkloRPISlwMjO9Q4ic6EvERB8i3XwJcvZANMYV0b9dEP17HHPjEkemYKB3QMi+QV+PRaZHViBG8lRMaU4hD87+Se7xc9zOOsKZLd/x6xdb2btkNbs+Xs6mhIWsio5lWUgMSb7hzPYNIdE3mDjHmPmEEiEKJcjVVxi9YHd/Ahyb0NkLXxcv3EY7Mzd+NgqpDKe+7j708lpsOit1Ch3yAjHl94vJP/sHR9bsYE/q1xzadoTv1/3Mnh/Pkrbia5YtXk9UWBJezgF4TwjE08Uf17GejBvlgttH7ni7ByFyCcDPLRg/t0kEi4KZ5BmEl4sINxdPPFw8iZsai0quHLFAL9FiU5mpV+iQFryi8NRlzn1zgN1LNrH7iz2cPVfI/ccyjp4rYOGSbUTFzCcyLBlfjxBBE5x9Ge8iYvzYiYwd5YKvR/DIO/cgQvyiCfIOJdgnjEBRCG4fIBJnzEJaLcFpoKsPY41GkLlKRuWt+9zZc4D9GzLZvmATx/Zd4sGjKuT6Tg6fLWTOnA3Mjl9B7LTFhPjFIJoYiPv4ANxdfPFx9cF19Fg8XdwIEoUIAf09ggkPmEzEpChC/SLwc5+Em7Mnc+KS0Kq0OA1292OQqKmtUWGsqOb5oSzOrtvO1/M38u2izfxx8h5yZT11tj4OHc0jac5XfJayhaQZS5gSlsxHo90ZN06Eq6sPARNE+DlPxG/cBEI9vAny8CNIFEpUyBRiQqYS6h+J38RJeDl7kTI3BblE/gFAqsYs12GWyKi+fJLsz9azbsYKflyyjdzT99DrG2hr7+XK+fssX7iDVQs3snL+KuInf8K4cb6MGeOBs4sv4R5+hLl5EOY6kZk+IqLdvYjw9CM5KoG44CnE+IQT6RFMXFAMKxelotfocRruHsAs01KnMtBiqMMgFvNw/3F+W7ebk+k7uLP7BIrXcuwd3ahlBtYt3cLKxKWsT0rh44AwJrl64zLaFR+3AOIDI5nm7cdMby9SIoKI8xURI/JnUdR0koOmkOg/mZToRJZOSWJL6hdoVRqcBnscY2igQWvGbmmiUWdFIa5CWyGl8tZD7mzZx9PMC1g1Zpqb7Fw/+wcrExayemoC6VHTWBQYSpynF9NFImZ6BzDLN4DF4UF8PjWcEGdn4v1DmBsUybKoeNYmprAhaSlpsUlsTVuFxWRwbMJhbHoLjQYrHfUtdNpaaTHZ6Ghup7PJjuFVGcWZJym7lkdttQqDysLJvYdZGTuP9JiZrI2ewsJJwXzsP4klkeGsiA4nfepkprpNJMbDi7TImWyMn8+ulJXsmL+CrUmL2J6SSvbO77AYDTgNDwzTaKynxdJAZ2MbvS0d9DR3CAC99m562zqpr5JSdv5P5PmlaEsVaKr03D13l00pGSyPmcGi0EiWTY5ieXQ0KeFhxEzwYH5oDD+kruPYpu/JXJ7B3qVpHMzYwPFtuzi5/TuuHsqmucE2chc0m220mEcAuu1dghxfvB0tdrrbu+i1d9GqNiLPKUCWX4FFWYdF24jklZpbF5+wf/cpvlr5DVvStrJ30w+cyzrNjV8vc2nXT2SvWsvpjZu5/ONP3Dh8jOuHjnIt82fuHT9FY50Vp8GBYdqsjQKE3dZCrxCwm562LuzN7SNWtNjpaemgo64JS2kV8vslVN/JR/2imnpDM7VKK8rqWqqe1fD89lNyDp7i+s7/8vuPB7h7+ARF127w4mYOhdducu/oKa5l7ePmkWzaHBUYGhim1dpIo7GOjoZW+uzd9HX0COps7RAg2hta6LC1CupubKfV3IClRonk9iNenDhP8ekrPDlwmrx92Tw+eJLC/12l+M+HvMwp5OW9J4hzn/DyZg75F65x+5dfuLRrF7eys7GZTDgN9Q8L/jebbLTbWoTMBe8/6B8AR3Ucaq9vpsvWSlt988jPiNqAWa5FV6VAXSFDUylHUyFH/kZKeeFrXt64R/G58zw5cYq7mVlc3LaF0+vWc+Gb/9BoseL0buCtANBkqhcq0flP832oRFeLHXtDqxC4ra6JJmMdDQYrtlozNo2JBp2Zeo0Jk8SxztXoq5QoX1UgeVKC+OoNCg8eIm/vHu7s/o7L6zZwdNly9i9M4cLWndSbjCMADXqrsIgaay1CZt1tnUJwB4SjCR090PkBotVkE+yyaR2XVy0WqRZTeQ3Gl2VoCl+gyHlC5fW7lJ65SH7Wz9zZ9i1X1qzlbOoqji/+lGMrVnEsPYOrP+yjvbERp+H+YZp0ZqxVKmxqI23WJqEKjuZzqKe9S1Bns12woM3cIAA06a006izUK/WYqhTUvihH/rCIyus5vD5zheKD2Tz8IYs7jpH7cjO/fbaak5+u5MKm7Vz59jseHz9Da0MDTkN9g9gUtRjfyIWMHCV2VKG1rlEouSO4UIm2TuwO761NgveNeitN+jqadBZsKiOmGhW14irk+c+peVBExc3HiC/d5tnpqzw6fJKHvxzl7n9/Jm9/No+O/o+Xf9yhu90+sogcmVtrNDRpzLQY67HXNdPVbKfHYUVnD32dvfR39tLb1kW3Yz80tNJR10K7tUmQYyqajfU0aIyYa5RYpGoMlUrUpRIUz0tRloiRFbyi5kEhsvxiJI9LUIsr6O/uwenv938z3DvIUM+AcL4bGOb9wFveD73j/eBb/h7+i7/f/sXfw++F57+G3/P+7XveDr4VNDw4zNuBf56HGB4YZKhvQNBgbz9Dff0MdPUKwQR1fTi7e/nr/V/8HzLpSvkUrIc+AAAAAElFTkSuQmCC">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
overflow-x: hidden;
}
.page {
display: none !important;
min-height: 100vh;
/* iOS notch / Dynamic Island: viewport-fit=cover 让 viewport 顶到物理边缘,
这里用 env(safe-area-inset-*) 把内容推回到安全区内,避免被前摄/底部 Home 条遮挡。
旧设备 / 非全屏环境下 env() 解析为 0等价于纯 20px 内边距。 */
padding: calc(20px + env(safe-area-inset-top))
calc(20px + env(safe-area-inset-right))
calc(20px + env(safe-area-inset-bottom))
calc(20px + env(safe-area-inset-left));
}
.page.active { display: block !important; }
#login-page.active {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, #1a1a2e 0%, #0f0f1a 100%);
}
.login-form {
background: rgba(22, 33, 62, 0.95);
padding: 40px;
border-radius: 16px;
width: 100%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
backdrop-filter: blur(10px);
border: 1px solid rgba(233, 69, 96, 0.2);
}
.login-form h1 {
text-align: center;
margin-bottom: 30px;
color: #e94560;
font-size: 28px;
text-shadow: 0 0 20px rgba(233, 69, 96, 0.3);
}
.login-form input {
width: 100%;
padding: 14px 16px;
margin-bottom: 16px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
background: rgba(15, 52, 96, 0.8);
color: #fff;
font-size: 16px;
outline: none;
transition: all 0.3s;
}
.login-form input:focus {
border-color: #e94560;
box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2);
}
.login-form input::placeholder { color: #666; }
.login-form > button {
width: 100%;
padding: 14px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #e94560 0%, #c73e54 100%);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 1px;
}
.login-form > button:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(233, 69, 96, 0.4); }
.login-form > button:disabled { background: #444; cursor: not-allowed; transform: none; box-shadow: none; }
.error-msg { color: #e94560; text-align: center; margin-top: 16px; font-size: 14px; }
.conn-status { text-align: center; margin-bottom: 20px; font-size: 13px; color: #666; }
.conn-status.connected { color: #4caf50; }
.conn-status.disconnected { color: #f44336; }
/* Password input with toggle */
.password-wrapper {
position: relative;
width: 100%;
margin-bottom: 16px;
}
.password-wrapper input {
margin-bottom: 0;
padding-right: 48px;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
font-size: 16px;
line-height: 24px;
text-align: center;
transition: color 0.2s;
opacity: 0.6;
display: flex;
align-items: center;
justify-content: center;
}
.password-toggle:hover { color: #e94560; opacity: 1; }
/* Login footer */
.login-footer {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.security-notice {
background: rgba(255,152,0,0.1);
border: 1px solid rgba(255,152,0,0.3);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
font-size: 12px;
color: #ccc;
line-height: 1.5;
}
.security-notice strong {
color: #ff9800;
display: block;
margin-bottom: 4px;
}
.login-links {
display: flex;
justify-content: center;
gap: 24px;
font-size: 13px;
}
.login-links a {
color: #e94560;
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.login-links a:hover { color: #ff6b8a; }
#devices-page { max-width: 1400px; margin: 0 auto; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.page-header h2 {
color: #e94560;
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.device-count {
background: rgba(233, 69, 96, 0.2);
color: #e94560;
padding: 4px 12px;
border-radius: 20px;
font-size: 14px;
font-weight: normal;
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
flex: 1;
min-width: 180px;
max-width: 300px;
}
.search-box input {
width: 100%;
padding: 10px 16px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(15, 52, 96, 0.6);
color: #fff;
font-size: 14px;
outline: none;
transition: all 0.3s;
}
.search-box input:focus {
border-color: #e94560;
background: rgba(15, 52, 96, 0.9);
}
.search-box input::placeholder { color: #666; }
.search-box input { padding-right: 32px; }
.search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #666;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
display: none;
}
.search-clear:hover { color: #e94560; }
.view-toggle {
display: flex;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
overflow: hidden;
}
.view-btn {
padding: 8px 14px;
border: none;
background: rgba(15, 52, 96, 0.6);
color: #888;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.view-btn:first-child { border-right: 1px solid rgba(255,255,255,0.1); }
.view-btn:hover { color: #fff; }
.view-btn.active { background: rgba(233, 69, 96, 0.3); color: #e94560; }
/* Unified icon button (Refresh / Users / Logout). 40x40 方块更紧凑,留位置给未来按钮。
颜色身份通过修饰类(.refresh / .users / .logout保留hover 高光与原来一致。 */
.icon-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 8px;
color: #fff;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 8px;
}
.icon-btn svg { display: block; width: 20px; height: 20px; }
.icon-btn:hover { transform: translateY(-1px); }
.icon-btn:active { transform: translateY(0); }
.icon-btn.refresh { background: linear-gradient(135deg, #0f3460 0%, #1a4a7a 100%); }
.icon-btn.refresh:hover { box-shadow: 0 4px 12px rgba(15, 52, 96, 0.4); }
.icon-btn.users { background: linear-gradient(135deg, #9b59b6 0%, #8e44ad 100%); display: none; }
.icon-btn.users.visible { display: inline-flex; }
.icon-btn.users:hover { box-shadow: 0 4px 12px rgba(142, 68, 173, 0.4); }
.icon-btn.logout { background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%); }
.icon-btn.logout:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); }
/* User Management Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: rgba(22, 33, 62, 0.98);
border-radius: 16px;
padding: 24px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
border: 1px solid rgba(233, 69, 96, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.modal-header h3 { color: #e94560; margin: 0; }
.modal-close {
background: none;
border: none;
color: #888;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.modal-close:hover { color: #e94560; }
.user-form { margin-bottom: 24px; }
.user-form h4 { color: #ccc; margin-bottom: 12px; font-size: 14px; }
.user-form input, .user-form select {
width: 100%;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(15, 52, 96, 0.8);
color: #fff;
font-size: 14px;
}
.user-form input:focus, .user-form select:focus {
outline: none;
border-color: #e94560;
}
.user-form button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%);
color: #fff;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.user-form button:hover { transform: translateY(-1px); }
.user-list h4 { color: #ccc; margin-bottom: 12px; font-size: 14px; }
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: rgba(15, 52, 96, 0.5);
border-radius: 8px;
margin-bottom: 8px;
}
.user-item .user-info { flex: 1; }
.user-item .username { color: #fff; font-weight: 500; }
.user-item .role { color: #888; font-size: 12px; }
.user-item .role.admin { color: #e94560; }
.user-item .delete-btn {
background: rgba(231, 76, 60, 0.2);
border: 1px solid rgba(231, 76, 60, 0.5);
color: #e74c3c;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.user-item .delete-btn:hover { background: rgba(231, 76, 60, 0.4); }
.user-item .delete-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.user-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
.user-msg.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
.user-msg.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
/* Generic confirmation modal (compact yes/no dialog, e.g. logout) —— 复用 .modal-overlay
做遮罩,自带一套紧凑布局 + 危险/取消双按钮风格。 */
.confirm-modal-content {
background: rgba(22, 33, 62, 0.98);
border-radius: 16px;
padding: 28px;
width: 90%;
max-width: 380px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
border: 1px solid rgba(231, 76, 60, 0.25);
text-align: center;
}
.confirm-modal-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
border-radius: 50%;
background: rgba(231, 76, 60, 0.15);
color: #e74c3c;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-modal-icon svg { width: 28px; height: 28px; }
.confirm-modal-content h3 { color: #fff; margin: 0 0 8px; font-size: 18px; }
.confirm-modal-content p { color: #aaa; margin: 0 0 24px; font-size: 14px; line-height: 1.5; }
.confirm-modal-actions { display: flex; gap: 12px; justify-content: center; }
.confirm-modal-actions button {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.confirm-modal-actions .cancel-btn {
background: rgba(255,255,255,0.08);
color: #ccc;
}
.confirm-modal-actions .cancel-btn:hover { background: rgba(255,255,255,0.15); }
.confirm-modal-actions .danger-btn {
background: linear-gradient(135deg, #c0392b 0%, #e74c3c 100%);
color: #fff;
}
.confirm-modal-actions .danger-btn:hover { box-shadow: 0 4px 12px rgba(192, 57, 43, 0.4); transform: translateY(-1px); }
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.device-card {
background: rgba(22, 33, 62, 0.8);
padding: 20px;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid rgba(255,255,255,0.05);
position: relative;
overflow: hidden;
}
.device-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #e94560, #0f3460);
opacity: 0;
transition: opacity 0.3s;
}
.device-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
border-color: rgba(233, 69, 96, 0.3);
}
.device-card:hover::before { opacity: 1; }
/* 终端图标按钮右上角独立于卡片本体点击事件onclick stopPropagation */
.device-card .card-term-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: #aaa;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
z-index: 1;
}
.device-card .card-term-btn:hover { background: rgba(76, 175, 80, 0.25); color: #4caf50; }
.device-card .card-term-btn:active { transform: scale(0.92); }
.device-card h3 { padding-right: 40px; } /* 给终端图标让位 */
/* ====== 终端页面 ====== */
#term-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #000;
padding: 0;
display: none;
flex-direction: column;
overflow: hidden; /* 防 iOS 软键盘弹出时 body 偷偷加 scroll 顶起整页 */
overscroll-behavior: contain;
}
#term-page.active { display: flex !important; }
/* 顶部 toolbar 加 safe-area-inset-top避免 iPhone 刘海 / 灵动岛压住 Back 按钮。
position: sticky + top:0 兜底:万一发生页面 scrollBack 按钮也始终钉在顶部 */
#term-page .screen-toolbar {
position: sticky;
top: 0;
z-index: 10;
background: rgba(0,0,0,0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.term-host {
flex: 1;
background: #000;
padding: 6px;
overflow: hidden;
min-height: 0; /* flex-item 在容器里 overflow 才生效 */
}
.term-host .xterm { height: 100% !important; width: 100% !important; }
.term-host .xterm .xterm-viewport { background-color: #000 !important; }
/* 始终可见的滚动条(覆盖 xterm.js 默认 + 浏览器 autohide
Firefox 用 scrollbar-width / scrollbar-color其它浏览器用 ::-webkit-scrollbar */
.term-host .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.35) transparent;
}
.term-host .xterm-viewport::-webkit-scrollbar { width: 8px; background: rgba(255,255,255,0.04); }
.term-host .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.35);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.55); background-clip: padding-box; }
/* 移动辅助按钮栏:底部固定一行,覆盖手机软键盘上方常用键
Tab / Esc / Ctrl+C / 历史 ↑ —— 90% 应急场景够用) */
.term-aux-bar {
display: none; /* 默认隐藏JS 在窄屏 / 触屏环境下显示 */
gap: 6px;
padding: 6px calc(8px + env(safe-area-inset-right))
calc(6px + env(safe-area-inset-bottom))
calc(8px + env(safe-area-inset-left));
background: rgba(0,0,0,0.85);
border-top: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
#term-page.active .term-aux-bar.visible { display: flex; }
.term-aux-bar button {
flex: 1;
min-width: 0;
height: 36px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: #ddd;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.term-aux-bar button:active { background: rgba(76,175,80,0.35); transform: scale(0.96); }
.device-card h3 {
color: #fff;
font-size: 16px;
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-card .info {
color: #888;
font-size: 13px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.device-card .info-label { color: #666; min-width: 32px; }
.device-card .resolution { color: #666; font-size: 12px; }
.device-card .info-row { display: flex; gap: 16px; flex-wrap: wrap; }
.device-card .active-window {
color: #888; font-size: 12px; margin-top: 8px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 100%; opacity: 0.8;
}
.device-card .active-window.busy {
color: #e94560; opacity: 1; font-weight: 500;
}
.device-card .meta-row { display: flex; gap: 12px; margin-top: 6px; font-size: 12px; color: #666; }
.device-card .meta-item { display: flex; align-items: center; gap: 4px; }
.device-card .meta-item.rtt { font-weight: 500; }
.device-card .meta-item.rtt.rtt-good { color: #4caf50; }
.device-card .meta-item.rtt.rtt-medium { color: #ff9800; }
.device-card .meta-item.rtt.rtt-poor { color: #f44336; }
.no-devices {
text-align: center;
color: #666;
padding: 60px 20px;
background: rgba(22, 33, 62, 0.5);
border-radius: 12px;
border: 1px dashed rgba(255,255,255,0.1);
}
.no-devices h3 { color: #888; margin-bottom: 10px; }
/* List view styles */
.device-grid.list-view {
display: flex;
flex-direction: column;
gap: 8px;
}
.device-grid.list-view .device-card {
display: flex;
align-items: center;
padding: 12px 16px;
gap: 20px;
}
.device-grid.list-view .device-card::before { display: none; }
.device-grid.list-view .device-card h3 {
min-width: 180px;
margin-bottom: 0;
font-size: 14px;
}
.device-grid.list-view .device-card .info {
margin-bottom: 0;
min-width: 120px;
}
.device-grid.list-view .device-card .info-row { flex-wrap: nowrap; gap: 12px; }
.device-grid.list-view .device-card .meta-row { margin-top: 0; }
.device-grid.list-view .device-card .active-window {
margin-top: 0; max-width: 200px; flex-shrink: 1;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding: 20px 0;
}
.pagination button {
padding: 8px 14px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
background: rgba(15, 52, 96, 0.6);
color: #aaa;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.pagination button:hover:not(:disabled) {
border-color: #e94560;
color: #fff;
}
.pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination button.active {
background: #e94560;
border-color: #e94560;
color: #fff;
}
.pagination .page-info {
color: #666;
font-size: 13px;
padding: 0 12px;
}
.stats-bar {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 12px 16px;
background: rgba(15, 52, 96, 0.4);
border-radius: 8px;
font-size: 13px;
}
.stats-bar .stat { color: #888; }
.stats-bar .stat strong { color: #e94560; }
#screen-page {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: #000;
padding: 0;
}
#screen-page.active { display: flex !important; flex-direction: column; }
.canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #111; position: relative; }
#screen-canvas { max-width: 100%; max-height: 100%; }
.screen-toolbar {
background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.85) 100%);
/* 远程桌面顶部工具栏也避开 notch / Dynamic Island —— 顶 padding + 安全区 */
padding: calc(12px + env(safe-area-inset-top))
calc(20px + env(safe-area-inset-right))
12px
calc(20px + env(safe-area-inset-left));
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.back-btn {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 14px;
cursor: pointer;
padding: 8px 16px;
border-radius: 6px;
transition: all 0.2s;
}
.back-btn:hover { background: #e94560; }
.toolbar-info { text-align: center; flex: 1; }
.toolbar-info .device-name { font-weight: 600; font-size: 15px; color: #fff; }
.toolbar-info .conn-info { font-size: 12px; color: #888; margin-top: 4px; }
.screen-status { font-size: 12px; padding: 6px 14px; border-radius: 20px; font-weight: 500; }
.screen-status.connecting { background: rgba(255, 152, 0, 0.2); color: #ff9800; }
.screen-status.connected { background: rgba(76, 175, 80, 0.2); color: #4caf50; }
.screen-status.error { background: rgba(244, 67, 54, 0.2); color: #f44336; }
.toolbar-right { display: flex; align-items: center; gap: 12px; }
.fullscreen-btn {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
transition: all 0.2s;
line-height: 1;
}
.fullscreen-btn:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn-bar {
background: rgba(255,255,255,0.1);
border: none;
color: #fff;
font-size: 18px;
cursor: pointer;
padding: 6px 10px;
border-radius: 6px;
transition: all 0.2s;
line-height: 1;
}
.toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn-bar.active { background: rgba(52,199,89,0.8); }
.toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); }
.toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; }
.toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); }
#screen-page:fullscreen .screen-toolbar { display: none; }
#screen-page:-webkit-full-screen .screen-toolbar { display: none; }
#screen-page:fullscreen .canvas-container { height: 100vh; }
#screen-page:-webkit-full-screen .canvas-container { height: 100vh; }
/* iOS pseudo-fullscreen (no native fullscreen API support) */
#screen-page.pseudo-fullscreen {
position: fixed !important;
top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
width: 100vw !important; height: 100vh !important;
height: 100dvh !important; /* Dynamic viewport for iOS */
z-index: 99999;
background: #000;
padding: 0 !important;
margin: 0 !important;
}
#screen-page.pseudo-fullscreen .screen-toolbar { display: none !important; }
#screen-page.pseudo-fullscreen .canvas-container {
width: 100vw !important; height: 100vh !important;
height: 100dvh !important;
max-height: none !important;
}
/* Portrait fullscreen: align canvas to top. 56px 是给浮动工具栏留出的空间;
竖屏 iPhone 全屏时再加上 safe-area-inset-top 把内容推到 notch / 灵动岛之下。 */
@media (orientation: portrait) {
#screen-page:fullscreen .canvas-container,
#screen-page:-webkit-full-screen .canvas-container,
#screen-page.pseudo-fullscreen .canvas-container {
align-items: flex-start !important;
padding-top: calc(56px + env(safe-area-inset-top)) !important;
}
}
#screen-page.pseudo-fullscreen #screen-canvas {
max-width: 100vw !important; max-height: 100vh !important;
max-height: 100dvh !important;
object-fit: contain;
}
#screen-page.pseudo-fullscreen .toolbar-toggle {
display: flex; /* Show toolbar toggle in fullscreen */
}
#screen-page:fullscreen .toolbar-toggle,
#screen-page:-webkit-full-screen .toolbar-toggle {
display: flex;
}
.compat-warning {
background: linear-gradient(90deg, #ff9800, #f57c00);
color: #000;
padding: 12px;
text-align: center;
font-size: 14px;
font-weight: 500;
}
/* Mobile floating controls */
/* Floating toolbar menu - minimal icon style */
.floating-toolbar {
position: fixed;
/* 8px 基础留白 + safe-area-inset-top 避开 iPhone 刘海/灵动岛 */
top: calc(8px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 1001;
display: none;
background: rgba(0,0,0,0.75);
border-radius: 22px;
padding: 4px 8px;
gap: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
}
.floating-toolbar.visible { display: flex; align-items: center; }
.toolbar-btn {
background: transparent;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 50%;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.toolbar-btn:hover { background: rgba(255,255,255,0.2); }
.toolbar-btn:active { transform: scale(0.9); background: rgba(255,255,255,0.3); }
.toolbar-btn.active { background: rgba(52,199,89,0.8); }
.toolbar-btn.active:hover { background: rgba(52,199,89,1); }
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.toolbar-btn:disabled:hover { background: transparent; }
.toolbar-btn:disabled:active { transform: none; }
.toolbar-toggle {
position: fixed;
/* 同 .floating-toolbar8px 基础 + 安全区 inset 避开刘海/灵动岛 */
top: calc(8px + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(0,0,0,0.5);
border: none;
color: #fff;
font-size: 16px;
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 50%;
transition: all 0.15s;
display: none; /* Hidden by default, shown in fullscreen */
align-items: center;
justify-content: center;
}
.toolbar-toggle:hover { background: rgba(0,0,0,0.7); }
.toolbar-toggle:active { transform: translateX(-50%) scale(0.9); }
/* Zoom indicator */
.zoom-indicator {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
color: #fff;
padding: 6px 14px;
border-radius: 20px;
font-size: 14px;
z-index: 1002;
display: none;
pointer-events: none;
}
.zoom-indicator.visible { display: block; }
.touch-indicator {
position: fixed;
width: 30px;
height: 30px;
border: 2px solid #e94560;
border-radius: 50%;
pointer-events: none;
display: none;
transform: translate(-50%, -50%);
z-index: 999;
}
/* Utility class for hiding elements */
.ui-hidden { display: none !important; }
/* Quick control buttons - always visible on touch devices */
.quick-controls {
position: absolute;
display: none;
gap: 8px;
z-index: 100;
padding: 8px;
}
.quick-controls.visible { display: flex; }
.quick-controls .qc-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.quick-controls .qc-btn:hover { background: rgba(0,0,0,0.8); }
.quick-controls .qc-btn:active { transform: scale(0.9); }
.quick-controls .qc-btn.active { background: rgba(52,199,89,0.9); }
.quick-controls .qc-btn:disabled { opacity: 0.4; }
/* Portrait: horizontal at top center */
@media (orientation: portrait) {
.quick-controls {
/* 非全屏screen-toolbar 已经吃了一份 safe-area这里再叠加会让快捷按钮
离 title 远到 ~2x 图标高度的空白,且 canvas-container 因此被推得过低,
底部 input-shortcuts 在键盘弹出时会被遮挡。
全屏:另在 fullscreen 选择器里补回 safe-area-inset-top 避开前置摄像头。 */
top: 4px;
left: 50%;
transform: translateX(-50%);
flex-direction: row;
padding: 4px;
gap: 6px;
}
.quick-controls .qc-btn {
width: 40px;
height: 40px;
font-size: 18px;
}
/* Portrait: align canvas to top, leave space for controls.
同上:非全屏 screen-toolbar 已避开刘海,这里只留固定 56px 给快捷按钮,
全屏路径在下方有独立 !important 覆写补回 safe-area。 */
.canvas-container {
align-items: flex-start;
padding-top: 56px;
}
}
/* 全屏(无 screen-toolbar 兜底)才需要 quick-controls 自己加 safe-area
避开 iPhone 刘海/灵动岛/前置摄像头。 */
@media (orientation: portrait) {
#screen-page:fullscreen .quick-controls,
#screen-page:-webkit-full-screen .quick-controls,
#screen-page.pseudo-fullscreen .quick-controls {
top: calc(4px + env(safe-area-inset-top));
}
}
/* Landscape: vertical at right center */
@media (orientation: landscape) {
.quick-controls {
right: 8px;
top: 8px;
transform: none;
flex-direction: column;
}
}
/* Cursor overlay (touchpad mode). Per-variant classes are swapped via JS based on
the remote IDC_* index. Each variant overrides --hx/--hy so the icon's tip
(hotspot), not the div's top-left, lands on the reported cursor position. */
.cursor-overlay {
position: fixed;
width: 24px;
height: 24px;
pointer-events: none;
display: none;
z-index: 1002;
background-repeat: no-repeat;
background-size: contain;
filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6));
transform-origin: 0 0;
--hx: 4px;
--hy: 4px;
transform: translate(calc(-1 * var(--hx)), calc(-1 * var(--hy)));
/* IDC_ARROW (default) — also the fallback for unmapped indices. */
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" d="M4 4l7 17 2.5-6.5L20 12z"/></svg>');
}
.cursor-overlay.active { display: block; }
/* IDC_IBEAM (5) — text input */
.cursor-overlay.cursor-ibeam {
--hx: 8px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="white" stroke-width="3" d="M4 3h8M8 3v18M4 21h8"/><path fill="none" stroke="black" stroke-width="1" d="M4 3h8M8 3v18M4 21h8"/></svg>');
}
/* IDC_HAND (3) — Windows-style pointing hand: tall index finger, thumb on the left,
three visible bent fingers (middle/ring/pinky) with descending heights, rounded palm. */
.cursor-overlay.cursor-hand {
--hx: 11.5px; --hy: 1.5px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round" d="M10 3 a1.5 1.5 0 0 1 3 0 v7 q1.5 -7 3 0 q1.5 -6 3 0 q1.5 -5 3 0 q1 1 1 2 v5 a4 4 0 0 1 -4 4 h-7 a4 4 0 0 1 -4 -4 v-2 q-2 0 -2 -2 q0 -2 2 -2 q1 0 1 1 q1 -1 1 -2 z"/></svg>');
}
/* IDC_WAIT (15) — busy hourglass */
.cursor-overlay.cursor-wait {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M6 3h12v4l-5 5 5 5v4H6v-4l5-5-5-5z"/></svg>');
}
/* IDC_NO (7) — forbidden */
.cursor-overlay.cursor-no {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="white" stroke-width="3"/><line x1="6.5" y1="6.5" x2="17.5" y2="17.5" stroke="white" stroke-width="3"/><circle cx="12" cy="12" r="8" fill="none" stroke="black" stroke-width="1.5"/><line x1="6.5" y1="6.5" x2="17.5" y2="17.5" stroke="black" stroke-width="1.5"/></svg>');
}
/* IDC_SIZEALL (9) — 4-way move */
.cursor-overlay.cursor-move {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v4H7V9l-4 3 4 3v-2h4v4H9l3 4 3-4h-2v-4h4v2l4-3-4-3v2h-4V7h2z"/></svg>');
}
/* IDC_SIZENS (11) — vertical resize */
.cursor-overlay.cursor-sizens {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v10H9l3 4 3-4h-2V7h2z"/></svg>');
}
/* IDC_SIZEWE (13) — horizontal resize */
.cursor-overlay.cursor-sizewe {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M3 12 7 9v2h10V9l4 3-4 3v-2H7v2z"/></svg>');
}
/* IDC_SIZENWSE (12) — diagonal NW-SE resize (\\). Rotating vertical NS arrow -45° (CCW)
tilts the top-left to upper-left and bottom-right to lower-right. */
.cursor-overlay.cursor-sizenwse {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g transform="rotate(-45 12 12)"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v10H9l3 4 3-4h-2V7h2z"/></g></svg>');
}
/* IDC_SIZENESW (10) — diagonal NE-SW resize (//). Rotating vertical NS arrow +45° (CW)
tilts the top toward upper-right and bottom toward lower-left. */
.cursor-overlay.cursor-sizenesw {
--hx: 12px; --hy: 12px;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g transform="rotate(45 12 12)"><path fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round" d="M12 3 9 7h2v10H9l3 4 3-4h-2V7h2z"/></g></svg>');
}
/* Input shortcut bar - below canvas, portrait mode only */
.input-shortcuts {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: none;
justify-content: center;
gap: 4px;
padding: 6px 4px;
z-index: 101;
}
.input-shortcuts.visible { display: flex; }
.input-shortcuts .shortcut-btn {
min-width: 36px;
height: 36px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid rgba(128,128,128,0.5);
background: rgba(128,128,128,0.4);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s;
text-shadow: 0 0 2px #000, 0 0 4px #000, 1px 1px 1px #000;
}
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
/* Mobile responsive */
@media (max-width: 768px) {
.page {
padding: calc(10px + env(safe-area-inset-top))
calc(10px + env(safe-area-inset-right))
calc(10px + env(safe-area-inset-bottom))
calc(10px + env(safe-area-inset-left));
}
.header { flex-direction: column; gap: 12px; padding: 12px; }
.header h1 { font-size: 20px; }
.search-box { width: 100%; }
.search-input { font-size: 14px; }
.device-grid { grid-template-columns: 1fr !important; gap: 10px; }
.device-card { padding: 14px; }
.device-card .name { font-size: 15px; }
.device-card .ip { font-size: 12px; }
.device-card .meta-row { flex-wrap: wrap; gap: 8px; }
.device-card .active-window { font-size: 11px; }
.screen-toolbar {
padding: calc(8px + env(safe-area-inset-top))
calc(12px + env(safe-area-inset-right))
8px
calc(12px + env(safe-area-inset-left));
}
.back-btn { padding: 6px 12px; font-size: 13px; }
.toolbar-info .device-name { font-size: 13px; }
.toolbar-info .conn-info { font-size: 11px; }
.screen-status { font-size: 11px; padding: 4px 10px; }
.fullscreen-btn { font-size: 16px; padding: 4px 8px; }
.pagination { flex-wrap: wrap; gap: 4px; }
.pagination button { padding: 6px 10px; font-size: 12px; }
.login-form { padding: 24px; margin: 10px; }
.login-form h1 { font-size: 22px; }
}
@media (max-width: 480px) {
.header h1 { font-size: 18px; }
.device-card .info-row { flex-direction: column; gap: 4px; }
.toolbar-right { gap: 8px; }
.mobile-btn { width: 44px; height: 44px; font-size: 18px; }
/* Small screens: hide toolbar buttons, show floating toggle */
.toolbar-btn-bar { display: none; }
.toolbar-toggle { display: flex; }
}
</style>
</head>
<body>
<div id="login-page" class="page active">
<div class="login-form">
<h1>SimpleRemoter</h1>
<div id="ws-status" class="conn-status disconnected">Connecting...</div>
<input type="text" id="username" placeholder="Username" value="admin">
<div class="password-wrapper">
<input type="password" id="password" placeholder="Password">
<button type="button" class="password-toggle" onclick="togglePasswordVisibility()" title="Show/Hide Password">&#x1F441;</button>
</div>
<button id="login-btn" onclick="login()" disabled>Login</button>
<div id="login-error" class="error-msg"></div>
<div class="login-footer">
<div class="security-notice">
<strong>&#x26A0; Security Notice</strong>
SimpleRemoter.com may be flagged as "dangerous" by browsers due to the word "Remote" in its name. This is a false positive. The software is fully open-source and safe to use.
</div>
<div class="login-links">
<a href="https://simpleremoter.com/" target="_blank">&#x1F310; Website</a>
<a href="https://git.simpleremoter.com/" target="_blank">&#x1F4E6; Source Code</a>
</div>
</div>
</div>
</div>
<div id="devices-page" class="page">
<div class="page-header">
<h2>Devices <span id="device-count" class="device-count">0</span></h2>
<div class="toolbar">
<div class="search-box">
<input type="text" id="search-input" placeholder="Search by name, IP, OS..." oninput="onSearchChange()">
<button class="search-clear" id="search-clear" onclick="clearSearch()">&times;</button>
</div>
<div class="view-toggle">
<button id="view-grid" class="view-btn active" onclick="setViewMode('grid')" title="Grid View">Grid</button>
<button id="view-list" class="view-btn" onclick="setViewMode('list')" title="List View">List</button>
</div>
<button class="icon-btn refresh" onclick="getDevices()" title="Refresh" aria-label="Refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
</button>
<button class="icon-btn users" id="users-btn" onclick="openUsersModal()" title="User Management" aria-label="User Management">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</button>
<button class="icon-btn logout" onclick="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
<div id="stats-bar" class="stats-bar">
<div class="stat">Total: <strong id="stat-total">0</strong></div>
<div class="stat">Showing: <strong id="stat-showing">0</strong></div>
</div>
<div id="device-list" class="device-grid"></div>
<div id="pagination" class="pagination"></div>
</div>
<div id="screen-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="disconnect()">Back</button>
<div class="toolbar-info">
<div id="device-name" class="device-name"></div>
<div id="frame-info" class="conn-info"></div>
</div>
<div class="toolbar-right">
<span id="screen-status" class="screen-status connecting">Connecting...</span>
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">&#x26F6;</button>
</div>
</div>
<div class="canvas-container">
<canvas id="screen-canvas"></canvas>
<div class="cursor-overlay" id="cursor-overlay"></div>
<div class="quick-controls" id="quick-controls">
<button class="qc-btn" id="qc-rdp" onclick="sendRdpReset()" title="RDP Reset" tabindex="-1">&#x21BB;</button>
<button class="qc-btn" id="qc-keyboard" onclick="toggleKeyboard()" title="Keyboard" tabindex="-1">&#x2328;</button>
<button class="qc-btn" id="qc-mouse" onclick="toggleControl()" title="Mouse" tabindex="-1">&#x1F5B1;</button>
<button class="qc-btn" id="qc-disconnect" onclick="disconnect()" title="Disconnect" tabindex="-1">&#x2715;</button>
</div>
<div class="input-shortcuts" id="input-shortcuts">
<button class="shortcut-btn" data-key="49" tabindex="-1">1</button>
<button class="shortcut-btn" data-key="50" tabindex="-1">2</button>
<button class="shortcut-btn" data-key="51" tabindex="-1">3</button>
<button class="shortcut-btn" data-key="52" tabindex="-1">4</button>
<button class="shortcut-btn" data-key="53" tabindex="-1">5</button>
<button class="shortcut-btn" data-key="190" tabindex="-1">.</button>
<button class="shortcut-btn" data-key="188" tabindex="-1">,</button>
<button class="shortcut-btn" data-key="191" data-shift="1" tabindex="-1">?</button>
<button class="shortcut-btn" data-key="32" data-ctrl="1" tabindex="-1" title="Ctrl+Space">&#x4E2D;</button>
</div>
</div>
<div class="touch-indicator" id="touch-indicator"></div>
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">&#x2022;&#x2022;&#x2022;</button>
<div class="floating-toolbar" id="floating-toolbar">
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">&#x21BB;</button>
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">&#x1F5B1;</button>
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>&#x2328;</button>
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">&#x2715;</button>
</div>
<div class="zoom-indicator" id="zoom-indicator">100%</div>
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
</div>
<!-- Terminal Page (xterm.js) -->
<div id="term-page" class="page">
<div class="screen-toolbar">
<button class="back-btn" onclick="closeTerminal()">Back</button>
<div class="toolbar-info">
<div id="term-title" class="device-name">Terminal</div>
<div id="term-status-info" class="conn-info">Connecting...</div>
</div>
</div>
<div id="term-host" class="term-host"></div>
<!-- 移动辅助按钮栏:触屏 / 窄屏环境下 JS 自动加 .visible -->
<div class="term-aux-bar" id="term-aux-bar">
<button onclick="termSendSpecial('tab')">Tab</button>
<button onclick="termSendSpecial('esc')">Esc</button>
<button onclick="termSendSpecial('ctrlc')">Ctrl+C</button>
<button onclick="termSendSpecial('up')">&uarr;</button>
<button onclick="termHideKeyboard()" title="Hide keyboard" aria-label="Hide keyboard">&#x25BC;</button>
</div>
</div>
<!-- User Management Modal -->
<div class="modal-overlay" id="users-modal">
<div class="modal-content">
<div class="modal-header">
<h3>User Management</h3>
<button class="modal-close" onclick="closeUsersModal()">&times;</button>
</div>
<div id="user-msg"></div>
<div class="user-form">
<h4>Create New User</h4>
<input type="text" id="new-username" placeholder="Username" autocomplete="off">
<input type="password" id="new-password" placeholder="Password" autocomplete="new-password">
<select id="new-role" onchange="onRoleChange()">
<option value="viewer">Viewer (read-only)</option>
<option value="admin">Admin (full access)</option>
</select>
<div class="groups-section" id="groups-section">
<label style="font-size:13px;color:#aaa;display:block;margin:8px 0 4px;">Allowed Groups:</label>
<div id="groups-checkboxes" style="max-height:120px;overflow-y:auto;background:rgba(0,0,0,0.2);border-radius:6px;padding:6px 8px;"></div>
</div>
<button onclick="createUser()">Create User</button>
</div>
<div class="user-list">
<h4>Existing Users</h4>
<div id="users-list"></div>
</div>
</div>
</div>
<!-- Logout Confirmation Modal -->
<div class="modal-overlay" id="logout-confirm-modal" onclick="if(event.target===this)cancelLogout()">
<div class="confirm-modal-content">
<div class="confirm-modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</div>
<h3>Confirm Logout</h3>
<p>You will be returned to the login screen.<br>Continue?</p>
<div class="confirm-modal-actions">
<button class="cancel-btn" onclick="cancelLogout()">Cancel</button>
<button class="danger-btn" onclick="confirmLogout()">Logout</button>
</div>
</div>
</div>
<script src="/static/xterm.js"></script>
<script src="/static/xterm-fit.js"></script>
<script>
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
const canvas = document.getElementById('screen-canvas');
const ctx = canvas.getContext('2d');
// Pagination and filter state
let currentPage = 1;
let viewMode = 'grid'; // 'grid' or 'list'
let searchQuery = '';
let challengeNonce = ''; // Challenge nonce from server
let passwordHash = ''; // SHA256 of password (computed once)
// SHA256 using Web Crypto API
// SHA256 implementation (works in non-secure contexts)
async function sha256(message) {
// Try Web Crypto API first (secure contexts only)
if (window.crypto && window.crypto.subtle) {
try {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (e) { /* fallback below */ }
}
// Fallback: pure JS SHA256
return sha256_js(message);
}
// Pure JS SHA256 implementation
function sha256_js(str) {
const K = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
];
const utf8 = unescape(encodeURIComponent(str));
const bytes = [];
for (let i = 0; i < utf8.length; i++) bytes.push(utf8.charCodeAt(i));
bytes.push(0x80);
while ((bytes.length + 8) % 64 !== 0) bytes.push(0);
const bitLen = (utf8.length * 8);
for (let i = 7; i >= 0; i--) bytes.push((bitLen / Math.pow(2, i * 8)) & 0xff);
let [h0, h1, h2, h3, h4, h5, h6, h7] = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
for (let i = 0; i < bytes.length; i += 64) {
const w = [];
for (let j = 0; j < 16; j++) w[j] = (bytes[i+j*4]<<24)|(bytes[i+j*4+1]<<16)|(bytes[i+j*4+2]<<8)|bytes[i+j*4+3];
for (let j = 16; j < 64; j++) {
const s0 = rotr(w[j-15],7) ^ rotr(w[j-15],18) ^ (w[j-15]>>>3);
const s1 = rotr(w[j-2],17) ^ rotr(w[j-2],19) ^ (w[j-2]>>>10);
w[j] = (w[j-16] + s0 + w[j-7] + s1) >>> 0;
}
let [a,b,c,d,e,f,g,h] = [h0,h1,h2,h3,h4,h5,h6,h7];
for (let j = 0; j < 64; j++) {
const S1 = rotr(e,6) ^ rotr(e,11) ^ rotr(e,25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K[j] + w[j]) >>> 0;
const S0 = rotr(a,2) ^ rotr(a,13) ^ rotr(a,22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + maj) >>> 0;
[h,g,f,e,d,c,b,a] = [g,f,e,(d+t1)>>>0,c,b,a,(t1+t2)>>>0];
}
[h0,h1,h2,h3,h4,h5,h6,h7] = [(h0+a)>>>0,(h1+b)>>>0,(h2+c)>>>0,(h3+d)>>>0,(h4+e)>>>0,(h5+f)>>>0,(h6+g)>>>0,(h7+h)>>>0];
}
return [h0,h1,h2,h3,h4,h5,h6,h7].map(x => x.toString(16).padStart(8,'0')).join('');
}
function getPageSize() {
return viewMode === 'grid' ? 12 : 50;
}
// Page visibility state for background detection (iOS PWA)
let isPageVisible = !document.hidden;
let reconnectTimer = null;
function scheduleReconnect() {
if (reconnectTimer) return; // Already scheduled
if (!isPageVisible) return; // Don't reconnect in background
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (isPageVisible) connectWebSocket();
}, 3000);
}
function cancelReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function connectWebSocket() {
if (!isPageVisible) return; // Don't connect in background
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
updateWsStatus('connecting');
try {
ws = new WebSocket(protocol + '//' + location.host + '/ws');
} catch (e) {
updateWsStatus('disconnected');
scheduleReconnect();
return;
}
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
updateWsStatus('connected');
startPingInterval();
// Auto-restore session if token exists
if (token) {
// Check if we were on screen-page with an active device
const screenPage = document.getElementById('screen-page');
if (screenPage.classList.contains('active') && currentDevice) {
// Reconnect to current device
updateScreenStatus('connecting');
ws.send(JSON.stringify({ cmd: 'connect', id: String(currentDevice.id), token }));
} else {
showPage('devices-page');
getDevices();
}
}
};
ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); scheduleReconnect(); };
ws.onerror = (e) => console.error('WS error:', e);
ws.onmessage = (event) => {
if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data));
else handleBinaryFrame(event.data);
};
}
function updateWsStatus(status) {
const el = document.getElementById('ws-status');
const btn = document.getElementById('login-btn');
if (status === 'connected') {
el.textContent = 'Connected'; el.className = 'conn-status connected'; btn.disabled = false;
} else if (status === 'connecting') {
el.textContent = 'Connecting...'; el.className = 'conn-status'; btn.disabled = true;
} else {
el.textContent = 'Disconnected'; el.className = 'conn-status disconnected'; btn.disabled = true;
}
}
function handleSignaling(msg) {
switch (msg.cmd) {
case 'challenge':
challengeNonce = msg.nonce || '';
console.log('Received challenge nonce');
break;
case 'salt':
if (msg.ok) {
completeLogin(msg.salt || '');
} else {
pendingLogin = null; // Clear pending state on error
document.getElementById('login-error').textContent = msg.msg || 'Failed to get salt';
}
break;
case 'login_result':
if (msg.ok) {
token = msg.token;
currentUserRole = msg.role || 'viewer';
sessionStorage.setItem('token', token);
sessionStorage.setItem('role', currentUserRole);
document.getElementById('login-error').textContent = '';
// Show Users button for admin only
const usersBtn = document.getElementById('users-btn');
if (currentUserRole === 'admin') {
usersBtn.classList.add('visible');
} else {
usersBtn.classList.remove('visible');
}
showPage('devices-page');
getDevices();
} else {
document.getElementById('login-error').textContent = msg.msg || 'Login failed';
}
break;
case 'device_list':
if (msg.ok === false) {
// Token invalid (e.g., server restarted), go back to login
token = null;
sessionStorage.removeItem('token');
showPage('login-page');
break;
}
devices = msg.devices || [];
// Keep current page (renderDeviceList adjusts if out of range)
renderDeviceList();
break;
case 'connect_result':
if (!token) break;
if (msg.ok) {
// Resolution may not be available yet (first connection)
if (msg.width && msg.height) {
updateScreenStatus('connected');
initDecoder(msg.width, msg.height);
} else {
// Wait for resolution_changed message
updateScreenStatus('waiting', 'Waiting for video...');
}
} else {
updateScreenStatus('error', msg.msg);
setTimeout(() => showPage('devices-page'), 2000);
}
break;
case 'term_ready':
termState.ready = true;
document.getElementById('term-status-info').textContent =
'Connected (' + (msg.mode === 'pty' ? 'PTY' : 'Legacy shell') + ')';
// 通知 server 当前 cols/rowsPTY 模式下 host 才知道窗口尺寸
if (termState.fit) try { termState.fit.fit(); } catch (e) {}
notifyTerminalResize();
break;
case 'term_closed':
if (termState.deviceId) {
if (termState.term) {
termState.term.write('\r\n\x1b[33m[Session closed' +
(msg.msg ? ': ' + msg.msg : '') + ']\x1b[0m\r\n');
}
termState.ready = false;
// 不立刻跳回 devices-page让用户看到提示。点 Back 才真返回。
}
break;
case 'disconnect_result':
// disconnect() already handles navigation, this is just server acknowledgment
// No action needed - prevents race conditions when switching devices
break;
case 'resolution_changed':
updateScreenStatus('connected');
initDecoder(msg.width, msg.height);
break;
case 'cursor':
// Update remote cursor visual: native CSS cursor on desktop,
// overlay variant on touch. 254=custom (unsupported), 255=unsupported.
currentCursorIndex = msg.index;
if (controlEnabled) {
applyRemoteCursor(msg.index);
}
break;
case 'device_offline':
// Only handle if this is the device we're currently viewing
if (!token) break;
if (!currentDevice || String(msg.id) !== String(currentDevice.id)) break;
updateScreenStatus('error', 'Device offline');
setTimeout(() => { showPage('devices-page'); getDevices(); }, 2000);
break;
case 'device_update':
// Only update if on devices page
if (document.getElementById('devices-page').classList.contains('active')) {
updateDeviceInfo(msg.id, msg.rtt, msg.activeWindow);
}
break;
case 'devices_changed':
// Refresh device list when devices come online/offline
if (document.getElementById('devices-page').classList.contains('active')) {
getDevices();
}
break;
case 'create_user_result':
if (msg.ok) {
showUserMsg('User created successfully', false);
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
listUsers();
} else {
showUserMsg(msg.msg || 'Failed to create user', true);
}
break;
case 'delete_user_result':
if (msg.ok) {
showUserMsg('User deleted', false);
listUsers();
} else {
showUserMsg(msg.msg || 'Failed to delete user', true);
}
break;
case 'list_users_result':
if (msg.ok) {
renderUsersList(msg.users);
}
break;
case 'groups':
if (msg.ok) {
renderGroupsCheckboxes(msg.groups);
}
break;
}
}
function checkWebCodecs() {
if (!('VideoDecoder' in window)) return { supported: false, reason: 'VideoDecoder not available' };
return { supported: true };
}
function initDecoder(width, height) {
decoderWidth = width;
decoderHeight = height;
needKeyframe = false;
decodeTimestamp = 0;
// Clear canvas before resizing to prevent residual content
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = width;
canvas.height = height;
// Clear again after resize (in case of smaller resolution)
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, width, height);
// Update input shortcuts position after canvas resize
requestAnimationFrame(updateInputShortcutsPosition);
// Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height);
if (decoder) { try { decoder.close(); } catch(e) {} }
frameCount = 0;
lastFrameTime = performance.now();
decoder = new VideoDecoder({
output: (frame) => {
// Check if frame dimensions match canvas
if (frame.displayWidth !== canvas.width || frame.displayHeight !== canvas.height) {
console.warn(`Frame size mismatch: frame=${frame.displayWidth}x${frame.displayHeight}, canvas=${canvas.width}x${canvas.height}`);
}
ctx.drawImage(frame, 0, 0);
frame.close();
frameCount++;
const now = performance.now();
if (now - lastFrameTime >= 1000) {
fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
frameCount = 0;
lastFrameTime = now;
document.getElementById('frame-info').textContent = width + 'x' + height + ' @ ' + fps + ' fps';
}
},
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
});
decoder.configure({
codec: 'avc1.42E01E',
codedWidth: width,
codedHeight: height,
optimizeForLatency: true
});
}
let decoderWidth = 0, decoderHeight = 0, needKeyframe = false;
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);
const dataLen = view.getUint32(5, true);
const isKeyframe = frameType === 1;
// If decoder is closed or errored, wait for keyframe to reinitialize
if (!decoder || decoder.state === 'closed') {
if (isKeyframe && decoderWidth > 0) {
console.log('Reinitializing decoder on keyframe');
initDecoder(decoderWidth, decoderHeight);
needKeyframe = false;
} else {
needKeyframe = true;
return;
}
}
if (decoder.state !== 'configured') return;
// Skip delta frames if we need a keyframe
if (needKeyframe && !isKeyframe) return;
if (isKeyframe) needKeyframe = false;
const h264Data = new Uint8Array(data, 9, dataLen);
try {
// Check decoder queue to avoid overwhelming it (but never skip keyframes)
if (!isKeyframe && decoder.decodeQueueSize > 10) {
needKeyframe = true; // Need keyframe to resync after skipping
return;
}
decoder.decode(new EncodedVideoChunk({
type: isKeyframe ? 'key' : 'delta',
timestamp: decodeTimestamp++,
data: h264Data
}));
} catch (e) {
console.error('Decode error:', e);
needKeyframe = true;
}
}
function getFilteredDevices() {
let filtered = devices;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(d =>
(d.name && d.name.toLowerCase().includes(q)) ||
(d.ip && d.ip.toLowerCase().includes(q)) ||
(d.os && d.os.toLowerCase().includes(q)) ||
(d.location && d.location.toLowerCase().includes(q)) ||
(d.version && d.version.toLowerCase().includes(q)) ||
(d.activeWindow && d.activeWindow.toLowerCase().includes(q))
);
}
return filtered;
}
function onSearchChange() {
searchQuery = document.getElementById('search-input').value;
document.getElementById('search-clear').style.display = searchQuery ? 'block' : 'none';
currentPage = 1;
renderDeviceList();
}
function clearSearch() {
document.getElementById('search-input').value = '';
onSearchChange();
}
function setViewMode(mode) {
viewMode = mode;
document.getElementById('view-grid').classList.toggle('active', mode === 'grid');
document.getElementById('view-list').classList.toggle('active', mode === 'list');
const list = document.getElementById('device-list');
list.classList.toggle('list-view', mode === 'list');
currentPage = 1;
renderDeviceList();
}
function goToPage(page) {
currentPage = page;
renderDeviceList();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function getRttClass(rtt) {
if (!rtt || rtt === '-') return '';
const ms = parseInt(rtt);
if (isNaN(ms)) return '';
if (ms < 100) return 'rtt-good'; // Green: < 100ms
if (ms < 300) return 'rtt-medium'; // Orange: 100-300ms
return 'rtt-poor'; // Red: > 300ms
}
function isWindowBusy(activeWindow) {
if (!activeWindow || activeWindow.trim() === '') return false;
const lower = activeWindow.toLowerCase();
if (lower.includes('locked') || lower.includes('inactive')) return false;
return true;
}
function updateDeviceInfo(deviceId, rtt, activeWindow) {
// Update device in array
const device = devices.find(d => d.id === deviceId || d.id === String(deviceId));
if (!device) return;
let changed = false;
if (rtt && device.rtt !== rtt) {
device.rtt = rtt;
changed = true;
}
if (activeWindow !== undefined && device.activeWindow !== activeWindow) {
device.activeWindow = activeWindow;
changed = true;
}
if (!changed) return;
// Update DOM directly for efficiency (avoid full re-render)
const cards = document.querySelectorAll('.device-card');
for (const card of cards) {
if (card.onclick && card.onclick.toString().includes(deviceId)) {
// Update RTT
const rttEl = card.querySelector('.meta-item.rtt');
if (rttEl && rtt) {
rttEl.textContent = 'RTT: ' + rtt;
rttEl.className = 'meta-item rtt ' + getRttClass(rtt);
}
// Update active window
let winEl = card.querySelector('.active-window');
if (activeWindow) {
if (!winEl) {
winEl = document.createElement('div');
winEl.className = 'active-window';
card.appendChild(winEl);
}
winEl.textContent = activeWindow;
winEl.title = activeWindow;
winEl.className = 'active-window' + (isWindowBusy(activeWindow) ? ' busy' : '');
} else if (winEl) {
winEl.remove();
}
break;
}
}
}
function renderDeviceList() {
const filtered = getFilteredDevices();
const ps = getPageSize();
const totalPages = Math.ceil(filtered.length / ps) || 1;
if (currentPage > totalPages) currentPage = totalPages;
const start = (currentPage - 1) * ps;
const pageDevices = filtered.slice(start, start + ps);
// Update stats
document.getElementById('device-count').textContent = devices.length;
document.getElementById('stat-total').textContent = devices.length;
document.getElementById('stat-showing').textContent = filtered.length;
// Render devices
const list = document.getElementById('device-list');
if (pageDevices.length === 0) {
list.innerHTML = '<div class="no-devices"><h3>No devices found</h3><p>Try adjusting your search or filter</p></div>';
} else {
list.innerHTML = pageDevices.map(d => {
const screenInfo = d.screen || '-'; // e.g. "2:3840x1080"
const loc = d.location || '-';
const rtt = d.rtt || '-';
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>' +
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
'</div>' +
'<div class="info"><span class="info-label">OS:</span> ' + escapeHtml(d.os || '-') + '</div>' +
'<div class="meta-row">' +
'<span class="meta-item rtt ' + getRttClass(rtt) + '">RTT: ' + escapeHtml(rtt) + '</span>' +
'<span class="meta-item">Ver: ' + escapeHtml(ver) + '</span>' +
'<span class="meta-item">' + screenInfo + '</span>' +
'</div>' +
(activeWin ? '<div class="active-window' + (isWindowBusy(activeWin) ? ' busy' : '') + '" title="' + escapeHtml(activeWin) + '">' + escapeHtml(activeWin) + '</div>' : '') +
'</div>';
}).join('');
}
// Render pagination
renderPagination(totalPages);
}
function renderPagination(totalPages) {
const pagination = document.getElementById('pagination');
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
html += '<button onclick="goToPage(1)" ' + (currentPage === 1 ? 'disabled' : '') + '>First</button>';
html += '<button onclick="goToPage(' + (currentPage - 1) + ')" ' + (currentPage === 1 ? 'disabled' : '') + '>Prev</button>';
// Page numbers
let startPage = Math.max(1, currentPage - 2);
let endPage = Math.min(totalPages, startPage + 4);
if (endPage - startPage < 4) startPage = Math.max(1, endPage - 4);
for (let i = startPage; i <= endPage; i++) {
html += '<button onclick="goToPage(' + i + ')" class="' + (i === currentPage ? 'active' : '') + '">' + i + '</button>';
}
html += '<button onclick="goToPage(' + (currentPage + 1) + ')" ' + (currentPage === totalPages ? 'disabled' : '') + '>Next</button>';
html += '<button onclick="goToPage(' + totalPages + ')" ' + (currentPage === totalPages ? 'disabled' : '') + '>Last</button>';
html += '<span class="page-info">Page ' + currentPage + ' of ' + totalPages + '</span>';
pagination.innerHTML = html;
}
function showPage(pageId) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.getElementById(pageId).classList.add('active');
}
function updateScreenStatus(status, msg) {
const el = document.getElementById('screen-status');
el.className = 'screen-status ' + status;
if (status === 'connected') el.textContent = 'Connected';
else if (status === 'connecting') el.textContent = 'Connecting...';
else if (status === 'waiting') el.textContent = msg || 'Waiting...';
else el.textContent = msg || 'Error';
}
function togglePasswordVisibility() {
const input = document.getElementById('password');
const btn = document.querySelector('.password-toggle');
if (input.type === 'password') {
input.type = 'text';
btn.innerHTML = '&#x1F440;'; // Eyes
} else {
input.type = 'password';
btn.innerHTML = '&#x1F441;'; // Eye
}
}
// Pending login state for salt-based auth
let pendingLogin = null;
async function login() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
if (!username || !password) { document.getElementById('login-error').textContent = 'Please enter credentials'; return; }
if (!ws || ws.readyState !== WebSocket.OPEN) { document.getElementById('login-error').textContent = 'Not connected'; return; }
if (!challengeNonce) { document.getElementById('login-error').textContent = 'No challenge received'; return; }
// Store pending login info and request salt first
pendingLogin = { username, password };
ws.send(JSON.stringify({ cmd: 'get_salt', username }));
}
async function completeLogin(salt) {
if (!pendingLogin) return;
const { username, password } = pendingLogin;
pendingLogin = null;
// Compute password hash with salt: SHA256(password + salt)
passwordHash = await sha256(password + salt);
// Compute response: SHA256(passwordHash + nonce)
const response = await sha256(passwordHash + challengeNonce);
ws.send(JSON.stringify({ cmd: 'login', username, response, nonce: challengeNonce }));
}
// Logout 二次确认onclick="logout()" 仅打开确认弹窗;用户点 "Logout" 才真正退出。
function logout() {
document.getElementById('logout-confirm-modal').classList.add('active');
}
function cancelLogout() {
document.getElementById('logout-confirm-modal').classList.remove('active');
}
function confirmLogout() {
document.getElementById('logout-confirm-modal').classList.remove('active');
// Close and reconnect WebSocket to get new challenge
if (ws) {
ws.onclose = null; // Prevent auto-reconnect delay
ws.close();
}
challengeNonce = '';
connectWebSocket(); // Reconnect immediately
token = null;
sessionStorage.removeItem('token');
devices = [];
showPage('login-page');
// Hide users button
document.getElementById('users-btn').classList.remove('visible');
}
// User Management Functions
let currentUserRole = 'viewer';
function openUsersModal() {
document.getElementById('users-modal').classList.add('active');
document.getElementById('user-msg').innerHTML = '';
document.getElementById('new-role').value = 'viewer'; // Reset to default
onRoleChange(); // Update groups section visibility
listUsers();
getGroups();
}
function getGroups() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'get_groups', token }));
}
}
function renderGroupsCheckboxes(groups) {
const container = document.getElementById('groups-checkboxes');
if (!groups || groups.length === 0) {
container.innerHTML = '<span style="color:#666;font-size:12px;">No groups available</span>';
return;
}
container.innerHTML = groups.map(g =>
'<label style="display:flex;align-items:center;padding:3px 0;cursor:pointer;white-space:nowrap;">' +
'<input type="checkbox" value="' + escapeHtml(g) + '" style="margin:0 6px 0 0;flex-shrink:0;width:14px;height:14px;">' +
escapeHtml(g) + '</label>'
).join('');
}
function onRoleChange() {
const role = document.getElementById('new-role').value;
const groupsSection = document.getElementById('groups-section');
groupsSection.style.display = (role === 'admin') ? 'none' : 'block';
}
function closeUsersModal() {
document.getElementById('users-modal').classList.remove('active');
}
function showUserMsg(msg, isError) {
const el = document.getElementById('user-msg');
el.className = 'user-msg ' + (isError ? 'error' : 'success');
el.textContent = msg;
setTimeout(() => { el.innerHTML = ''; }, 3000);
}
function createUser() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) {
showUserMsg('Username and password are required', true);
return;
}
// Collect selected groups
const checkboxes = document.querySelectorAll('#groups-checkboxes input[type="checkbox"]:checked');
const allowed_groups = Array.from(checkboxes).map(cb => cb.value);
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'create_user', token, username, password, role, allowed_groups }));
}
}
function deleteUser(username) {
if (!confirm('Delete user "' + username + '"?')) return;
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'delete_user', token, username }));
}
}
function listUsers() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'list_users', token }));
}
}
function renderUsersList(users) {
const container = document.getElementById('users-list');
if (!users || users.length === 0) {
container.innerHTML = '<div style="color:#666;padding:12px;">No users</div>';
return;
}
container.innerHTML = users.map(u => {
const isAdmin = u.role === 'admin';
const canDelete = u.username !== 'admin'; // Cannot delete built-in admin
const groups = u.allowed_groups || [];
const groupsText = u.username === 'admin' ? '(all)' :
(groups.length > 0 ? groups.join(', ') : '(none)');
return '<div class="user-item">' +
'<div class="user-info">' +
'<div class="username">' + escapeHtml(u.username) + '</div>' +
'<div class="role ' + (isAdmin ? 'admin' : '') + '">' + u.role + '</div>' +
'<div class="groups" style="font-size:11px;color:#888;margin-top:2px;">Groups: ' + escapeHtml(groupsText) + '</div>' +
'</div>' +
(canDelete ? '<button class="delete-btn" onclick="deleteUser(\'' + escapeHtml(u.username) + '\')">Delete</button>' : '') +
'</div>';
}).join('');
}
function getDevices() {
if (!ws || ws.readyState !== WebSocket.OPEN || !token) return;
ws.send(JSON.stringify({ cmd: 'get_devices', token }));
}
function connectDevice(id) {
const dev = devices.find(d => d.id === id || d.id === String(id));
if (!dev || !dev.online) {
alert('Device is offline');
return;
}
// 无显示器screen 字段格式 "n:WxH"n=0 表示无显示器)→ 远程桌面没有意义,
// 直接走终端。
const screenStr = String(dev.screen || '');
const screenCount = parseInt(screenStr.split(':')[0], 10);
if (!isNaN(screenCount) && screenCount === 0) {
openTerminal(id);
return;
}
const compat = checkWebCodecs();
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
currentDevice = dev;
document.getElementById('device-name').textContent = currentDevice.name;
document.getElementById('frame-info').textContent = '';
updateScreenStatus('connecting');
showPage('screen-page');
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');
}
// 终端窗口大小变化 → 通知 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) {}
}
});
function toggleFullscreen() {
const el = document.getElementById('screen-page');
const isFs = document.fullscreenElement || document.webkitFullscreenElement;
const isPseudo = el.classList.contains('pseudo-fullscreen');
// Try native fullscreen first
if (!isPseudo && (el.requestFullscreen || el.webkitRequestFullscreen)) {
if (!isFs) {
(el.requestFullscreen || el.webkitRequestFullscreen).call(el).catch(() => {
// Native fullscreen failed (iOS), use pseudo-fullscreen
el.classList.add('pseudo-fullscreen');
window.scrollTo(0, 1);
updateUIForOrientation();
});
} else {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
}
} else {
// Toggle pseudo-fullscreen (iOS fallback)
el.classList.toggle('pseudo-fullscreen');
if (el.classList.contains('pseudo-fullscreen')) window.scrollTo(0, 1);
updateUIForOrientation();
}
}
// Double-tap to exit pseudo-fullscreen on mobile (only when control mode is OFF)
let lastTapTime = 0;
document.getElementById('screen-canvas').addEventListener('touchend', function(e) {
// When control mode is enabled, double-tap sends double-click, not exit fullscreen
if (controlEnabled) return;
const el = document.getElementById('screen-page');
if (!el.classList.contains('pseudo-fullscreen')) return;
// Only exit fullscreen on single-finger double tap, not during pinch
if (touchState.touchCount > 1 || zoomState.isPinching || zoomState.scale > 1) return;
const now = Date.now();
if (now - lastTapTime < 300 && e.changedTouches.length === 1) {
el.classList.remove('pseudo-fullscreen');
updateUIForOrientation();
e.preventDefault();
}
lastTapTime = now;
});
document.addEventListener('keydown', function(e) {
if (e.key !== 'F11') return;
if (!document.getElementById('screen-page').classList.contains('active')) return;
// In control mode F11 is a remote keystroke — let it fall through
// to the desktop key handler (which calls sendKey). Outside control
// mode F11 toggles our page fullscreen as the local convenience.
if (controlEnabled) return;
e.preventDefault();
toggleFullscreen();
});
// Control mode state (mouse/keyboard control)
let controlEnabled = false;
// Remote cursor mapping (Windows cursor index -> CSS cursor)
// Index matches CursorInfo.h: IDC_APPSTARTING(0) to IDC_WAIT(15), 254=custom, 255=unsupported
// Custom I-beam cursor with white fill and black stroke for visibility on any background
const ibeamCursor = "url('data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"24\" viewBox=\"0 0 16 24\"><path fill=\"none\" stroke=\"white\" stroke-width=\"3\" d=\"M4 3h8M8 3v18M4 21h8\"/><path fill=\"none\" stroke=\"black\" stroke-width=\"1\" d=\"M4 3h8M8 3v18M4 21h8\"/></svg>') 8 12, text";
const cursorMap = [
'progress', // 0: IDC_APPSTARTING
'default', // 1: IDC_ARROW
'crosshair', // 2: IDC_CROSS
'pointer', // 3: IDC_HAND
'help', // 4: IDC_HELP
ibeamCursor, // 5: IDC_IBEAM - custom cursor with outline
'default', // 6: IDC_ICON (no direct CSS equivalent)
'not-allowed', // 7: IDC_NO
'default', // 8: IDC_SIZE (deprecated, use default)
'move', // 9: IDC_SIZEALL
'nesw-resize', // 10: IDC_SIZENESW
'ns-resize', // 11: IDC_SIZENS
'nwse-resize', // 12: IDC_SIZENWSE
'ew-resize', // 13: IDC_SIZEWE
'default', // 14: IDC_UPARROW (no direct CSS equivalent)
'wait' // 15: IDC_WAIT
];
let currentCursorIndex = 1; // Default: arrow
// IDC_* index -> CSS class on .cursor-overlay (touch-mode visual cursor).
// Indices not listed (0,2,4,6,8,14, custom) fall back to default arrow.
const cursorOverlayClassMap = {
3: 'cursor-hand',
5: 'cursor-ibeam',
7: 'cursor-no',
9: 'cursor-move',
10: 'cursor-sizenesw',
11: 'cursor-sizens',
12: 'cursor-sizenwse',
13: 'cursor-sizewe',
15: 'cursor-wait'
};
// Apply the remote cursor: native CSS cursor on desktop, overlay variant on touch.
function applyRemoteCursor(index) {
if (isTouchDevice) {
const overlay = document.getElementById('cursor-overlay');
if (!overlay) return;
const wasActive = overlay.classList.contains('active');
overlay.className = 'cursor-overlay' + (wasActive ? ' active' : '');
const cls = cursorOverlayClassMap[index];
if (cls) overlay.classList.add(cls);
} else {
const canvas = document.getElementById('screen-canvas');
if (!canvas) return;
const cssCursor = (index >= 0 && index < cursorMap.length)
? cursorMap[index]
: 'default';
canvas.style.cursor = cssCursor;
}
}
// Floating toolbar state
let toolbarVisible = false;
let toolbarHideTimer = null;
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement ||
document.getElementById('screen-page')?.classList.contains('pseudo-fullscreen'));
}
// Keyboard Lock keeps F11 / Esc from triggering their browser-default
// behaviour (F11 → toggle browser fullscreen, Esc → exit native page
// fullscreen) while we're actively controlling a remote desktop —
// those keys belong to the remote OS in that mode, not the local
// browser. Lock is no-op on browsers without the API.
function updateKeyboardLock() {
if (!navigator.keyboard || !navigator.keyboard.lock) return;
if (isFullscreen() && controlEnabled) {
navigator.keyboard.lock(['Escape', 'F11']).catch(() => {});
} else {
navigator.keyboard.unlock();
}
}
// Unified fullscreen-change handler. Always clears the floating
// quick-action toolbar (its three buttons duplicate the top-right
// toolbar that becomes visible again on exit — leaving them pinned
// to the top of the page is just noise). Then re-evaluates the
// keyboard lock and, for touch devices, refreshes the orientation-
// dependent layout.
function onFullscreenChange() {
if (!isFullscreen()) {
const fb = document.getElementById('floating-toolbar');
if (fb) fb.classList.remove('visible');
toolbarVisible = false;
if (toolbarHideTimer) {
clearTimeout(toolbarHideTimer);
toolbarHideTimer = null;
}
}
updateKeyboardLock();
if (isTouchDevice) updateUIForOrientation();
}
function toggleFloatingToolbar() {
const toolbar = document.getElementById('floating-toolbar');
toolbarVisible = !toolbarVisible;
toolbar.classList.toggle('visible', toolbarVisible);
// Clear any existing timer
if (toolbarHideTimer) {
clearTimeout(toolbarHideTimer);
toolbarHideTimer = null;
}
// Only auto-hide when NOT in fullscreen (desktop behavior)
// In fullscreen (touch device), user must click again to close
if (toolbarVisible && !isFullscreen()) {
toolbarHideTimer = setTimeout(() => {
toolbarVisible = false;
toolbar.classList.remove('visible');
}, 4000);
}
}
function isLandscape() {
return window.innerWidth > window.innerHeight;
}
// Cached DOM elements (initialized in window.onload)
let uiElements = null;
function initUIElements() {
uiElements = {
quickControls: document.getElementById('quick-controls'),
toolbarToggle: document.getElementById('toolbar-toggle'),
floatingToolbar: document.getElementById('floating-toolbar'),
btnRdpResetBar: document.getElementById('btn-rdp-reset-bar'),
btnMouseBar: document.getElementById('btn-mouse-bar'),
btnKeyboardBar: document.getElementById('btn-keyboard-bar'),
inputShortcuts: document.getElementById('input-shortcuts')
};
}
function updateUIForOrientation() {
if (!isTouchDevice || !uiElements) return;
// Clear any pending toolbar hide timer when orientation/fullscreen changes
if (toolbarHideTimer) {
clearTimeout(toolbarHideTimer);
toolbarHideTimer = null;
}
const { quickControls, toolbarToggle, floatingToolbar,
btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements;
if (isLandscape()) {
// Landscape mode
quickControls.classList.remove('visible');
inputShortcuts.classList.remove('visible');
if (isFullscreen()) {
// Landscape fullscreen: show three-dot menu
toolbarToggle.classList.remove('ui-hidden');
btnRdpResetBar.classList.add('ui-hidden');
btnMouseBar.classList.add('ui-hidden');
btnKeyboardBar.classList.add('ui-hidden');
} else {
// Landscape non-fullscreen: show top toolbar buttons
toolbarToggle.classList.add('ui-hidden');
floatingToolbar.classList.remove('visible');
toolbarVisible = false;
btnRdpResetBar.classList.remove('ui-hidden');
btnMouseBar.classList.remove('ui-hidden');
btnKeyboardBar.classList.remove('ui-hidden');
}
} else {
// Portrait mode: show quick controls
quickControls.classList.add('visible');
// Input shortcuts only visible when keyboard is open
const keyboardOpen = document.activeElement === mobileKeyboard;
inputShortcuts.classList.toggle('visible', keyboardOpen);
toolbarToggle.classList.add('ui-hidden');
floatingToolbar.classList.remove('visible');
toolbarVisible = false;
btnRdpResetBar.classList.add('ui-hidden');
btnMouseBar.classList.add('ui-hidden');
btnKeyboardBar.classList.add('ui-hidden');
}
// Position input shortcuts below canvas
updateInputShortcutsPosition();
}
// Position input shortcuts bar below the canvas
function updateInputShortcutsPosition() {
if (!isTouchDevice || !uiElements || !uiElements.inputShortcuts) return;
if (isLandscape()) return; // Only for portrait mode
const canvas = document.getElementById('screen-canvas');
const shortcuts = uiElements.inputShortcuts;
if (!canvas || canvas.offsetHeight === 0) return;
// Position shortcuts 8px below canvas
const canvasBottom = canvas.offsetTop + canvas.offsetHeight;
shortcuts.style.top = (canvasBottom + 8) + 'px';
}
function sendRdpReset() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'rdp_reset', token }));
// Visual feedback - rotate icon
const btn = event.target;
btn.style.transform = 'rotate(360deg)';
btn.style.transition = 'transform 0.5s';
setTimeout(() => {
btn.style.transform = '';
btn.style.transition = '';
}, 500);
}
}
// Detect touch device (mobile/tablet)
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
function toggleControl() {
controlEnabled = !controlEnabled;
// Update floating toolbar buttons
const btnMouse = document.getElementById('btn-mouse');
btnMouse.classList.toggle('active', controlEnabled);
// Update top toolbar buttons (sync state)
const btnMouseBar = document.getElementById('btn-mouse-bar');
btnMouseBar.classList.toggle('active', controlEnabled);
// Update quick controls buttons
const qcMouse = document.getElementById('qc-mouse');
if (qcMouse) qcMouse.classList.toggle('active', controlEnabled);
// Desktop only: keyboard requires mouse control enabled
if (!isTouchDevice) {
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.disabled = !controlEnabled;
if (btnKeyboardBar) btnKeyboardBar.disabled = !controlEnabled;
}
// Cursor handling
const canvas = document.getElementById('screen-canvas');
const cursorOverlay = document.getElementById('cursor-overlay');
// Touch: hide native cursor and show our overlay (touchpad mode).
// Desktop: native CSS cursor is set by applyRemoteCursor below.
if (controlEnabled && isTouchDevice) {
canvas.style.cursor = 'none';
} else if (!controlEnabled) {
canvas.style.cursor = 'default';
}
cursorOverlay.classList.toggle('active', controlEnabled && isTouchDevice);
if (controlEnabled) {
applyRemoteCursor(currentCursorIndex);
}
// Sync the keyboard lock: in fullscreen + control mode, ESC and F11
// must be passed through to the remote OS instead of triggering the
// browser's exit-fullscreen / toggle-fullscreen behavior. The
// updateKeyboardLock helper no-ops if either condition is missing
// or the API is unavailable.
updateKeyboardLock();
}
// Update cursor overlay position (accounting for zoom/pan transform)
// Only used on touch devices (touchpad mode)
function updateCursorOverlay(canvasX, canvasY) {
if (!controlEnabled || !isTouchDevice) return;
const canvas = document.getElementById('screen-canvas');
const cursorOverlay = document.getElementById('cursor-overlay');
// Get canvas base position (without transform, use container + offset)
// offsetLeft/offsetTop are layout positions, unaffected by CSS transform
const container = document.querySelector('.canvas-container');
const containerRect = container.getBoundingClientRect();
const canvasDisplayWidth = canvas.offsetWidth;
const canvasDisplayHeight = canvas.offsetHeight;
const canvasLeft = containerRect.left + canvas.offsetLeft;
const canvasTop = containerRect.top + canvas.offsetTop;
// Convert canvas coords to position on unzoomed canvas
// Map [0, width-1] to [0, 1] for proper edge-to-edge display
const relX = canvas.width > 1 ? canvasX / (canvas.width - 1) : 0;
const relY = canvas.height > 1 ? canvasY / (canvas.height - 1) : 0;
// Position on unzoomed canvas (in pixels from canvas top-left)
const unzoomedX = relX * canvasDisplayWidth;
const unzoomedY = relY * canvasDisplayHeight;
// Apply zoom transform
// Transform origin
const originX = canvasDisplayWidth * zoomState.originX / 100;
const originY = canvasDisplayHeight * zoomState.originY / 100;
// Scale around origin, then translate
const scaledX = originX + (unzoomedX - originX) * zoomState.scale + zoomState.translateX * zoomState.scale;
const scaledY = originY + (unzoomedY - originY) * zoomState.scale + zoomState.translateY * zoomState.scale;
// Final screen position
const screenX = canvasLeft + scaledX;
const screenY = canvasTop + scaledY;
cursorOverlay.style.left = screenX + 'px';
cursorOverlay.style.top = screenY + 'px';
}
// Inverse transform: screen position -> canvas coordinates
// Used to recalculate cursor position after zoom/pan
function screenToCanvas(screenX, screenY) {
const canvas = document.getElementById('screen-canvas');
// Get canvas base position (without transform, use container + offset)
const container = document.querySelector('.canvas-container');
const containerRect = container.getBoundingClientRect();
const canvasDisplayWidth = canvas.offsetWidth;
const canvasDisplayHeight = canvas.offsetHeight;
const canvasLeft = containerRect.left + canvas.offsetLeft;
const canvasTop = containerRect.top + canvas.offsetTop;
// Reverse the transform chain
const scaledX = screenX - canvasLeft;
const scaledY = screenY - canvasTop;
const originX = canvasDisplayWidth * zoomState.originX / 100;
const originY = canvasDisplayHeight * zoomState.originY / 100;
// Reverse: scaledX = originX + (unzoomedX - originX) * scale + translateX * scale
const unzoomedX = (scaledX - originX) / zoomState.scale + originX - zoomState.translateX;
const unzoomedY = (scaledY - originY) / zoomState.scale + originY - zoomState.translateY;
// Reverse: unzoomedX = relX * canvasDisplayWidth
const relX = canvasDisplayWidth > 0 ? unzoomedX / canvasDisplayWidth : 0;
const relY = canvasDisplayHeight > 0 ? unzoomedY / canvasDisplayHeight : 0;
// Reverse: relX = canvasX / (canvas.width - 1)
const canvasX = relX * (canvas.width - 1);
const canvasY = relY * (canvas.height - 1);
// Clamp to valid range
return {
x: Math.max(0, Math.min(canvas.width - 1, canvasX)),
y: Math.max(0, Math.min(canvas.height - 1, canvasY))
};
}
// Two-finger gesture constants
// 缩放 vs 滚动判定(仅在双指 + 触摸场景):
// 1) 间距比例变化 > ZOOM_THRESHOLD 且
// 2) 间距绝对变化 > ZOOM_MIN_PX 且
// 3) 间距变化 > 中心垂直位移 → 判为缩放
// 三个条件同时满足才触发缩放,避免双指上下滚动时手指自然张合的 ~5% 抖动被误判。
// 真实缩放:双指主动张合,间距变化幅度本身就远大于这些阈值。
const ZOOM_THRESHOLD = 0.15; // 间距比例变化阈值15%
const ZOOM_MIN_PX = 30; // 间距绝对变化阈值px
// 注:服务端 (WebService.cpp HandleMouse) 把 wheelDelta 钳成 ±120 一格 notch
// SCROLL_SENSITIVITY 实际不起作用;真正决定滚动速度的是 SCROLL_DEADZONE。
// 触摸 touchmove 在 60fps 下高频触发,旧值 2px 等于手指动一下就连发一堆 notch体感非常飘。
// 现在 28px ≈ 让手指移动 ~1 个文本行距离才发一格,接近 iOS 原生触摸滚动节奏。
const SCROLL_SENSITIVITY = 1; // (server clamps to ±120, kept for clarity only)
const SCROLL_DEADZONE = 28; // Minimum finger-Y delta (px) to send one wheel notch
// Pinch-to-zoom state
let zoomState = {
scale: 1,
minScale: 1,
maxScale: 4,
translateX: 0,
translateY: 0,
lastPinchDist: 0,
isPinching: false,
pinchCenterX: 0,
pinchCenterY: 0,
// Transform origin relative to canvas (percentage)
originX: 50,
originY: 50,
// Two-finger gesture detection
hasZoomed: false, // Whether zoom occurred in current gesture
lastScrollY: 0, // For scroll delta calculation
initialPinchDist: 0, // Distance at gesture start (for cumulative detection)
initialCenterX: 0, // Pinch center at gesture start (for scroll-vs-zoom intent)
initialCenterY: 0
};
const zoomIndicator = document.getElementById('zoom-indicator');
let zoomIndicatorTimer = null;
function showZoomIndicator() {
zoomIndicator.textContent = Math.round(zoomState.scale * 100) + '%';
zoomIndicator.classList.add('visible');
if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer);
zoomIndicatorTimer = setTimeout(() => {
zoomIndicator.classList.remove('visible');
}, 1500);
}
function resetZoom() {
zoomState.scale = 1;
zoomState.translateX = 0;
zoomState.translateY = 0;
applyZoomTransform();
showZoomIndicator();
}
// Track previous scale to detect zoom reset
let prevScale = 1;
function applyZoomTransform() {
const container = document.querySelector('.canvas-container');
if (zoomState.scale === 1) {
canvas.style.transform = '';
canvas.style.transformOrigin = '';
// Reset all zoom state when scale returns to 1
zoomState.originX = 50;
zoomState.originY = 50;
zoomState.translateX = 0;
zoomState.translateY = 0;
// If we just returned from zoomed state, force cursor overlay update
if (prevScale !== 1 && controlEnabled) {
console.log('[Zoom] Reset from ' + prevScale.toFixed(2) + ' to 1, updating cursor overlay');
updateCursorOverlay(cursorState.x, cursorState.y);
}
prevScale = 1;
} else {
prevScale = zoomState.scale;
// Clamp translate to prevent canvas from going out of view
const rect = container.getBoundingClientRect();
const maxTranslateX = (canvas.width * zoomState.scale - rect.width) / 2 / zoomState.scale;
const maxTranslateY = (canvas.height * zoomState.scale - rect.height) / 2 / zoomState.scale;
if (canvas.width * zoomState.scale > rect.width) {
zoomState.translateX = Math.max(-maxTranslateX, Math.min(maxTranslateX, zoomState.translateX));
} else {
zoomState.translateX = 0;
}
if (canvas.height * zoomState.scale > rect.height) {
zoomState.translateY = Math.max(-maxTranslateY, Math.min(maxTranslateY, zoomState.translateY));
} else {
zoomState.translateY = 0;
}
// Use pinch center as transform origin
canvas.style.transformOrigin = zoomState.originX + '% ' + zoomState.originY + '%';
canvas.style.transform = 'scale(' + zoomState.scale + ') translate(' + zoomState.translateX + 'px, ' + zoomState.translateY + 'px)';
}
}
function getPinchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getPinchCenter(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2
};
}
// Mobile touch handling (touchpad mode - like Microsoft Remote Desktop)
// Cursor position is separate from finger position
let cursorState = { x: 0, y: 0, initialized: false }; // Remote cursor position
// Touch state machine - delayed mousedown approach
// Key insight: Don't send mousedown on first touchstart
// This allows clean separation of "move cursor" vs "click/drag"
//
// States:
// IDLE: No touch
// FIRST_DOWN: First finger down, waiting to see intent
// MOVING: User is moving cursor (no mouse buttons involved)
// WAITING_SECOND: First tap done, waiting for possible second tap
// SECOND_DOWN: Second tap down, waiting to see drag or dblclick
// DRAGGING: Confirmed drag operation (mousedown sent)
const T_IDLE = 0;
const T_FIRST_DOWN = 1;
const T_MOVING = 2;
const T_WAITING_SECOND = 3;
const T_SECOND_DOWN = 4;
const T_DRAGGING = 5;
let touchState = {
state: T_IDLE,
longPressTimer: null,
lastX: 0, lastY: 0,
touchCount: 0,
startX: 0, startY: 0,
startTime: 0,
moved: false,
secondTapTimer: null // Timer for waiting second tap
};
const touchIndicator = document.getElementById('touch-indicator');
const mobileKeyboard = document.getElementById('mobile-keyboard');
// Initialize cursor to center of screen
function initCursor() {
if (!cursorState.initialized && canvas.width > 0) {
cursorState.x = Math.round(canvas.width / 2);
cursorState.y = Math.round(canvas.height / 2);
cursorState.initialized = true;
updateCursorOverlay(cursorState.x, cursorState.y);
}
}
// Move cursor by delta (touchpad mode)
function moveCursorBy(dx, dy) {
initCursor();
// Sensitivity multiplier (adjust for comfortable control)
const sensitivity = 1.5;
cursorState.x = Math.max(0, Math.min(canvas.width - 1, cursorState.x + dx * sensitivity));
cursorState.y = Math.max(0, Math.min(canvas.height - 1, cursorState.y + dy * sensitivity));
updateCursorOverlay(cursorState.x, cursorState.y);
// Send move to remote
sendMouse('move', Math.round(cursorState.x), Math.round(cursorState.y), 0);
}
// Click at current cursor position
function clickAtCursor(button) {
initCursor();
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('down', x, y, button);
sendMouse('up', x, y, button);
}
function dblClickAtCursor() {
initCursor();
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('dblclick', x, y, 0);
}
function getTouchPos(touch) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.round((touch.clientX - rect.left) * scaleX),
y: Math.round((touch.clientY - rect.top) * scaleY)
};
}
function sendMouse(type, x, y, button, delta) {
if (!controlEnabled) return; // Control mode required
// Update cursor overlay position
updateCursorOverlay(x, y);
if (ws && ws.readyState === WebSocket.OPEN && token) {
const msg = { cmd: 'mouse', token, type, x, y, button: button || 0 };
if (delta !== undefined) msg.delta = delta;
ws.send(JSON.stringify(msg));
}
}
function sendKey(keyCode, isDown, altKey) {
if (!controlEnabled) return; // Control mode required
// Filter Windows keys (handled locally, not sent to remote)
if (keyCode === 91 || keyCode === 92) return; // VK_LWIN, VK_RWIN
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: !!altKey }));
}
}
function showTouchIndicator(x, y) {
touchIndicator.style.left = x + 'px';
touchIndicator.style.top = y + 'px';
touchIndicator.style.display = 'block';
setTimeout(() => touchIndicator.style.display = 'none', 200);
}
canvas.addEventListener('touchstart', function(e) {
e.preventDefault();
e.stopPropagation();
touchState.touchCount = e.touches.length;
if (e.touches.length === 2) {
// Two finger - pinch zoom or scroll
zoomState.isPinching = true;
const initialDist = getPinchDistance(e.touches);
zoomState.initialPinchDist = initialDist;
zoomState.lastPinchDist = initialDist;
zoomState.hasZoomed = false;
const center = getPinchCenter(e.touches);
zoomState.pinchCenterX = center.x;
zoomState.pinchCenterY = center.y;
zoomState.initialCenterX = center.x;
zoomState.initialCenterY = center.y;
zoomState.lastScrollY = center.y;
if (zoomState.scale === 1) {
const rect = canvas.getBoundingClientRect();
const relX = (center.x - rect.left) / rect.width * 100;
const relY = (center.y - rect.top) / rect.height * 100;
zoomState.originX = Math.max(0, Math.min(100, relX));
zoomState.originY = Math.max(0, Math.min(100, relY));
}
// Clean up single-touch state
if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; }
if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; }
// If we were dragging, end it
if (touchState.state === T_DRAGGING) {
sendMouse('up', Math.round(cursorState.x), Math.round(cursorState.y), 0);
}
touchState.state = T_IDLE;
return;
}
// Single finger touch
initCursor();
const touch = e.touches[0];
const oldStartX = touchState.startX;
const oldStartY = touchState.startY;
touchState.startX = touch.clientX;
touchState.startY = touch.clientY;
touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY;
touchState.moved = false;
touchState.startTime = Date.now();
zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY;
if (!controlEnabled) {
touchState.state = T_FIRST_DOWN;
return;
}
if (touchState.state === T_WAITING_SECOND) {
// Second tap detected
if (touchState.secondTapTimer) { clearTimeout(touchState.secondTapTimer); touchState.secondTapTimer = null; }
// Check distance from first tap
const dx = touch.clientX - oldStartX;
const dy = touch.clientY - oldStartY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 50) {
// Too far - treat as new first tap, complete previous click first
clickAtCursor(0);
console.log('[Touch] Second tap too far (' + Math.round(dist) + 'px), new first tap');
// Move cursor to new position
const rect = canvas.getBoundingClientRect();
cursorState.x = Math.max(0, Math.min(canvas.width - 1, cursorState.x + dx * canvas.width / rect.width * 1.5));
cursorState.y = Math.max(0, Math.min(canvas.height - 1, cursorState.y + dy * canvas.height / rect.height * 1.5));
updateCursorOverlay(cursorState.x, cursorState.y);
touchState.state = T_FIRST_DOWN;
// Set up long press for right click
touchState.longPressTimer = setTimeout(() => {
if (!touchState.moved && touchState.state === T_FIRST_DOWN) {
clickAtCursor(2);
showTouchIndicator(touch.clientX, touch.clientY);
touchState.state = T_IDLE;
}
touchState.longPressTimer = null;
}, 500);
} else {
// Close enough - this is second tap for double-click or drag
touchState.state = T_SECOND_DOWN;
console.log('[Touch] Second tap - waiting for drag or dblclick');
}
} else {
// First tap
if (touchState.longPressTimer) { clearTimeout(touchState.longPressTimer); touchState.longPressTimer = null; }
touchState.state = T_FIRST_DOWN;
console.log('[Touch] First tap');
// Long press for right click
touchState.longPressTimer = setTimeout(() => {
if (!touchState.moved && touchState.state === T_FIRST_DOWN) {
clickAtCursor(2);
showTouchIndicator(touch.clientX, touch.clientY);
touchState.state = T_IDLE;
}
touchState.longPressTimer = null;
}, 500);
}
}, { passive: false });
canvas.addEventListener('touchmove', function(e) {
e.preventDefault();
e.stopPropagation(); // Prevent exiting fullscreen
if (e.touches.length === 2 && zoomState.isPinching) {
// Two finger move - zoom+pan or scroll
const newDist = getPinchDistance(e.touches);
const newCenter = getPinchCenter(e.touches);
const frameDelta = newDist / zoomState.lastPinchDist; // Frame-by-frame change
const totalDelta = newDist / zoomState.initialPinchDist; // Cumulative change from gesture start
// Detect gesture type: zoom vs scroll
// 缩放意图 = 间距比例显著变 + 间距绝对显著变 + 间距变化 > 中心垂直位移
// 单纯比例阈值 5% 太敏感:双指上下滚动时手指自然张合 ~5% 就被误判为缩放。
// 结合绝对像素阈值 + "间距变化 vs 中心位移" 的方向性,能稳定区分两种意图。
const distAbsChange = Math.abs(newDist - zoomState.initialPinchDist);
const distRatioChange = Math.abs(totalDelta - 1);
const centerMoveY = Math.abs(newCenter.y - zoomState.initialCenterY);
const isClearZoom = distRatioChange > ZOOM_THRESHOLD &&
distAbsChange > ZOOM_MIN_PX &&
distAbsChange > centerMoveY;
// 已到缩放边界仍朝同方向尝试 → 给视觉反馈,但要求绝对像素变化 > 半阈值,
// 避免静止时手指轻微抖动也被认为"想缩放"。
const atMinScale = zoomState.scale <= zoomState.minScale;
const atMaxScale = zoomState.scale >= zoomState.maxScale;
const tryingToShrink = totalDelta < 1;
const tryingToEnlarge = totalDelta > 1;
const boundaryFeedback = distAbsChange > ZOOM_MIN_PX / 2 && (
(atMinScale && tryingToShrink) || (atMaxScale && tryingToEnlarge)
);
if (isClearZoom || boundaryFeedback) {
zoomState.hasZoomed = true;
}
if (zoomState.hasZoomed) {
// Zoom + pan mode (once zoomed in this gesture, stay in this mode)
const newScale = Math.max(zoomState.minScale, Math.min(zoomState.maxScale, zoomState.scale * frameDelta));
const dx = newCenter.x - zoomState.pinchCenterX;
const dy = newCenter.y - zoomState.pinchCenterY;
// Pan when zoomed or zooming
if (zoomState.scale > 1 || newScale > 1) {
zoomState.translateX += dx / zoomState.scale;
zoomState.translateY += dy / zoomState.scale;
}
// Update state
zoomState.pinchCenterX = newCenter.x;
zoomState.pinchCenterY = newCenter.y;
zoomState.lastPinchDist = newDist;
if (newScale !== zoomState.scale) {
zoomState.scale = newScale;
showZoomIndicator();
}
applyZoomTransform();
// Note: cursor overlay stays fixed on screen during zoom (Microsoft RD style)
// cursorState will be recalculated on touchend via screenToCanvas()
} else {
// Scroll mode (no zoom occurred yet)
const scrollDelta = newCenter.y - zoomState.lastScrollY;
if (Math.abs(scrollDelta) > SCROLL_DEADZONE) {
initCursor();
// Send wheel event at cursor position
sendMouse('wheel', Math.round(cursorState.x), Math.round(cursorState.y), 0, -scrollDelta * SCROLL_SENSITIVITY);
zoomState.lastScrollY = newCenter.y;
}
// Always update state to prevent jump if user starts zooming
zoomState.pinchCenterX = newCenter.x;
zoomState.pinchCenterY = newCenter.y;
zoomState.lastPinchDist = newDist;
}
return;
}
if (e.touches.length === 1 && zoomState.scale > 1 && touchState.state !== T_DRAGGING && !controlEnabled) {
// Pan when zoomed (only when control mode is OFF - view-only mode)
const touch = e.touches[0];
const dx = touch.clientX - zoomState.pinchCenterX;
const dy = touch.clientY - zoomState.pinchCenterY;
// Only pan if finger moved significantly (prevents accidental pan on tap)
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
// Cancel tap detection since user is panning
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
zoomState.translateX += dx / zoomState.scale;
zoomState.translateY += dy / zoomState.scale;
zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY;
applyZoomTransform();
}
return;
}
// Single finger move - state machine based
const touch = e.touches[0];
const dx = touch.clientX - touchState.lastX;
const dy = touch.clientY - touchState.lastY;
const totalDx = touch.clientX - touchState.startX;
const totalDy = touch.clientY - touchState.startY;
const totalDist = Math.sqrt(totalDx * totalDx + totalDy * totalDy);
// Different thresholds for different states
const moveThreshold = (touchState.state === T_SECOND_DOWN) ? 10 : 20;
if (totalDist > moveThreshold && !touchState.moved) {
touchState.moved = true;
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
}
const rect = canvas.getBoundingClientRect();
const canvasDx = dx * canvas.width / rect.width;
const canvasDy = dy * canvas.height / rect.height;
// State machine transitions and actions
if (touchState.state === T_FIRST_DOWN && touchState.moved) {
// First tap + move = pure cursor movement (no click involved)
touchState.state = T_MOVING;
console.log('[Touch] Moving cursor');
}
if (touchState.state === T_SECOND_DOWN && touchState.moved) {
// Second tap + move = START DRAG NOW
// Send mousedown at CURRENT position (after some movement)
// This prevents Windows from treating it as double-click
touchState.state = T_DRAGGING;
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('down', x, y, 0);
const overlay = document.getElementById('cursor-overlay');
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) hue-rotate(90deg)';
console.log('[Touch] Drag started at', x, y);
}
// Move cursor based on state
if (touchState.state === T_MOVING || touchState.state === T_DRAGGING) {
moveCursorBy(canvasDx, canvasDy);
}
touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY;
}, { passive: false });
canvas.addEventListener('touchend', function(e) {
e.preventDefault();
e.stopPropagation(); // Prevent exiting fullscreen
// Handle pinch end
if (zoomState.isPinching) {
if (e.touches.length < 2) {
// Recalculate cursor position after zoom/pan (Microsoft RD style)
// Cursor stayed fixed on screen, so reverse-calculate its new canvas coords
if (cursorState.initialized && zoomState.hasZoomed) {
const cursorOverlay = document.getElementById('cursor-overlay');
const cursorScreenX = parseFloat(cursorOverlay.style.left) || 0;
const cursorScreenY = parseFloat(cursorOverlay.style.top) || 0;
const newCoords = screenToCanvas(cursorScreenX, cursorScreenY);
cursorState.x = newCoords.x;
cursorState.y = newCoords.y;
}
zoomState.isPinching = false;
// Update pan center and lastX/lastY for smooth transition
if (e.touches.length === 1) {
const touch = e.touches[0];
zoomState.pinchCenterX = touch.clientX;
zoomState.pinchCenterY = touch.clientY;
// Reset lastX/lastY to prevent delta jump on next move
touchState.lastX = touch.clientX;
touchState.lastY = touch.clientY;
}
}
touchState.touchCount = e.touches.length;
return;
}
// State machine based touchend
const overlay = document.getElementById('cursor-overlay');
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
if (touchState.longPressTimer) {
clearTimeout(touchState.longPressTimer);
touchState.longPressTimer = null;
}
if (touchState.state === T_DRAGGING) {
// End drag
sendMouse('up', x, y, 0);
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))';
console.log('[Touch] Drag ended at', x, y);
touchState.state = T_IDLE;
} else if (touchState.state === T_SECOND_DOWN) {
// Second tap released without moving = double click
// Must send first click before dblclick for Windows to recognize
console.log('[Touch] Double click');
clickAtCursor(0); // First click
dblClickAtCursor(); // Then double click
touchState.state = T_IDLE;
} else if (touchState.state === T_FIRST_DOWN && !touchState.moved) {
// First tap released without moving = single click
// Wait briefly for possible second tap
overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) brightness(2)';
setTimeout(() => overlay.style.filter = 'drop-shadow(2px 2px 3px rgba(0,0,0,0.6))', 150);
console.log('[Touch] First tap done, waiting for second');
touchState.state = T_WAITING_SECOND;
// Set timer: if no second tap, complete the single click
touchState.secondTapTimer = setTimeout(() => {
if (touchState.state === T_WAITING_SECOND) {
console.log('[Touch] Single click (no second tap)');
clickAtCursor(0);
touchState.state = T_IDLE;
}
touchState.secondTapTimer = null;
}, 250);
} else if (touchState.state === T_MOVING) {
// Was just moving cursor, no action needed
console.log('[Touch] Cursor move done');
touchState.state = T_IDLE;
} else {
touchState.state = T_IDLE;
}
touchState.moved = false;
touchState.touchCount = e.touches.length;
}, { passive: false });
let lastKeyboardBtnTouch = 0; // Timestamp of last keyboard button touch
let wasKeyboardFocused = false; // Capture focus state at touchstart (before blur)
function updateKeyboardButtons(active) {
// Update all keyboard buttons across different toolbars
const qcKeyboard = document.getElementById('qc-keyboard');
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (qcKeyboard) qcKeyboard.classList.toggle('active', active);
if (btnKeyboard) btnKeyboard.classList.toggle('active', active);
if (btnKeyboardBar) btnKeyboardBar.classList.toggle('active', active);
// Show/hide input shortcuts based on keyboard state (portrait only)
const shortcuts = document.getElementById('input-shortcuts');
if (shortcuts && !isLandscape()) {
shortcuts.classList.toggle('visible', active);
if (active) updateInputShortcutsPosition();
}
}
function toggleKeyboard() {
// Use focus state captured at touchstart (more reliable than button's active class)
const isOpen = wasKeyboardFocused;
wasKeyboardFocused = false; // Reset
if (isOpen) {
mobileKeyboard.blur();
updateKeyboardButtons(false);
} else {
mobileKeyboard.focus();
updateKeyboardButtons(true);
}
}
mobileKeyboard.addEventListener('focus', function() {
updateKeyboardButtons(true);
});
mobileKeyboard.addEventListener('blur', function() {
// Skip if keyboard button was touched within last 300ms
if (Date.now() - lastKeyboardBtnTouch < 300) return;
updateKeyboardButtons(false);
});
function sendRightClick() {
// Use cursor position (canvas coordinates), not touch position (screen coordinates)
const x = Math.round(cursorState.x);
const y = Math.round(cursorState.y);
sendMouse('down', x, y, 2);
sendMouse('up', x, y, 2);
}
// Desktop mouse handling
let lastMouseMove = 0;
let lastMouseX = 0, lastMouseY = 0;
function getMousePos(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY)
};
}
canvas.addEventListener('mousedown', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('down', pos.x, pos.y, e.button);
});
canvas.addEventListener('mouseup', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('up', pos.x, pos.y, e.button);
});
// dblclick handler - server will forward only to macOS clients
canvas.addEventListener('dblclick', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('dblclick', pos.x, pos.y, e.button);
});
canvas.addEventListener('mousemove', function(e) {
const now = Date.now();
const pos = getMousePos(e);
// Always update cursor overlay for smooth visual feedback
updateCursorOverlay(pos.x, pos.y);
// Throttle network sends
const dx = Math.abs(pos.x - lastMouseX);
const dy = Math.abs(pos.y - lastMouseY);
const distSq = dx * dx + dy * dy;
const minInterval = distSq > 2500 ? 33 : 50;
if (now - lastMouseMove < minInterval && distSq < 100) return;
lastMouseMove = now;
lastMouseX = pos.x;
lastMouseY = pos.y;
sendMouse('move', pos.x, pos.y, 0);
});
canvas.addEventListener('wheel', function(e) {
e.preventDefault();
const pos = getMousePos(e);
sendMouse('wheel', pos.x, pos.y, 0, e.deltaY);
}, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
// Desktop keyboard handling
document.addEventListener('keydown', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return;
if (e.target.tagName === 'INPUT') return;
// F11 belongs to the local fullscreen toggle UNLESS we're actively
// controlling the remote — see the F11 handler above. Skip here
// to let that one handle it.
if (e.key === 'F11' && !controlEnabled) return;
e.preventDefault();
sendKey(e.keyCode, true, e.altKey);
});
document.addEventListener('keyup', function(e) {
if (!document.getElementById('screen-page').classList.contains('active')) return;
if (e.target.tagName === 'INPUT') return;
if (e.key === 'F11' && !controlEnabled) return;
e.preventDefault();
sendKey(e.keyCode, false, e.altKey);
});
// 字符 → Win32 VK 映射表(提到 listener 外避免每次输入重建)。
// US 键盘上"不需要 Shift"的 OEM 符号 ASCII 码 ≠ VK 码,必须显式映射。
const SHIFT_SYMBOLS = '~!@#$%^&*()_+{}|:"<>?';
const SYMBOL_VK_MAP = {
// —— Unshifted OEM symbols (US layout) ——
'`': 192, '-': 189, '=': 187, '[': 219, ']': 221, '\\': 220,
';': 186, "'": 222, ',': 188, '.': 190, '/': 191,
// —— Shifted symbols (与 above 共享 VK多了 Shift 修饰) ——
'~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
'^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
'+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
'"': 222, '<': 188, '>': 190, '?': 191
};
// 把单个字符发成一对 keyDown/keyUp必要时夹一对 Shift
function sendCharAsKey(ch) {
const isUpperCase = ch >= 'A' && ch <= 'Z';
const needsShift = isUpperCase || SHIFT_SYMBOLS.includes(ch);
const keyCode = SYMBOL_VK_MAP[ch] || ch.toUpperCase().charCodeAt(0);
if (needsShift) sendKey(16, true); // VK_SHIFT = 16
sendKey(keyCode, true);
sendKey(keyCode, false);
if (needsShift) sendKey(16, false);
}
mobileKeyboard.addEventListener('input', function(e) {
// e.data 可能携带多个字符(中/日/韩 IME 候选词上屏、Gboard 滑行输入、
// 剪贴板粘贴一段文本都会一次性 commit。逐字符发送保证每个都到达 host。
const text = e.data;
if (text) {
for (const ch of text) sendCharAsKey(ch);
}
mobileKeyboard.value = '';
});
mobileKeyboard.addEventListener('keydown', function(e) {
const keyMap = {
'Backspace': 8, 'Tab': 9, 'Enter': 13, 'Escape': 27,
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
'Delete': 46
};
if (keyMap[e.key]) {
e.preventDefault();
sendKey(keyMap[e.key], true);
sendKey(keyMap[e.key], false);
}
});
function disconnect() {
// Reset control mode
controlEnabled = false;
// Reset keyboard state (blur event will update button state)
mobileKeyboard.blur();
// Reset floating toolbar buttons
const btnMouse = document.getElementById('btn-mouse');
if (btnMouse) btnMouse.classList.remove('active');
// Reset top toolbar buttons
const btnMouseBar = document.getElementById('btn-mouse-bar');
if (btnMouseBar) btnMouseBar.classList.remove('active');
// Desktop only: disable keyboard button
if (!isTouchDevice) {
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.disabled = true;
if (btnKeyboardBar) btnKeyboardBar.disabled = true;
}
// Reset quick control buttons
const qcMouse = document.getElementById('qc-mouse');
if (qcMouse) qcMouse.classList.remove('active');
document.getElementById('screen-canvas').style.cursor = 'default';
// Strip any cursor-* variant on the overlay, leaving the bare default arrow.
document.getElementById('cursor-overlay').className = 'cursor-overlay';
currentCursorIndex = 1; // Reset to default arrow
// Reset zoom state
zoomState.scale = 1;
zoomState.translateX = 0;
zoomState.translateY = 0;
zoomState.isPinching = false;
canvas.style.transform = '';
canvas.style.transformOrigin = '';
if (decoder) { try { decoder.close(); } catch(e) {} decoder = null; }
if (ws && ws.readyState === WebSocket.OPEN && token && currentDevice) {
ws.send(JSON.stringify({ cmd: 'disconnect', token, id: String(currentDevice.id) }));
}
currentDevice = null; // Clear current device
showPage('devices-page');
getDevices();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function startPingInterval() {
if (pingInterval) clearInterval(pingInterval);
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN && token) ws.send(JSON.stringify({ cmd: 'ping', token }));
}, 30000);
}
function stopPingInterval() {
if (pingInterval) { clearInterval(pingInterval); pingInterval = null; }
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && document.querySelector('.page.active').id === 'login-page') login();
});
// Handle page visibility change (iOS PWA background/foreground)
let backgroundDisconnectTimer = null;
const BACKGROUND_TIMEOUT_MOBILE = 30000; // 30s for mobile/tablet
function doBackgroundDisconnect() {
cancelReconnect();
stopPingInterval();
if (ws) {
ws.onclose = null;
ws.close();
ws = null;
}
updateWsStatus('disconnected');
}
document.addEventListener('visibilitychange', () => {
isPageVisible = !document.hidden;
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 || onTermPage) {
// 屏幕预览 / 终端:移动端给 30 秒宽限,期间切回应用就无缝继续;
// 桌面:保持长连,靠 ping 心跳
if (isTouchDevice) {
backgroundDisconnectTimer = setTimeout(doBackgroundDisconnect, BACKGROUND_TIMEOUT_MOBILE);
}
} else {
// 其它页面 - 立即断开省流量
doBackgroundDisconnect();
}
} else {
// Page coming to foreground
if (backgroundDisconnectTimer) {
clearTimeout(backgroundDisconnectTimer);
backgroundDisconnectTimer = null;
}
// Reconnect or send immediate ping
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWebSocket();
} else if (token) {
// Connection still open - send immediate ping to refresh server heartbeat
ws.send(JSON.stringify({ cmd: 'ping', token }));
}
}
});
window.onload = function() {
// Initialize cached DOM elements
initUIElements();
const compat = checkWebCodecs();
if (!compat.supported) {
document.body.insertAdjacentHTML('afterbegin',
'<div class="compat-warning">Warning: Your browser may not support H264 decoding.</div>');
}
// On touch devices: setup UI based on orientation and fullscreen state
if (isTouchDevice) {
// Initial UI setup
updateUIForOrientation();
// Touch devices: keyboard is independent from mouse, enable keyboard buttons
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.disabled = false;
if (btnKeyboardBar) btnKeyboardBar.disabled = false;
// Listen for orientation changes (with debounce)
let resizeTimer = null;
window.addEventListener('resize', function() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(updateUIForOrientation, 100);
});
// Touch-device-only handlers continue here; the unified
// fullscreen change handler (onFullscreenChange) is registered
// below outside this branch so desktop browsers also get the
// floating-toolbar cleanup + keyboard lock sync.
// Input shortcut buttons event handlers
// Shortcuts only visible when keyboard is open, so no extra check needed
function sendShortcutKey(keyCode, isDown) {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false }));
}
}
const shortcutBtns = document.querySelectorAll('.shortcut-btn');
shortcutBtns.forEach(function(btn) {
btn.addEventListener('touchstart', function(e) {
e.preventDefault();
const keyCode = parseInt(btn.dataset.key);
const needShift = btn.dataset.shift === '1';
const needCtrl = btn.dataset.ctrl === '1';
if (needCtrl) sendShortcutKey(17, true); // Ctrl down
if (needShift) sendShortcutKey(16, true); // Shift down
sendShortcutKey(keyCode, true);
sendShortcutKey(keyCode, false);
if (needShift) sendShortcutKey(16, false); // Shift up
if (needCtrl) sendShortcutKey(17, false); // Ctrl up
});
btn.addEventListener('click', function(e) {
e.preventDefault();
// Only handle click for non-touch (mouse)
if (!('ontouchstart' in window)) {
const keyCode = parseInt(btn.dataset.key);
const needShift = btn.dataset.shift === '1';
const needCtrl = btn.dataset.ctrl === '1';
if (needCtrl) sendShortcutKey(17, true); // Ctrl down
if (needShift) sendShortcutKey(16, true);
sendShortcutKey(keyCode, true);
sendShortcutKey(keyCode, false);
if (needShift) sendShortcutKey(16, false);
if (needCtrl) sendShortcutKey(17, false); // Ctrl up
}
});
});
} else {
// Desktop: hide keyboard buttons (physical keyboard available)
const btnKeyboard = document.getElementById('btn-keyboard');
const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnKeyboard) btnKeyboard.classList.add('ui-hidden');
if (btnKeyboardBar) btnKeyboardBar.classList.add('ui-hidden');
}
// Unified fullscreen change handler — registered for all devices.
// Touch devices need updateUIForOrientation; desktop devices need
// the floating-toolbar cleanup. Both need the keyboard lock sync.
document.addEventListener('fullscreenchange', onFullscreenChange);
document.addEventListener('webkitfullscreenchange', onFullscreenChange);
// Keyboard buttons: capture focus state and prevent blur from updating button state
function bindKeyboardBtnEvents(btn) {
if (!btn) return;
btn.addEventListener('touchstart', function() {
lastKeyboardBtnTouch = Date.now();
wasKeyboardFocused = (document.activeElement === mobileKeyboard);
}, { passive: true });
btn.addEventListener('mousedown', function() {
lastKeyboardBtnTouch = Date.now();
wasKeyboardFocused = (document.activeElement === mobileKeyboard);
});
}
bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar'));
// Restore token and role from sessionStorage
token = sessionStorage.getItem('token');
currentUserRole = sessionStorage.getItem('role') || 'viewer';
// Show Users button for admin only (will be updated after login verification)
if (token && currentUserRole === 'admin') {
document.getElementById('users-btn').classList.add('visible');
}
connectWebSocket();
};
</script>
</body>
</html>