2026-09-03 12:46:14 +00:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8" />
|
2026-09-06 14:09:17 +00:00
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
2026-09-03 12:46:14 +00:00
|
|
|
<title>LazyBoy desktop</title>
|
|
|
|
|
<style>
|
|
|
|
|
html, body, #screen {
|
|
|
|
|
margin: 0;
|
|
|
|
|
height: 100%;
|
|
|
|
|
width: 100%;
|
|
|
|
|
background: #0f172a;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
2026-09-07 09:05:14 +00:00
|
|
|
#status { flex:none; padding:6px 10px; color:#f8fafc; font:12px system-ui; }
|
|
|
|
|
#status[hidden] { display:none; }
|
2026-09-06 14:09:17 +00:00
|
|
|
body { display:flex; flex-direction:column; }
|
|
|
|
|
#screen { flex:1; min-height:0; height:auto; }
|
2026-09-03 12:46:14 +00:00
|
|
|
#screen canvas { cursor: default; }
|
2026-09-07 09:05:14 +00:00
|
|
|
#trackpad { height:clamp(64px,18vh,150px); margin-top:6px; border:1px solid #64748b;
|
|
|
|
|
border-radius:10px; background:#111b2e; touch-action:none; user-select:none;
|
|
|
|
|
display:grid; place-items:center; color:#94a3b8; }
|
|
|
|
|
#trackpad[hidden] { display:none; }
|
|
|
|
|
#mobile-controls .shortcuts { margin-top:5px; }
|
|
|
|
|
#mobile-controls .shortcuts[hidden] { display:none; }
|
|
|
|
|
@media(orientation:landscape) {
|
|
|
|
|
body.touch-ui {
|
|
|
|
|
display:grid;
|
|
|
|
|
grid-template:"status status" auto "screen controls" 1fr / minmax(0,1fr) 210px;
|
|
|
|
|
}
|
|
|
|
|
body.touch-ui #status { grid-area:status; }
|
|
|
|
|
body.touch-ui #screen { grid-area:screen; width:auto; height:auto; min-width:0; }
|
|
|
|
|
body.touch-ui #mobile-controls { grid-area:controls; width:auto; box-sizing:border-box; overflow:auto; }
|
|
|
|
|
body.touch-ui #mobile-controls .buttons { flex-wrap:wrap; }
|
|
|
|
|
body.touch-ui #trackpad { height:80px; }
|
|
|
|
|
}
|
2026-09-06 14:09:17 +00:00
|
|
|
#mobile-controls { display:none; flex:none; padding:6px 8px max(6px,env(safe-area-inset-bottom));
|
|
|
|
|
background:#172033; color:#f8fafc; font:12px system-ui; border-top:1px solid #334155; }
|
|
|
|
|
#mobile-controls .buttons { display:flex; gap:5px; overflow-x:auto; }
|
|
|
|
|
#mobile-controls button { flex:1 0 auto; min-height:44px; padding:8px 10px;
|
|
|
|
|
border:1px solid #64748b; border-radius:8px; background:#25334a; color:inherit; font:inherit; touch-action:manipulation; }
|
|
|
|
|
#mobile-controls button[aria-pressed="true"] { background:#155e75; border-color:#67e8f9; }
|
|
|
|
|
#mobile-controls button:disabled { opacity:.4; }
|
|
|
|
|
#pointer-help { margin:5px 0 0; line-height:1.4; }
|
|
|
|
|
#mobile-cursor { display:none; position:fixed; width:18px; height:18px; border:2px solid white;
|
|
|
|
|
border-radius:50%; box-shadow:0 0 0 1px #000,0 1px 5px #000; transform:translate(-50%,-50%);
|
|
|
|
|
pointer-events:none; z-index:5; }
|
|
|
|
|
#mobile-cursor::after { content:""; position:absolute; width:4px; height:4px; background:#fff;
|
|
|
|
|
border-radius:50%; left:7px; top:7px; }
|
|
|
|
|
@media(any-pointer:coarse) { #mobile-controls { display:block; } }
|
2026-09-07 09:05:14 +00:00
|
|
|
body.touch-ui #mobile-controls { display:block; }
|
2026-09-06 14:09:17 +00:00
|
|
|
/* Keep a real editable element on screen for iOS/Android keyboards. */
|
|
|
|
|
#mobile-keyboard { position:fixed; bottom:0; left:0; width:1px; height:1px;
|
|
|
|
|
padding:0; border:0; opacity:.01; font-size:16px; pointer-events:none; }
|
2026-09-03 12:46:14 +00:00
|
|
|
</style>
|
|
|
|
|
<script type="module">
|
|
|
|
|
import RFB from "./core/rfb.js";
|
|
|
|
|
|
|
|
|
|
function query(name) {
|
|
|
|
|
const match = `${document.location.href}${window.location.hash}`.match(
|
|
|
|
|
new RegExp(`[?&#]${name}=([^&#]*)`)
|
|
|
|
|
);
|
|
|
|
|
return match ? decodeURIComponent(match[1]) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flag(name, fallback = false) {
|
|
|
|
|
const raw = query(name);
|
|
|
|
|
if (raw == null) return fallback;
|
|
|
|
|
const value = String(raw).toLowerCase();
|
|
|
|
|
return value === "1" || value === "true" || value === "yes";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statusEl = document.getElementById("status");
|
|
|
|
|
const setStatus = (text) => {
|
|
|
|
|
statusEl.textContent = text;
|
2026-09-07 09:05:14 +00:00
|
|
|
statusEl.hidden = !text;
|
2026-09-03 12:46:14 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const prefix = window.location.pathname.replace(/[^/]+$/, "");
|
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
|
|
|
const path = (query("path") || "websockify").replace(/^\//, "");
|
|
|
|
|
const url = path.includes("/")
|
|
|
|
|
? `${protocol}://${window.location.host}/${path}`
|
|
|
|
|
: `${protocol}://${window.location.host}${prefix}${path}`;
|
|
|
|
|
|
|
|
|
|
let rfb = null;
|
|
|
|
|
let reconnectTimer = null;
|
2026-09-08 01:14:53 +00:00
|
|
|
// A desktop that has never answered on this page is still coming up, and
|
|
|
|
|
// x11vnc opens its port a heartbeat after the last attempt failed. Holding
|
|
|
|
|
// every retry at the calm cadence leaves up to half a second of veil on top
|
|
|
|
|
// of a desktop that is already there, so the first window of attempts comes
|
|
|
|
|
// back eager. Once a session has been seen the calm cadence returns: a
|
|
|
|
|
// genuinely dropped connection should not be hammered.
|
|
|
|
|
const CALM_RETRY_MS = 1500;
|
|
|
|
|
const EAGER_RETRY_MS = 350;
|
|
|
|
|
const EAGER_ATTEMPTS = 40;
|
|
|
|
|
let everConnected = false;
|
|
|
|
|
let eagerAttempts = 0;
|
|
|
|
|
const retryDelay = () => (!everConnected && eagerAttempts < EAGER_ATTEMPTS)
|
|
|
|
|
? (eagerAttempts += 1, EAGER_RETRY_MS)
|
|
|
|
|
: CALM_RETRY_MS;
|
2026-09-03 23:43:37 +00:00
|
|
|
const parentOrigin = window.location.origin;
|
2026-09-06 14:09:17 +00:00
|
|
|
const keyboard = document.getElementById("mobile-keyboard");
|
|
|
|
|
const sentinel = "\u200b";
|
|
|
|
|
let composing = false;
|
|
|
|
|
let touchStart = null;
|
|
|
|
|
|
|
|
|
|
const controls = document.getElementById("mobile-controls");
|
|
|
|
|
const cursor = document.getElementById("mobile-cursor");
|
|
|
|
|
const modeButton = document.getElementById("pointer-mode");
|
|
|
|
|
const dragButton = document.getElementById("pointer-drag");
|
|
|
|
|
const pointerHelp = document.getElementById("pointer-help");
|
2026-09-07 09:05:14 +00:00
|
|
|
const touchUi = window.matchMedia?.("(any-pointer:coarse)");
|
|
|
|
|
document.body?.classList?.toggle("touch-ui", Boolean(touchUi?.matches));
|
|
|
|
|
touchUi?.addEventListener?.("change", event => document.body?.classList?.toggle("touch-ui", event.matches));
|
|
|
|
|
let trackpad = Boolean(touchUi?.matches);
|
|
|
|
|
const pad = document.getElementById("trackpad");
|
|
|
|
|
const shortcuts = document.getElementById("keyboard-shortcuts");
|
|
|
|
|
let modifier = null;
|
2026-09-06 14:09:17 +00:00
|
|
|
let dragging = false;
|
|
|
|
|
let gesture = null;
|
|
|
|
|
let pointer = { x: .5, y: .5 };
|
|
|
|
|
|
|
|
|
|
function pointerGeometry() {
|
|
|
|
|
const canvas = document.querySelector("#screen canvas");
|
|
|
|
|
const rect = canvas?.getBoundingClientRect();
|
|
|
|
|
return rect && rect.width > 0 && rect.height > 0 ? rect : null;
|
|
|
|
|
}
|
|
|
|
|
function paintPointer() {
|
|
|
|
|
const rect = pointerGeometry();
|
|
|
|
|
if (!cursor?.style) return;
|
|
|
|
|
cursor.style.display = trackpad && rect && rfb && !rfb.viewOnly ? "block" : "none";
|
|
|
|
|
if (rect) {
|
|
|
|
|
cursor.style.left = `${rect.left + pointer.x * rect.width}px`;
|
|
|
|
|
cursor.style.top = `${rect.top + pointer.y * rect.height}px`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Pointer adapter for the pinned noVNC 1.7.0. These methods take scaled
|
|
|
|
|
// canvas coordinates and preserve noVNC's throttling/protocol handling.
|
|
|
|
|
function sendPointer(mask = null) {
|
|
|
|
|
const rect = pointerGeometry();
|
|
|
|
|
if (!rect || !rfb || rfb.viewOnly) return;
|
|
|
|
|
const x = Math.min(rect.width - 1, Math.max(0, pointer.x * rect.width));
|
|
|
|
|
const y = Math.min(rect.height - 1, Math.max(0, pointer.y * rect.height));
|
|
|
|
|
if (mask === null) rfb._handleMouseMove(x, y);
|
|
|
|
|
else rfb._handleMouseButton(x, y, mask);
|
|
|
|
|
paintPointer();
|
|
|
|
|
}
|
|
|
|
|
function releaseDrag() {
|
|
|
|
|
if (dragging) sendPointer(0);
|
|
|
|
|
dragging = false;
|
|
|
|
|
dragButton?.setAttribute?.("aria-pressed", "false");
|
|
|
|
|
}
|
|
|
|
|
function pointerClick(mask) {
|
|
|
|
|
releaseDrag();
|
|
|
|
|
sendPointer(mask);
|
|
|
|
|
sendPointer(0);
|
|
|
|
|
}
|
|
|
|
|
function scrollPointer(deltaX, deltaY) {
|
|
|
|
|
const rect = pointerGeometry();
|
|
|
|
|
if (!rect || !rfb || rfb.viewOnly) return;
|
|
|
|
|
rfb._handleWheel({ clientX:rect.left + pointer.x * rect.width,
|
|
|
|
|
clientY:rect.top + pointer.y * rect.height, deltaX, deltaY, deltaMode:0,
|
|
|
|
|
buttons:0, preventDefault(){}, stopPropagation(){} });
|
|
|
|
|
}
|
|
|
|
|
function updatePointerControls() {
|
2026-09-07 09:05:14 +00:00
|
|
|
if (pad) pad.hidden = !trackpad;
|
2026-09-06 14:09:17 +00:00
|
|
|
modeButton?.setAttribute?.("aria-pressed", String(trackpad));
|
|
|
|
|
if (modeButton) modeButton.textContent = trackpad ? "觸控板" : "直接點選";
|
|
|
|
|
if (pointerHelp) pointerHelp.textContent = trackpad
|
|
|
|
|
? "滑動移游標・輕點左鍵・雙指捲動/輕點右鍵・拖曳按完再按一次放開"
|
2026-09-07 09:05:14 +00:00
|
|
|
: "點畫面定位・按「鍵盤」輸入・精準操作可切換觸控板";
|
2026-09-06 14:09:17 +00:00
|
|
|
for (const button of controls?.querySelectorAll?.("button[data-action]") || []) {
|
|
|
|
|
button.disabled = !rfb || rfb.viewOnly;
|
|
|
|
|
}
|
|
|
|
|
paintPointer();
|
|
|
|
|
}
|
|
|
|
|
function showKeyboard() {
|
|
|
|
|
if (!rfb || rfb.viewOnly) return;
|
|
|
|
|
rfb.focusOnClick = false;
|
2026-09-07 09:05:14 +00:00
|
|
|
if (shortcuts) shortcuts.hidden = false;
|
2026-09-06 14:09:17 +00:00
|
|
|
keyboard.focus({ preventScroll: true });
|
|
|
|
|
keyboard.setSelectionRange(keyboard.value.length, keyboard.value.length);
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener("click", event => {
|
|
|
|
|
const button = event.target.closest?.("#mobile-controls button");
|
|
|
|
|
if (!button) return;
|
|
|
|
|
const action = button.dataset.action;
|
|
|
|
|
if (action === "mode") {
|
|
|
|
|
releaseDrag(); gesture = null; touchStart = null;
|
|
|
|
|
trackpad = !trackpad; keyboard.blur(); updatePointerControls(); return;
|
|
|
|
|
}
|
|
|
|
|
if (!rfb || rfb.viewOnly) return;
|
|
|
|
|
if (action === "keyboard") { releaseDrag(); showKeyboard(); return; }
|
2026-09-07 09:05:14 +00:00
|
|
|
if (action === "hide-keyboard") { keyboard.blur(); if (shortcuts) shortcuts.hidden = true; return; }
|
|
|
|
|
if (action === "modifier") {
|
|
|
|
|
modifier = modifier === button.dataset.key ? null : button.dataset.key;
|
|
|
|
|
for (const item of controls.querySelectorAll('[data-action="modifier"]')) item.setAttribute("aria-pressed", String(item.dataset.key === modifier));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (action === "key") { mobileKey(button.dataset.key); return; }
|
2026-09-06 14:09:17 +00:00
|
|
|
if (action === "left") pointerClick(1);
|
|
|
|
|
if (action === "right") pointerClick(4);
|
|
|
|
|
if (action === "up") { releaseDrag(); scrollPointer(0, -100); }
|
|
|
|
|
if (action === "down") { releaseDrag(); scrollPointer(0, 100); }
|
|
|
|
|
if (action === "drag") {
|
|
|
|
|
if (!trackpad) { trackpad = true; keyboard.blur(); }
|
|
|
|
|
dragging = !dragging; sendPointer(dragging ? 1 : 0);
|
|
|
|
|
dragButton.setAttribute("aria-pressed", String(dragging));
|
|
|
|
|
updatePointerControls();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
function stopTouch(event) { event.preventDefault(); event.stopImmediatePropagation(); }
|
|
|
|
|
function touchCenter(touches) {
|
|
|
|
|
const points = Array.from(touches);
|
|
|
|
|
return { x:points.reduce((sum,p)=>sum+p.clientX,0)/points.length,
|
|
|
|
|
y:points.reduce((sum,p)=>sum+p.clientY,0)/points.length };
|
|
|
|
|
}
|
|
|
|
|
function startPointerTouch(event) {
|
2026-09-07 09:05:14 +00:00
|
|
|
if (!trackpad || !event.target.closest?.("#trackpad")) return false;
|
2026-09-06 14:09:17 +00:00
|
|
|
stopTouch(event);
|
|
|
|
|
if (!rfb || rfb.viewOnly) {
|
|
|
|
|
window.parent.postMessage({ type:"lazyboy-request-control" }, parentOrigin);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const center = touchCenter(event.touches);
|
|
|
|
|
if (event.touches.length > 1) releaseDrag();
|
|
|
|
|
gesture = { last:center, start:center, fingers:event.touches.length,
|
|
|
|
|
moved:false, time:Date.now() };
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
function movePointerTouch(event) {
|
|
|
|
|
if (!trackpad || !gesture) return false;
|
|
|
|
|
stopTouch(event);
|
|
|
|
|
if (!rfb || rfb.viewOnly) { gesture = null; return true; }
|
|
|
|
|
const center = touchCenter(event.touches);
|
|
|
|
|
if (event.touches.length !== gesture.fingers) {
|
|
|
|
|
gesture.last = center; gesture.moved = true; return true;
|
|
|
|
|
}
|
|
|
|
|
const dx = center.x - gesture.last.x, dy = center.y - gesture.last.y;
|
|
|
|
|
gesture.last = center;
|
|
|
|
|
if (Math.hypot(center.x - gesture.start.x, center.y - gesture.start.y) > 5) gesture.moved = true;
|
|
|
|
|
if (gesture.fingers === 2) { scrollPointer(-dx, -dy); return true; }
|
|
|
|
|
const rect = pointerGeometry();
|
|
|
|
|
if (rect && gesture.fingers === 1 && gesture.moved) {
|
|
|
|
|
pointer.x = Math.max(0, Math.min(1, pointer.x + dx / rect.width));
|
|
|
|
|
pointer.y = Math.max(0, Math.min(1, pointer.y + dy / rect.height));
|
|
|
|
|
sendPointer();
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
function endPointerTouch(event) {
|
|
|
|
|
if (!trackpad || !gesture) return false;
|
|
|
|
|
stopTouch(event);
|
|
|
|
|
if (event.touches.length) return true;
|
|
|
|
|
if (!gesture.moved && Date.now() - gesture.time < 450 && !dragging) {
|
|
|
|
|
if (gesture.fingers === 1) pointerClick(1);
|
|
|
|
|
if (gesture.fingers === 2) pointerClick(4);
|
|
|
|
|
}
|
|
|
|
|
gesture = null;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener("blur", () => { gesture = null; releaseDrag(); });
|
|
|
|
|
window.addEventListener("pagehide", () => { gesture = null; releaseDrag(); });
|
|
|
|
|
|
|
|
|
|
function resetKeyboard() {
|
|
|
|
|
keyboard.value = sentinel;
|
|
|
|
|
composing = false;
|
|
|
|
|
}
|
|
|
|
|
function mobileKey(key) {
|
|
|
|
|
if (!rfb || rfb.viewOnly) return;
|
2026-09-07 09:05:14 +00:00
|
|
|
window.parent.postMessage({ type: "lazyboy-mobile-key", key: modifier ? `${modifier}+${key}` : key }, parentOrigin);
|
|
|
|
|
modifier = null;
|
|
|
|
|
for (const item of controls?.querySelectorAll?.('[data-action="modifier"]') || []) item.setAttribute("aria-pressed", "false");
|
2026-09-06 14:09:17 +00:00
|
|
|
}
|
|
|
|
|
function commitKeyboard() {
|
|
|
|
|
if (composing) return;
|
|
|
|
|
const value = keyboard.value;
|
|
|
|
|
if (!rfb || rfb.viewOnly) { resetKeyboard(); return; }
|
|
|
|
|
if (!value) mobileKey("BackSpace");
|
|
|
|
|
else {
|
|
|
|
|
const text = value.startsWith(sentinel) ? value.slice(1) : value;
|
|
|
|
|
if (text) pasteIntoDesktop(text);
|
|
|
|
|
}
|
|
|
|
|
resetKeyboard();
|
|
|
|
|
}
|
|
|
|
|
resetKeyboard();
|
|
|
|
|
window.addEventListener("compositionstart", event => {
|
|
|
|
|
if (event.target === keyboard) composing = true;
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("compositionend", event => {
|
|
|
|
|
if (event.target !== keyboard) return;
|
|
|
|
|
composing = false;
|
|
|
|
|
commitKeyboard();
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("input", event => {
|
|
|
|
|
if (event.target === keyboard && !event.isComposing) commitKeyboard();
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("beforeinput", event => {
|
|
|
|
|
if (event.target !== keyboard || composing || event.isComposing) return;
|
|
|
|
|
if (event.inputType === "insertLineBreak" || event.inputType === "insertParagraph") {
|
|
|
|
|
event.preventDefault(); mobileKey("Return"); resetKeyboard();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Focus synchronously in the user's tap, before iOS loses activation.
|
|
|
|
|
// VNC pixels cannot reveal whether the remote target is a text field.
|
2026-09-07 09:05:14 +00:00
|
|
|
function ignoreScreenTouch(event) {
|
|
|
|
|
if (!trackpad || !event.target.closest?.("#screen")) return false;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopImmediatePropagation();
|
|
|
|
|
touchStart = null;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-09-06 14:09:17 +00:00
|
|
|
window.addEventListener("touchstart", event => {
|
|
|
|
|
if (event.target === keyboard) return;
|
|
|
|
|
if (startPointerTouch(event)) { touchStart = null; return; }
|
2026-09-07 09:05:14 +00:00
|
|
|
if (ignoreScreenTouch(event)) return;
|
2026-09-06 14:09:17 +00:00
|
|
|
const point = event.touches[0];
|
|
|
|
|
const rect = pointerGeometry();
|
|
|
|
|
if (point && rect && event.target.closest?.("#screen")) {
|
|
|
|
|
pointer.x = Math.max(0, Math.min(1, (point.clientX - rect.left) / rect.width));
|
|
|
|
|
pointer.y = Math.max(0, Math.min(1, (point.clientY - rect.top) / rect.height));
|
|
|
|
|
}
|
|
|
|
|
touchStart = event.touches.length === 1 && event.target.closest?.("#screen")
|
|
|
|
|
? { x: point.clientX, y: point.clientY } : null;
|
|
|
|
|
if (rfb) rfb.focusOnClick = false;
|
|
|
|
|
}, { passive: false, capture: true });
|
|
|
|
|
window.addEventListener("touchmove", event => {
|
|
|
|
|
if (movePointerTouch(event)) return;
|
2026-09-07 09:05:14 +00:00
|
|
|
if (ignoreScreenTouch(event)) return;
|
2026-09-06 14:09:17 +00:00
|
|
|
const point = event.touches[0];
|
|
|
|
|
if (touchStart && (!point || event.touches.length !== 1 ||
|
|
|
|
|
Math.hypot(point.clientX - touchStart.x, point.clientY - touchStart.y) > 10)) touchStart = null;
|
|
|
|
|
}, { passive: false, capture: true });
|
|
|
|
|
window.addEventListener("touchcancel", () => { touchStart = null; gesture = null; releaseDrag(); }, true);
|
|
|
|
|
window.addEventListener("touchend", event => {
|
|
|
|
|
if (endPointerTouch(event)) return;
|
2026-09-07 09:05:14 +00:00
|
|
|
if (ignoreScreenTouch(event)) return;
|
2026-09-06 14:09:17 +00:00
|
|
|
const tapped = touchStart && event.touches.length === 0;
|
|
|
|
|
touchStart = null;
|
|
|
|
|
if (!tapped || !rfb || rfb.viewOnly) return;
|
2026-09-07 09:05:14 +00:00
|
|
|
// Direct taps position the pointer; the keyboard opens only on request.
|
2026-09-06 14:09:17 +00:00
|
|
|
}, { passive: false, capture: true });
|
2026-09-03 12:46:14 +00:00
|
|
|
|
2026-09-03 16:16:34 +00:00
|
|
|
function pasteIntoDesktop(text) {
|
|
|
|
|
if (!rfb || rfb.viewOnly || !text) return;
|
2026-09-05 09:41:44 +00:00
|
|
|
window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
|
2026-09-03 16:16:34 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 12:46:14 +00:00
|
|
|
function pinTaskbar() {
|
|
|
|
|
// noVNC centers the scaled canvas (margin:auto), leaving a dead
|
|
|
|
|
// strip under the panel. Stick the canvas to the bottom.
|
|
|
|
|
const inner = document.querySelector("#screen > div");
|
|
|
|
|
const canvas = document.querySelector("#screen canvas");
|
|
|
|
|
if (inner) {
|
|
|
|
|
inner.style.alignItems = "flex-end";
|
|
|
|
|
inner.style.justifyContent = "center";
|
|
|
|
|
}
|
|
|
|
|
if (canvas) {
|
|
|
|
|
canvas.style.margin = "0 auto";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 01:14:53 +00:00
|
|
|
// The host flips this gate whenever control changes hands, including
|
|
|
|
|
// while the frame is still dialling or between retries. Keep the last
|
|
|
|
|
// thing it asked for and hand it to every new RFB, so a handoff never
|
|
|
|
|
// depends on which side of a reconnect the message happened to land.
|
|
|
|
|
let wantedViewOnly = null;
|
2026-09-05 09:41:44 +00:00
|
|
|
function applyViewOnly(value) {
|
2026-09-08 01:14:53 +00:00
|
|
|
wantedViewOnly = Boolean(value);
|
2026-09-05 09:41:44 +00:00
|
|
|
if (!rfb) return;
|
2026-09-08 01:14:53 +00:00
|
|
|
if (wantedViewOnly) { releaseDrag(); gesture = null; modifier = null; if (shortcuts) shortcuts.hidden = true; }
|
|
|
|
|
rfb.viewOnly = wantedViewOnly;
|
2026-09-06 14:09:17 +00:00
|
|
|
updatePointerControls();
|
|
|
|
|
if (rfb.viewOnly) { keyboard.blur(); resetKeyboard(); }
|
2026-09-08 01:14:53 +00:00
|
|
|
// Taking over has to be usable, not just painted: focus so the very
|
|
|
|
|
// first keystroke after the veil lands on the desktop.
|
|
|
|
|
else { try { rfb.focus(); } catch (_) {} }
|
2026-09-05 09:41:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 12:46:14 +00:00
|
|
|
function connect() {
|
|
|
|
|
setStatus("Connecting to desktop…");
|
2026-09-05 09:41:44 +00:00
|
|
|
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
2026-09-03 12:46:14 +00:00
|
|
|
rfb = new RFB(document.getElementById("screen"), url);
|
2026-09-08 09:14:15 +00:00
|
|
|
rfb.viewOnly = wantedViewOnly ?? flag("view_only", true);
|
2026-09-03 12:46:14 +00:00
|
|
|
rfb.scaleViewport = true;
|
2026-09-07 09:05:14 +00:00
|
|
|
rfb.qualityLevel = 6;
|
|
|
|
|
rfb.compressionLevel = 2;
|
2026-09-03 12:46:14 +00:00
|
|
|
rfb.clipViewport = false;
|
|
|
|
|
rfb.background = "#0f172a";
|
|
|
|
|
pinTaskbar();
|
2026-09-06 14:09:17 +00:00
|
|
|
updatePointerControls();
|
2026-09-03 12:46:14 +00:00
|
|
|
rfb.addEventListener("connect", () => {
|
2026-09-08 01:14:53 +00:00
|
|
|
everConnected = true;
|
|
|
|
|
eagerAttempts = 0;
|
2026-09-07 09:05:14 +00:00
|
|
|
setStatus("");
|
2026-09-03 12:46:14 +00:00
|
|
|
pinTaskbar();
|
2026-09-06 14:09:17 +00:00
|
|
|
updatePointerControls();
|
2026-09-03 12:46:14 +00:00
|
|
|
try { rfb.focus(); } catch (_) {}
|
2026-09-08 01:14:53 +00:00
|
|
|
if (wantedViewOnly !== null) applyViewOnly(wantedViewOnly);
|
2026-09-05 09:41:44 +00:00
|
|
|
window.parent.postMessage({ type: "lazyboy-desktop-ready" }, parentOrigin);
|
2026-09-03 12:46:14 +00:00
|
|
|
});
|
|
|
|
|
rfb.addEventListener("disconnect", (event) => {
|
2026-09-06 14:09:17 +00:00
|
|
|
releaseDrag(); gesture = null;
|
2026-09-08 09:14:15 +00:00
|
|
|
// Disable the disconnected instance without overwriting host intent.
|
|
|
|
|
rfb.viewOnly = true;
|
|
|
|
|
modifier = null;
|
|
|
|
|
if (shortcuts) shortcuts.hidden = true;
|
|
|
|
|
updatePointerControls();
|
2026-09-06 14:09:17 +00:00
|
|
|
keyboard.blur(); resetKeyboard();
|
2026-09-05 09:41:44 +00:00
|
|
|
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
2026-09-03 12:46:14 +00:00
|
|
|
const clean = event && event.detail && event.detail.clean;
|
|
|
|
|
setStatus(clean ? "Disconnected — retrying" : "Desktop connection lost — retrying");
|
|
|
|
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
2026-09-08 01:14:53 +00:00
|
|
|
reconnectTimer = setTimeout(connect, retryDelay());
|
2026-09-03 12:46:14 +00:00
|
|
|
});
|
2026-09-03 16:16:34 +00:00
|
|
|
rfb.addEventListener("clipboard", (event) => {
|
|
|
|
|
const text = event && event.detail ? event.detail.text : "";
|
|
|
|
|
if (typeof text === "string") {
|
2026-09-03 23:43:37 +00:00
|
|
|
window.parent.postMessage({ type: "lazyboy-desktop-clipboard", text }, parentOrigin);
|
2026-09-03 16:16:34 +00:00
|
|
|
}
|
|
|
|
|
});
|
2026-09-03 12:46:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-05 09:41:44 +00:00
|
|
|
// Capture before noVNC forwards the shortcut; otherwise stale remote
|
|
|
|
|
// clipboard contents can be pasted once before the local text arrives.
|
|
|
|
|
window.addEventListener("keydown", async (event) => {
|
2026-09-06 14:09:17 +00:00
|
|
|
if (event.target === keyboard) {
|
|
|
|
|
if (event.isComposing || composing || event.keyCode === 229) return;
|
|
|
|
|
const keys = { Enter: "Return", Backspace: "BackSpace", Tab: "Tab", Escape: "Escape",
|
|
|
|
|
ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down" };
|
|
|
|
|
if (keys[event.key]) {
|
|
|
|
|
event.preventDefault(); event.stopImmediatePropagation();
|
|
|
|
|
mobileKey(keys[event.key]); resetKeyboard();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-05 09:41:44 +00:00
|
|
|
if (rfb && !rfb.viewOnly && event.metaKey && event.code === "KeyC") {
|
|
|
|
|
event.preventDefault(); event.stopImmediatePropagation();
|
|
|
|
|
window.parent.postMessage({type:"lazyboy-copy-request"},parentOrigin); return;
|
|
|
|
|
}
|
|
|
|
|
if (!rfb || rfb.viewOnly || !(event.ctrlKey || event.metaKey) || event.altKey || event.code !== "KeyV") return;
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopImmediatePropagation();
|
|
|
|
|
const target = rfb;
|
|
|
|
|
try {
|
|
|
|
|
const text = await navigator.clipboard.readText();
|
|
|
|
|
if (rfb === target && !target.viewOnly) pasteIntoDesktop(text);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
window.parent.postMessage({ type: "lazyboy-paste-request" }, parentOrigin);
|
|
|
|
|
}
|
|
|
|
|
}, true);
|
2026-09-03 12:46:14 +00:00
|
|
|
connect();
|
2026-09-06 14:09:17 +00:00
|
|
|
window.addEventListener("resize", () => { pinTaskbar(); paintPointer(); });
|
|
|
|
|
window.addEventListener("pointerdown", (event) => {
|
|
|
|
|
if (event.target === keyboard || event.target.closest?.("#mobile-controls")) return;
|
|
|
|
|
try { if (rfb && event.pointerType !== "touch") { rfb.focusOnClick = true; rfb.focus(); } } catch (_) {}
|
2026-09-03 12:46:14 +00:00
|
|
|
if (rfb && rfb.viewOnly) {
|
2026-09-03 23:43:37 +00:00
|
|
|
window.parent.postMessage({ type: "lazyboy-request-control" }, parentOrigin);
|
2026-09-03 12:46:14 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
window.addEventListener("paste", (event) => {
|
|
|
|
|
if (!rfb || rfb.viewOnly) return;
|
|
|
|
|
const text = event.clipboardData ? event.clipboardData.getData("text") : "";
|
|
|
|
|
if (!text) return;
|
|
|
|
|
event.preventDefault();
|
2026-09-03 16:16:34 +00:00
|
|
|
pasteIntoDesktop(text);
|
2026-09-06 14:09:17 +00:00
|
|
|
if (event.target === keyboard) resetKeyboard();
|
2026-09-03 16:16:34 +00:00
|
|
|
});
|
|
|
|
|
window.addEventListener("message", (event) => {
|
2026-09-05 09:41:44 +00:00
|
|
|
if (event.origin !== parentOrigin || event.source !== window.parent) return;
|
|
|
|
|
if (!event.data) return;
|
|
|
|
|
if (event.data.type === "lazyboy-view-only") {
|
|
|
|
|
applyViewOnly(event.data.viewOnly);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (event.data.type !== "lazyboy-host-clipboard") return;
|
2026-09-03 16:16:34 +00:00
|
|
|
pasteIntoDesktop(String(event.data.text || ""));
|
2026-09-03 12:46:14 +00:00
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
2026-09-05 09:41:44 +00:00
|
|
|
<div id="status" hidden>Loading desktop…</div>
|
2026-09-03 12:46:14 +00:00
|
|
|
<div id="screen"></div>
|
2026-09-06 14:09:17 +00:00
|
|
|
<div id="mobile-cursor" aria-hidden="true"></div>
|
|
|
|
|
<nav id="mobile-controls" aria-label="遠端滑鼠">
|
|
|
|
|
<div class="buttons">
|
|
|
|
|
<button id="pointer-mode" data-action="mode" aria-pressed="false" title="切換直接點選與觸控板">直接點選</button>
|
|
|
|
|
<button data-action="left">左鍵</button>
|
|
|
|
|
<button data-action="right">右鍵</button>
|
|
|
|
|
<button id="pointer-drag" data-action="drag" aria-pressed="false">拖曳</button>
|
|
|
|
|
<button data-action="up" aria-label="向上捲動">↑</button>
|
|
|
|
|
<button data-action="down" aria-label="向下捲動">↓</button>
|
|
|
|
|
<button data-action="keyboard">鍵盤</button>
|
|
|
|
|
</div>
|
2026-09-07 09:05:14 +00:00
|
|
|
<div class="buttons shortcuts" id="keyboard-shortcuts" hidden>
|
|
|
|
|
<button data-action="modifier" data-key="ctrl" aria-pressed="false">Ctrl</button>
|
|
|
|
|
<button data-action="modifier" data-key="alt" aria-pressed="false">Alt</button>
|
|
|
|
|
<button data-action="key" data-key="a">A</button><button data-action="key" data-key="c">C</button>
|
|
|
|
|
<button data-action="key" data-key="v">V</button><button data-action="key" data-key="z">Z</button>
|
|
|
|
|
<button data-action="key" data-key="Tab">Tab</button><button data-action="key" data-key="Escape">Esc</button>
|
|
|
|
|
<button data-action="key" data-key="Left">←</button><button data-action="key" data-key="Right">→</button>
|
|
|
|
|
<button data-action="key" data-key="Up">↑</button><button data-action="key" data-key="Down">↓</button>
|
|
|
|
|
<button data-action="key" data-key="Return">Enter</button><button data-action="key" data-key="BackSpace">⌫</button>
|
|
|
|
|
<button data-action="hide-keyboard">收起鍵盤</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="trackpad" role="group" aria-label="獨立觸控板" hidden>在這裡滑動控制滑鼠</div>
|
2026-09-06 14:09:17 +00:00
|
|
|
<p id="pointer-help"></p>
|
|
|
|
|
</nav>
|
|
|
|
|
<textarea id="mobile-keyboard" aria-label="Remote desktop keyboard" tabindex="-1"
|
|
|
|
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
2026-09-03 12:46:14 +00:00
|
|
|
</body>
|
|
|
|
|
</html>
|