mirror of
https://github.com/epiral/bb-browser.git
synced 2026-09-14 13:45:45 +08:00
403f29592f
- Remove pointer-events:none from dimmed tab/url bar so tabs remain clickable even when not connected to a stream. - Update activeTabId and re-render immediately on click for responsive UI, then async invoke stream.switch only if connected. - Skip stream.switch call when disconnected to avoid errors.
1164 lines
34 KiB
HTML
1164 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Remote Browser</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; overflow: hidden; background: #0a0a0a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; }
|
|
#app { display: flex; flex-direction: column; height: 100%; }
|
|
#toolbar { display: flex; align-items: center; gap: 8px; padding: 0 12px; height: 32px; background: #1a1a1a; flex-shrink: 0; }
|
|
#toolbar button { padding: 4px 14px; border: 1px solid #2a8c4a; border-radius: 4px; background: #1a5c2e; color: #8f8; cursor: pointer; font-size: 13px; }
|
|
#toolbar button:hover { background: #1f6b35; }
|
|
#toolbar button.connected { border-color: #8c2a2a; background: #5c1a1a; color: #e0e0e0; }
|
|
#toolbar button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
|
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
|
|
/* Tab bar */
|
|
#tab-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
background: #1a1a1a;
|
|
border-bottom: 1px solid #333;
|
|
height: 36px;
|
|
padding: 0 8px;
|
|
gap: 2px;
|
|
overflow-x: auto;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tab {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 4px 12px;
|
|
background: #252525;
|
|
border: 1px solid #333;
|
|
border-bottom: none;
|
|
border-radius: 6px 6px 0 0;
|
|
color: #aaa;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
max-width: 180px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
user-select: none;
|
|
gap: 6px;
|
|
}
|
|
|
|
.tab.active {
|
|
background: #333;
|
|
color: #fff;
|
|
border-color: #555;
|
|
}
|
|
|
|
.tab:hover { background: #2a2a2a; }
|
|
.tab.active:hover { background: #333; }
|
|
|
|
.tab-close {
|
|
font-size: 14px;
|
|
line-height: 1;
|
|
color: #666;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tab-close:hover { color: #f55; }
|
|
|
|
#new-tab-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: 1px solid #444;
|
|
border-radius: 6px;
|
|
color: #888;
|
|
font-size: 16px;
|
|
cursor: pointer;
|
|
flex-shrink: 0;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
#new-tab-btn:hover { background: #333; color: #fff; }
|
|
|
|
/* URL bar */
|
|
#url-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
background: #1a1a1a;
|
|
border-bottom: 1px solid #333;
|
|
height: 40px;
|
|
padding: 0 8px;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nav-btn {
|
|
width: 30px;
|
|
height: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: 1px solid #333;
|
|
border-radius: 6px;
|
|
color: #888;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.nav-btn:hover { background: #333; color: #fff; }
|
|
|
|
#url-input {
|
|
flex: 1;
|
|
height: 30px;
|
|
background: #252525;
|
|
border: 1px solid #444;
|
|
border-radius: 6px;
|
|
color: #e0e0e0;
|
|
font-size: 13px;
|
|
padding: 0 10px;
|
|
outline: none;
|
|
}
|
|
|
|
#url-input:focus { border-color: #4a9eff; }
|
|
|
|
/* Video container */
|
|
#video-container { flex: 1; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; background: #000; }
|
|
video { max-width: 100%; max-height: 100%; object-fit: contain; background: #000; }
|
|
#ime-input { position: fixed; top: -100px; left: -100px; width: 1px; height: 1px; opacity: 0; }
|
|
|
|
/* Video overlay (status message when not connected) */
|
|
#video-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
color: #888;
|
|
font-size: 14px;
|
|
z-index: 5;
|
|
pointer-events: none;
|
|
}
|
|
#video-overlay.hidden { display: none; }
|
|
|
|
/* Input overlay (captures mouse events over video) */
|
|
#input-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 4;
|
|
cursor: default;
|
|
}
|
|
#input-overlay.active { cursor: crosshair; }
|
|
|
|
/* Tab/URL bar dimming when disconnected */
|
|
#tab-bar.dimmed, #url-bar.dimmed {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Status bar */
|
|
#status-bar {
|
|
height: 24px;
|
|
background: #1a1a1a;
|
|
border-top: 1px solid #333;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
font-size: 11px;
|
|
color: #666;
|
|
flex-shrink: 0;
|
|
gap: 16px;
|
|
}
|
|
|
|
#status-bar .status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: #555;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#status-bar .status-dot.connected { background: #4caf50; }
|
|
#status-bar .status-dot.connecting { background: #ff9800; }
|
|
#status-bar .status-dot.error { background: #f44; }
|
|
|
|
#status-resolution {
|
|
margin-left: auto;
|
|
}
|
|
|
|
#debug-toggle {
|
|
height: 18px;
|
|
padding: 0 8px;
|
|
background: #252525;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
color: #888;
|
|
font-size: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#debug-toggle.active {
|
|
color: #fff;
|
|
border-color: #4a9eff;
|
|
background: #123152;
|
|
}
|
|
|
|
/* Debug panel */
|
|
#debug-panel {
|
|
position: absolute;
|
|
right: 12px;
|
|
bottom: 36px;
|
|
width: min(760px, calc(100vw - 24px));
|
|
max-height: min(420px, calc(100vh - 120px));
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: rgba(8, 12, 18, 0.94);
|
|
border: 1px solid #35506d;
|
|
border-radius: 8px;
|
|
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.45);
|
|
overflow: hidden;
|
|
z-index: 20;
|
|
}
|
|
|
|
#debug-panel[hidden] { display: none; }
|
|
|
|
#debug-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 10px;
|
|
background: #101a26;
|
|
border-bottom: 1px solid #27384b;
|
|
color: #cbe4ff;
|
|
font-size: 12px;
|
|
}
|
|
|
|
#debug-header strong { margin-right: auto; }
|
|
|
|
.debug-action {
|
|
height: 22px;
|
|
padding: 0 8px;
|
|
background: #17283a;
|
|
border: 1px solid #35506d;
|
|
border-radius: 4px;
|
|
color: #cbe4ff;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#debug-log {
|
|
margin: 0;
|
|
padding: 10px;
|
|
overflow: auto;
|
|
color: #b7d8ff;
|
|
font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
white-space: pre-wrap;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div id="toolbar">
|
|
<span style="font-size:13px;color:#888;margin-right:auto">Remote Browser</span>
|
|
<button id="btn-connect">Connect</button>
|
|
</div>
|
|
<div id="tab-bar">
|
|
<button id="new-tab-btn" title="New tab">+</button>
|
|
</div>
|
|
<div id="url-bar">
|
|
<button class="nav-btn" id="btn-back" title="Back">◀</button>
|
|
<button class="nav-btn" id="btn-forward" title="Forward">▶</button>
|
|
<button class="nav-btn" id="btn-reload" title="Reload">↻</button>
|
|
<input type="text" id="url-input" placeholder="Enter URL..." />
|
|
<button class="nav-btn" id="btn-go" title="Go">➜</button>
|
|
</div>
|
|
<div id="video-container">
|
|
<video id="video" autoplay playsinline muted></video>
|
|
<div id="video-overlay">Click Connect to start</div>
|
|
<div id="input-overlay"></div>
|
|
</div>
|
|
<div id="status-bar">
|
|
<span class="status-dot" id="status-dot"></span>
|
|
<span id="status-text">Disconnected</span>
|
|
<span id="status-resolution"></span>
|
|
<button id="debug-toggle" type="button" title="Toggle debug panel (Ctrl+Shift+D)">Debug</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="debug-panel" hidden>
|
|
<div id="debug-header">
|
|
<strong>Debug Events</strong>
|
|
<span id="debug-count">0 events</span>
|
|
<button class="debug-action" id="debug-copy" type="button">Copy</button>
|
|
<button class="debug-action" id="debug-clear" type="button">Clear</button>
|
|
</div>
|
|
<pre id="debug-log"></pre>
|
|
</div>
|
|
|
|
<textarea id="ime-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
|
|
|
<script type="module">
|
|
// invoke() calls the Hub's clip API endpoint. Works in subdomain mode:
|
|
// browser.hub.pinixai.com/api/stream.start -> Hub proxies to provider invoke
|
|
async function invoke(command, input) {
|
|
const resp = await fetch(`/api/${command}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(input || {}),
|
|
});
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error(`invoke ${command} failed (${resp.status}): ${text}`);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
// --- State ---
|
|
let pc = null;
|
|
let dc = null;
|
|
let connected = false;
|
|
let activeTabId = null;
|
|
let lastTabs = [];
|
|
let tabPollTimer = null;
|
|
let lastMouseTraceAt = 0;
|
|
|
|
let videoEl = document.getElementById("video");
|
|
let btnConnect = document.getElementById("btn-connect");
|
|
let imeInput = document.getElementById("ime-input");
|
|
let videoContainer = document.getElementById("video-container");
|
|
let videoOverlay = document.getElementById("video-overlay");
|
|
let inputOverlay = document.getElementById("input-overlay");
|
|
let isComposing = false;
|
|
|
|
// UI elements
|
|
const statusDot = document.getElementById("status-dot");
|
|
const statusText = document.getElementById("status-text");
|
|
const statusRes = document.getElementById("status-resolution");
|
|
const urlInput = document.getElementById("url-input");
|
|
const tabBar = document.getElementById("tab-bar");
|
|
const newTabBtn = document.getElementById("new-tab-btn");
|
|
const backBtn = document.getElementById("btn-back");
|
|
const forwardBtn = document.getElementById("btn-forward");
|
|
const reloadBtn = document.getElementById("btn-reload");
|
|
const goBtn = document.getElementById("btn-go");
|
|
const debugToggleBtn = document.getElementById("debug-toggle");
|
|
const debugPanel = document.getElementById("debug-panel");
|
|
const debugLogEl = document.getElementById("debug-log");
|
|
const debugCountEl = document.getElementById("debug-count");
|
|
const debugCopyBtn = document.getElementById("debug-copy");
|
|
const debugClearBtn = document.getElementById("debug-clear");
|
|
|
|
// --- Debug harness ---
|
|
const debugEvents = [];
|
|
let debugEnabled = localStorage.getItem("bb-view-debug") === "1";
|
|
|
|
function safeDetail(detail) {
|
|
if (!detail) return "";
|
|
try { return JSON.stringify(detail); } catch { return String(detail); }
|
|
}
|
|
|
|
function debugLine(entry) {
|
|
const rel = String(entry.ms).padStart(7, " ");
|
|
const detail = safeDetail(entry.detail);
|
|
return `${entry.time} +${rel}ms [${entry.tag}] ${entry.event}${detail ? " " + detail : ""}`;
|
|
}
|
|
|
|
function renderDebugLog() {
|
|
debugCountEl.textContent = debugEvents.length + " events";
|
|
if (!debugEnabled) return;
|
|
debugLogEl.textContent = debugEvents.map(debugLine).join("\n");
|
|
debugLogEl.scrollTop = debugLogEl.scrollHeight;
|
|
}
|
|
|
|
function setDebugEnabled(enabled, trigger) {
|
|
debugEnabled = enabled;
|
|
localStorage.setItem("bb-view-debug", enabled ? "1" : "0");
|
|
debugPanel.hidden = !enabled;
|
|
debugToggleBtn.classList.toggle("active", enabled);
|
|
debugLog("debug", "toggle", { enabled, trigger });
|
|
renderDebugLog();
|
|
}
|
|
|
|
function debugLog(tag, event, detail) {
|
|
const entry = {
|
|
time: new Date().toISOString(),
|
|
ms: Math.round(performance.now()),
|
|
tag,
|
|
event,
|
|
detail: detail || null,
|
|
};
|
|
debugEvents.push(entry);
|
|
if (debugEvents.length > 500) debugEvents.shift();
|
|
if (debugEnabled) {
|
|
console.debug("[bb-view]", entry);
|
|
renderDebugLog();
|
|
}
|
|
}
|
|
|
|
function traceMouseMove(detail) {
|
|
const now = performance.now();
|
|
if (now - lastMouseTraceAt < 250) return;
|
|
lastMouseTraceAt = now;
|
|
debugLog("input", "mouse_move", detail);
|
|
}
|
|
|
|
// Init debug state
|
|
setDebugEnabled(debugEnabled, "init");
|
|
|
|
debugToggleBtn.onclick = () => setDebugEnabled(!debugEnabled, "button");
|
|
debugClearBtn.onclick = () => {
|
|
debugEvents.length = 0;
|
|
debugLog("debug", "clear", { trigger: "button" });
|
|
};
|
|
debugCopyBtn.onclick = async () => {
|
|
const text = debugEvents.map(debugLine).join("\n");
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
debugLog("debug", "copy", { events: debugEvents.length, ok: true });
|
|
} catch (e) {
|
|
debugLog("debug", "copy_failed", { message: e.message });
|
|
}
|
|
};
|
|
|
|
// Ctrl+Shift+D shortcut
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.ctrlKey && e.shiftKey && e.code === "KeyD") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDebugEnabled(!debugEnabled, "shortcut");
|
|
}
|
|
}, true);
|
|
|
|
// --- Status ---
|
|
function setStatus(state, text) {
|
|
debugLog("status", "set", { state, text });
|
|
statusDot.className = "status-dot " + state;
|
|
statusText.textContent = text;
|
|
}
|
|
|
|
// Overlay messages by disconnect reason
|
|
const OVERLAY_MESSAGES = {
|
|
user: "Disconnected",
|
|
idle: "Stream closed \u2014 tab was inactive",
|
|
network: "Connection lost",
|
|
init: "Click Connect to start",
|
|
};
|
|
|
|
function setConnected(state, reason) {
|
|
connected = state;
|
|
btnConnect.textContent = state ? "Disconnect" : "Connect";
|
|
btnConnect.classList.toggle("connected", state);
|
|
btnConnect.disabled = false;
|
|
// Video overlay
|
|
videoOverlay.classList.toggle("hidden", state);
|
|
if (!state) {
|
|
videoOverlay.textContent = OVERLAY_MESSAGES[reason] || OVERLAY_MESSAGES.init;
|
|
}
|
|
// Input overlay cursor
|
|
inputOverlay.classList.toggle("active", state);
|
|
// Tab/URL bar dimming
|
|
tabBar.classList.toggle("dimmed", !state);
|
|
document.getElementById("url-bar").classList.toggle("dimmed", !state);
|
|
if (state) {
|
|
setStatus("connected", "Connected");
|
|
} else {
|
|
setStatus("", "Disconnected");
|
|
}
|
|
}
|
|
|
|
// Video resolution display
|
|
videoEl.addEventListener("resize", () => {
|
|
statusRes.textContent = videoEl.videoWidth + "x" + videoEl.videoHeight;
|
|
debugLog("video", "resize", { width: videoEl.videoWidth, height: videoEl.videoHeight });
|
|
});
|
|
|
|
// --- Tab management ---
|
|
function renderTabs(tabs) {
|
|
tabBar.querySelectorAll(".tab").forEach(el => el.remove());
|
|
tabs.forEach(tab => {
|
|
const el = document.createElement("div");
|
|
el.className = "tab" + (tab.tab === activeTabId ? " active" : "");
|
|
el.dataset.id = tab.tab;
|
|
|
|
const title = document.createElement("span");
|
|
title.textContent = tab.title || tab.url || "New Tab";
|
|
title.style.overflow = "hidden";
|
|
title.style.textOverflow = "ellipsis";
|
|
el.appendChild(title);
|
|
|
|
const close = document.createElement("span");
|
|
close.className = "tab-close";
|
|
close.textContent = "\u00d7";
|
|
close.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
debugLog("tab", "close_click", { tab: tab.tab });
|
|
try {
|
|
await invoke("close", { tab: tab.tab });
|
|
debugLog("tab", "close_done", { tab: tab.tab });
|
|
await fetchTabs();
|
|
} catch (err) {
|
|
debugLog("tab", "close_failed", { tab: tab.tab, message: err.message });
|
|
setStatus("error", "Close tab failed: " + err.message);
|
|
}
|
|
};
|
|
el.appendChild(close);
|
|
|
|
el.onclick = () => selectTab(tab.tab);
|
|
tabBar.insertBefore(el, newTabBtn);
|
|
});
|
|
}
|
|
|
|
async function fetchTabs() {
|
|
try {
|
|
debugLog("api", "fetch_tabs_start", { activeTabId });
|
|
const result = await invoke("tab_list", {});
|
|
const tabs = (result.tabs || []).map((t, i) => ({ ...t, tab: t.tab || t.id, index: i }));
|
|
lastTabs = tabs;
|
|
// Update active tab — find the tab marked active by daemon
|
|
const daemonActive = tabs.find(t => t.active);
|
|
if (tabs.length === 0) {
|
|
activeTabId = null;
|
|
} else if (daemonActive && (!activeTabId || !tabs.some(t => t.tab === activeTabId))) {
|
|
activeTabId = daemonActive.tab;
|
|
} else if (!activeTabId || !tabs.some(t => t.tab === activeTabId)) {
|
|
activeTabId = tabs[0].tab;
|
|
}
|
|
renderTabs(tabs);
|
|
// Update URL bar with active tab's URL (skip if user is editing)
|
|
const activeTab = tabs.find(t => t.tab === activeTabId);
|
|
if (activeTab && activeTab.url && document.activeElement !== urlInput) {
|
|
urlInput.value = activeTab.url;
|
|
}
|
|
debugLog("api", "fetch_tabs_done", { count: tabs.length, activeTabId });
|
|
return tabs;
|
|
} catch (err) {
|
|
debugLog("api", "fetch_tabs_failed", { message: err.message });
|
|
// Don't clear existing tabs on error
|
|
renderTabs(lastTabs);
|
|
return lastTabs;
|
|
}
|
|
}
|
|
|
|
async function selectTab(tabId) {
|
|
if (tabId === activeTabId) return;
|
|
debugLog("tab", "select_click", { tab: tabId, previous: activeTabId });
|
|
activeTabId = tabId;
|
|
renderTabs(lastTabs);
|
|
try {
|
|
if (connected) {
|
|
await invoke("stream.switch", { tab: tabId });
|
|
}
|
|
debugLog("tab", "select_done", { tab: tabId });
|
|
await fetchTabs();
|
|
} catch (err) {
|
|
debugLog("tab", "select_failed", { tab: tabId, message: err.message });
|
|
setStatus("error", "Select tab failed: " + err.message);
|
|
}
|
|
}
|
|
|
|
newTabBtn.onclick = async () => {
|
|
debugLog("tab", "new_click", { activeTabId });
|
|
try {
|
|
const result = await invoke("tab_new", {});
|
|
activeTabId = result.tab || result.id;
|
|
debugLog("tab", "new_done", { tab: result.tab });
|
|
// Switch streamer to the new tab
|
|
if (connected && activeTabId) {
|
|
try { await invoke("stream.switch", { tab: activeTabId }); } catch {}
|
|
}
|
|
await fetchTabs();
|
|
} catch (err) {
|
|
debugLog("tab", "new_failed", { message: err.message });
|
|
setStatus("error", "New tab failed: " + err.message);
|
|
}
|
|
};
|
|
|
|
// Start tab polling
|
|
function startTabPolling() {
|
|
if (tabPollTimer) return;
|
|
tabPollTimer = setInterval(() => { fetchTabs(); }, 5000);
|
|
}
|
|
|
|
function stopTabPolling() {
|
|
if (tabPollTimer) {
|
|
clearInterval(tabPollTimer);
|
|
tabPollTimer = null;
|
|
}
|
|
}
|
|
|
|
// --- URL bar navigation ---
|
|
function normalizeURL(url) {
|
|
url = url.trim();
|
|
if (/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|\[::1\])(?::|\/|$)/i.test(url)) {
|
|
return "http://" + url;
|
|
}
|
|
if (/^(https?|about|chrome|file|data|devtools):/i.test(url)) {
|
|
return url;
|
|
}
|
|
return "https://" + url;
|
|
}
|
|
|
|
async function navigateToURL(url) {
|
|
if (!url) {
|
|
debugLog("nav", "ignored", { reason: "empty_url" });
|
|
return;
|
|
}
|
|
url = normalizeURL(url);
|
|
urlInput.value = url;
|
|
debugLog("nav", "trigger", { url, tab: activeTabId, trigger: "url_bar" });
|
|
try {
|
|
const params = { url };
|
|
if (activeTabId) params.tab = activeTabId;
|
|
const result = await invoke("open", params);
|
|
debugLog("nav", "done", { url, result });
|
|
// Refresh tabs after navigation to update titles
|
|
setTimeout(() => fetchTabs(), 1000);
|
|
} catch (err) {
|
|
debugLog("nav", "failed", { url, message: err.message });
|
|
setStatus("error", "Navigate failed: " + err.message);
|
|
}
|
|
}
|
|
|
|
backBtn.onclick = async () => {
|
|
debugLog("nav", "back_click");
|
|
const params = activeTabId ? { tab: activeTabId } : {};
|
|
try { await invoke("back", params); } catch (err) { debugLog("nav", "back_failed", { message: err.message }); }
|
|
};
|
|
forwardBtn.onclick = async () => {
|
|
debugLog("nav", "forward_click");
|
|
const params = activeTabId ? { tab: activeTabId } : {};
|
|
try { await invoke("forward", params); } catch (err) { debugLog("nav", "forward_failed", { message: err.message }); }
|
|
};
|
|
reloadBtn.onclick = async () => {
|
|
debugLog("nav", "reload_click");
|
|
const params = activeTabId ? { tab: activeTabId } : {};
|
|
try { await invoke("reload", params); } catch (err) { debugLog("nav", "reload_failed", { message: err.message }); }
|
|
};
|
|
goBtn.onclick = () => navigateToURL(urlInput.value);
|
|
|
|
// Prevent URL input keystrokes from being forwarded to remote,
|
|
// AND handle Enter for navigation — single capture-phase listener.
|
|
urlInput.addEventListener("keydown", (e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Enter" && !e.isComposing) {
|
|
e.preventDefault();
|
|
debugLog("nav", "enter", { value: urlInput.value });
|
|
navigateToURL(urlInput.value);
|
|
urlInput.blur();
|
|
}
|
|
}, true);
|
|
urlInput.addEventListener("keyup", (e) => {
|
|
// Let modifier key releases propagate to document so onKeyUp can
|
|
// forward them to the remote and clear the stuck modifier state.
|
|
if (e.key === "Meta" || e.key === "Control" || e.key === "Shift" || e.key === "Alt") return;
|
|
e.stopPropagation();
|
|
}, true);
|
|
|
|
|
|
// --- Binary protocol helpers ---
|
|
function packHeader(opcode, payloadLen) {
|
|
const buf = new ArrayBuffer(3 + payloadLen);
|
|
const view = new DataView(buf);
|
|
view.setUint8(0, opcode);
|
|
view.setUint16(1, payloadLen);
|
|
return { buf, view };
|
|
}
|
|
|
|
function sendMouseMove(x, y) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const { buf, view } = packHeader(0x01, 4);
|
|
view.setUint16(3, x);
|
|
view.setUint16(5, y);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendScroll(dx, dy) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const { buf, view } = packHeader(0x02, 4);
|
|
view.setInt16(3, dx);
|
|
view.setInt16(5, dy);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendKeyDown(keysym) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const { buf, view } = packHeader(0x03, 4);
|
|
view.setUint32(3, keysym);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendKeyUp(keysym) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const { buf, view } = packHeader(0x04, 4);
|
|
view.setUint32(3, keysym);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendButtonDown(button) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const { buf, view } = packHeader(0x05, 1);
|
|
view.setUint8(3, button);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendButtonUp(button) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const { buf, view } = packHeader(0x06, 1);
|
|
view.setUint8(3, button);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendInsertText(text) {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
const encoded = new TextEncoder().encode(text);
|
|
const { buf, view } = packHeader(0x07, encoded.length);
|
|
new Uint8Array(buf).set(encoded, 3);
|
|
dc.send(buf);
|
|
}
|
|
|
|
function sendPing() {
|
|
if (!dc || dc.readyState !== "open") return;
|
|
try {
|
|
// Send timestamp as two uint32s (ms since epoch fits in 53 bits)
|
|
const now = Date.now();
|
|
const { buf, view } = packHeader(0x08, 8);
|
|
view.setUint32(3, Math.floor(now / 0x100000000));
|
|
view.setUint32(7, now >>> 0);
|
|
dc.send(buf);
|
|
} catch (e) {
|
|
debugLog("heartbeat", "ping_error", { message: e.message });
|
|
}
|
|
}
|
|
|
|
// --- Heartbeat & Visibility lifecycle ---
|
|
const HEARTBEAT_ACTIVE_MS = 5000; // ping interval when visible
|
|
const HEARTBEAT_IDLE_MS = 10000; // ping interval when hidden
|
|
const IDLE_TIMEOUT_MS = 30000; // disconnect after 30s hidden
|
|
|
|
let heartbeatTimer = null;
|
|
let idleTimer = null;
|
|
|
|
// --- Keysym mapping ---
|
|
const SPECIAL_KEYS = {
|
|
Backspace: 0xFF08, Tab: 0xFF09, Enter: 0xFF0D, Escape: 0xFF1B,
|
|
Delete: 0xFFFF, Home: 0xFF50, End: 0xFF57,
|
|
PageUp: 0xFF55, PageDown: 0xFF56,
|
|
ArrowLeft: 0xFF51, ArrowUp: 0xFF52, ArrowRight: 0xFF53, ArrowDown: 0xFF54,
|
|
ShiftLeft: 0xFFE1, ShiftRight: 0xFFE2,
|
|
ControlLeft: 0xFFE3, ControlRight: 0xFFE4,
|
|
AltLeft: 0xFFE9, AltRight: 0xFFEA,
|
|
MetaLeft: 0xFFEB, MetaRight: 0xFFEC,
|
|
CapsLock: 0xFFE5, NumLock: 0xFF7F, ScrollLock: 0xFF14,
|
|
Insert: 0xFF63, Pause: 0xFF13, PrintScreen: 0xFF61,
|
|
F1: 0xFFBE, F2: 0xFFBF, F3: 0xFFC0, F4: 0xFFC1,
|
|
F5: 0xFFC2, F6: 0xFFC3, F7: 0xFFC4, F8: 0xFFC5,
|
|
F9: 0xFFC6, F10: 0xFFC7, F11: 0xFFC8, F12: 0xFFC9,
|
|
Space: 0x0020,
|
|
};
|
|
|
|
function keyToKeysym(e) {
|
|
if (SPECIAL_KEYS[e.code]) return SPECIAL_KEYS[e.code];
|
|
if (SPECIAL_KEYS[e.key]) return SPECIAL_KEYS[e.key];
|
|
// Modifier keys by key name
|
|
if (e.key === "Shift") return 0xFFE1;
|
|
if (e.key === "Control") return 0xFFE3;
|
|
if (e.key === "Alt") return 0xFFE9;
|
|
if (e.key === "Meta") return 0xFFEB;
|
|
// Printable ASCII
|
|
if (e.key.length === 1) {
|
|
const code = e.key.charCodeAt(0);
|
|
if (code >= 0x20 && code <= 0x7E) return code;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// --- Mouse coordinate mapping ---
|
|
function videoCoords(e) {
|
|
const rect = videoEl.getBoundingClientRect();
|
|
const vw = videoEl.videoWidth || 1;
|
|
const vh = videoEl.videoHeight || 1;
|
|
// Compute displayed area within the element (object-fit: contain)
|
|
const scale = Math.min(rect.width / vw, rect.height / vh);
|
|
const dw = vw * scale;
|
|
const dh = vh * scale;
|
|
const ox = (rect.width - dw) / 2;
|
|
const oy = (rect.height - dh) / 2;
|
|
const lx = e.clientX - rect.left - ox;
|
|
const ly = e.clientY - rect.top - oy;
|
|
const x = Math.round((lx / dw) * vw);
|
|
const y = Math.round((ly / dh) * vh);
|
|
return [Math.max(0, Math.min(vw, x)), Math.max(0, Math.min(vh, y))];
|
|
}
|
|
|
|
function mapButton(e) {
|
|
// Web API: 0=left, 1=middle, 2=right
|
|
// bb-viewer expects same 0-indexed: 0=left, 1=middle, 2=right
|
|
return e.button;
|
|
}
|
|
|
|
// --- Event handlers ---
|
|
function onMouseMove(e) {
|
|
if (!connected) return;
|
|
const [x, y] = videoCoords(e);
|
|
traceMouseMove({ x, y, buttons: e.buttons });
|
|
sendMouseMove(x, y);
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
if (!connected) return;
|
|
e.preventDefault();
|
|
imeInput.focus();
|
|
const [x, y] = videoCoords(e);
|
|
debugLog("input", "mouse_down", { x, y, button: e.button });
|
|
sendMouseMove(x, y);
|
|
sendButtonDown(mapButton(e));
|
|
}
|
|
|
|
function onMouseUp(e) {
|
|
if (!connected) return;
|
|
e.preventDefault();
|
|
debugLog("input", "mouse_up", { button: e.button });
|
|
sendButtonUp(mapButton(e));
|
|
}
|
|
|
|
function onWheel(e) {
|
|
if (!connected) return;
|
|
e.preventDefault();
|
|
const dx = Math.round(e.deltaX);
|
|
const dy = Math.round(e.deltaY);
|
|
debugLog("input", "wheel", { deltaX: dx, deltaY: dy });
|
|
sendScroll(dx, dy);
|
|
}
|
|
|
|
function onContextMenu(e) {
|
|
if (connected) e.preventDefault();
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (!connected) return;
|
|
if (e.isComposing || e.keyCode === 229) return;
|
|
// Don't capture if URL input is focused
|
|
if (document.activeElement === urlInput) return;
|
|
e.preventDefault();
|
|
const ks = keyToKeysym(e);
|
|
if (ks) {
|
|
debugLog("input", "key_down", { key: e.key, code: e.code, keysym: ks });
|
|
sendKeyDown(ks);
|
|
}
|
|
}
|
|
|
|
function onKeyUp(e) {
|
|
if (!connected) return;
|
|
if (e.isComposing || e.keyCode === 229) return;
|
|
const ks = keyToKeysym(e);
|
|
if (!ks) return;
|
|
// Always forward modifier key releases even when URL input is focused,
|
|
// so the remote doesn't get stuck with a modifier held down.
|
|
const isModifier = ks >= 0xFFE1 && ks <= 0xFFEE;
|
|
if (document.activeElement === urlInput && !isModifier) return;
|
|
e.preventDefault();
|
|
debugLog("input", "key_up", { key: e.key, code: e.code, keysym: ks });
|
|
sendKeyUp(ks);
|
|
}
|
|
|
|
function onCompositionStart() { isComposing = true; debugLog("input", "composition_start"); }
|
|
function onCompositionEnd(e) {
|
|
isComposing = false;
|
|
debugLog("input", "composition_end", { data: e.data });
|
|
if (e.data) sendInsertText(e.data);
|
|
imeInput.value = "";
|
|
}
|
|
|
|
// --- WebRTC ---
|
|
async function connect() {
|
|
if (connected) return;
|
|
btnConnect.disabled = true;
|
|
videoOverlay.textContent = "Connecting...";
|
|
videoOverlay.classList.remove("hidden");
|
|
setStatus("connecting", "Requesting offer...");
|
|
debugLog("webrtc", "connect_start");
|
|
let offer;
|
|
try {
|
|
offer = await invoke("stream.start", {});
|
|
debugLog("webrtc", "offer_received", { sdpBytes: offer.offer_sdp ? offer.offer_sdp.length : 0, candidates: (offer.candidates || []).length });
|
|
} catch (err) {
|
|
setStatus("error", "Failed: " + (err.message || err));
|
|
debugLog("webrtc", "offer_failed", { message: err.message });
|
|
btnConnect.disabled = false;
|
|
videoOverlay.textContent = "Click Connect to start";
|
|
return;
|
|
}
|
|
|
|
const iceServers = (offer.ice_servers || []).map(s => ({
|
|
urls: s.urls || [],
|
|
username: s.username || undefined,
|
|
credential: s.credential || undefined,
|
|
})).filter(s => s.urls.length > 0);
|
|
|
|
const config = { iceServers: iceServers.length > 0 ? iceServers : [{ urls: "stun:stun.l.google.com:19302" }] };
|
|
pc = new RTCPeerConnection(config);
|
|
debugLog("webrtc", "pc_created", { iceServers: config.iceServers.length });
|
|
|
|
pc.ontrack = (e) => {
|
|
debugLog("webrtc", "track", { kind: e.track.kind, streams: e.streams ? e.streams.length : 0 });
|
|
if (e.streams && e.streams[0]) {
|
|
videoEl.srcObject = e.streams[0];
|
|
} else {
|
|
const stream = new MediaStream();
|
|
stream.addTrack(e.track);
|
|
videoEl.srcObject = stream;
|
|
}
|
|
try { videoEl.playoutDelayHint = 0; } catch {}
|
|
try { videoEl.jitterBufferTarget = 0; } catch {}
|
|
};
|
|
|
|
pc.ondatachannel = (e) => {
|
|
dc = e.channel;
|
|
dc.binaryType = "arraybuffer";
|
|
debugLog("webrtc", "datachannel", { label: dc.label });
|
|
dc.onopen = () => {
|
|
debugLog("webrtc", "datachannel_open");
|
|
setConnected(true, null);
|
|
imeInput.focus();
|
|
// Start tab polling once connected
|
|
fetchTabs();
|
|
startTabPolling();
|
|
startHeartbeat();
|
|
};
|
|
dc.onclose = () => {
|
|
debugLog("webrtc", "datachannel_close");
|
|
cleanup("network");
|
|
};
|
|
};
|
|
|
|
pc.oniceconnectionstatechange = () => {
|
|
const state = pc.iceConnectionState;
|
|
debugLog("webrtc", "ice_state", { state });
|
|
if (state === "connected" || state === "completed") {
|
|
setStatus("connected", "Connected");
|
|
} else if (state === "failed") {
|
|
setStatus("error", "ICE failed");
|
|
cleanup("network");
|
|
} else if (state === "disconnected") {
|
|
setStatus("error", "ICE disconnected");
|
|
cleanup("network");
|
|
} else if (state === "checking") {
|
|
setStatus("connecting", "ICE: " + state);
|
|
}
|
|
};
|
|
|
|
// Set remote offer
|
|
setStatus("connecting", "Setting remote offer...");
|
|
try {
|
|
await pc.setRemoteDescription({ type: "offer", sdp: offer.offer_sdp });
|
|
debugLog("webrtc", "set_remote_description_done");
|
|
} catch (err) {
|
|
setStatus("error", "SDP error: " + err.message);
|
|
debugLog("webrtc", "set_remote_description_failed", { message: err.message });
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
// Add remote ICE candidates
|
|
const remoteCandidates = offer.candidates || [];
|
|
for (const c of remoteCandidates) {
|
|
try {
|
|
await pc.addIceCandidate(new RTCIceCandidate({
|
|
candidate: c.candidate,
|
|
sdpMLineIndex: c.sdpMLineIndex,
|
|
sdpMid: c.sdpMid || null,
|
|
}));
|
|
} catch (err) {
|
|
debugLog("webrtc", "remote_candidate_failed", { message: err.message });
|
|
}
|
|
}
|
|
|
|
// Create answer
|
|
setStatus("connecting", "Creating answer...");
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
debugLog("webrtc", "answer_created", { sdpBytes: answer.sdp.length });
|
|
|
|
// Wait for ICE gathering complete
|
|
const localCandidates = [];
|
|
await new Promise((resolve) => {
|
|
if (pc.iceGatheringState === "complete") { resolve(); return; }
|
|
const timeout = setTimeout(resolve, 5000);
|
|
pc.onicegatheringstatechange = () => {
|
|
if (pc.iceGatheringState === "complete") { clearTimeout(timeout); resolve(); }
|
|
};
|
|
pc.onicecandidate = (e) => {
|
|
if (e.candidate) {
|
|
localCandidates.push({
|
|
candidate: e.candidate.candidate,
|
|
sdpMLineIndex: e.candidate.sdpMLineIndex,
|
|
sdpMid: e.candidate.sdpMid || "",
|
|
});
|
|
}
|
|
};
|
|
});
|
|
debugLog("webrtc", "ice_gathering_done", { candidates: localCandidates.length });
|
|
|
|
// Send answer
|
|
setStatus("connecting", "Sending answer...");
|
|
try {
|
|
await invoke("stream.answer", {
|
|
answer_sdp: pc.localDescription.sdp,
|
|
candidates: localCandidates,
|
|
});
|
|
debugLog("webrtc", "answer_sent");
|
|
} catch (err) {
|
|
setStatus("error", "Answer failed: " + (err.message || err));
|
|
debugLog("webrtc", "answer_send_failed", { message: err.message });
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
setStatus("connecting", "Waiting for connection...");
|
|
}
|
|
|
|
async function disconnect(reason) {
|
|
reason = reason || "user";
|
|
debugLog("webrtc", "disconnect", { reason });
|
|
// Fire-and-forget: don't wait for stream.close response.
|
|
// The server may be slow or unreachable; cleanup must happen immediately
|
|
// so the UI updates and heartbeat stops.
|
|
invoke("stream.close", {}).catch(() => {});
|
|
cleanup(reason);
|
|
}
|
|
|
|
function cleanup(reason) {
|
|
reason = reason || "user";
|
|
debugLog("webrtc", "cleanup", { reason });
|
|
stopHeartbeat();
|
|
if (dc) { try { dc.close(); } catch {} dc = null; }
|
|
if (pc) { try { pc.close(); } catch {} pc = null; }
|
|
if (videoEl.srcObject) {
|
|
videoEl.srcObject.getTracks().forEach(t => t.stop());
|
|
videoEl.srcObject = null;
|
|
}
|
|
stopTabPolling();
|
|
setConnected(false, reason);
|
|
}
|
|
|
|
// --- Heartbeat lifecycle ---
|
|
function startHeartbeat() {
|
|
stopHeartbeat();
|
|
const interval = document.hidden ? HEARTBEAT_IDLE_MS : HEARTBEAT_ACTIVE_MS;
|
|
heartbeatTimer = setInterval(sendPing, interval);
|
|
sendPing(); // send immediately
|
|
debugLog("heartbeat", "start", { interval, hidden: document.hidden });
|
|
}
|
|
|
|
function stopHeartbeat() {
|
|
if (heartbeatTimer) {
|
|
clearInterval(heartbeatTimer);
|
|
heartbeatTimer = null;
|
|
}
|
|
if (idleTimer) {
|
|
clearTimeout(idleTimer);
|
|
idleTimer = null;
|
|
}
|
|
}
|
|
|
|
// --- Visibility change: idle timeout ---
|
|
// We use a polling interval instead of setTimeout because browsers heavily
|
|
// throttle timers in hidden tabs (Chrome delays them to >=1 minute).
|
|
// A setTimeout(fn, 30000) in a hidden tab may not fire for over a minute.
|
|
// Instead we record when the tab was hidden and check elapsed time on each
|
|
// heartbeat tick — the interval is throttled but still fires periodically.
|
|
let hiddenSince = 0; // timestamp when tab became hidden, 0 = visible
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (!connected) return;
|
|
|
|
if (document.hidden) {
|
|
hiddenSince = Date.now();
|
|
debugLog("lifecycle", "tab_hidden", { timeout: IDLE_TIMEOUT_MS });
|
|
// Switch to idle heartbeat — each tick also checks idle timeout
|
|
stopHeartbeat();
|
|
heartbeatTimer = setInterval(() => {
|
|
sendPing();
|
|
if (hiddenSince > 0 && Date.now() - hiddenSince >= IDLE_TIMEOUT_MS) {
|
|
debugLog("lifecycle", "idle_timeout", { elapsed: Date.now() - hiddenSince });
|
|
disconnect("idle");
|
|
}
|
|
}, HEARTBEAT_IDLE_MS);
|
|
sendPing();
|
|
} else {
|
|
hiddenSince = 0;
|
|
debugLog("lifecycle", "tab_visible");
|
|
// Cancel any pending idle — switch back to active heartbeat
|
|
stopHeartbeat();
|
|
heartbeatTimer = setInterval(sendPing, HEARTBEAT_ACTIVE_MS);
|
|
sendPing();
|
|
}
|
|
});
|
|
|
|
// --- Event binding ---
|
|
inputOverlay.addEventListener("mousemove", onMouseMove);
|
|
inputOverlay.addEventListener("mousedown", onMouseDown);
|
|
inputOverlay.addEventListener("mouseup", onMouseUp);
|
|
inputOverlay.addEventListener("wheel", onWheel, { passive: false });
|
|
inputOverlay.addEventListener("contextmenu", onContextMenu);
|
|
|
|
document.addEventListener("keydown", onKeyDown);
|
|
document.addEventListener("keyup", onKeyUp);
|
|
|
|
imeInput.addEventListener("compositionstart", onCompositionStart);
|
|
imeInput.addEventListener("compositionend", onCompositionEnd);
|
|
|
|
btnConnect.addEventListener("click", () => {
|
|
if (connected) disconnect("user");
|
|
else connect();
|
|
});
|
|
|
|
// Focus IME input on overlay click + release all modifiers to clear any
|
|
// stuck state from when focus was elsewhere (URL bar, toolbar, etc.)
|
|
inputOverlay.addEventListener("click", () => {
|
|
imeInput.focus();
|
|
const mods = [0xFFE1, 0xFFE2, 0xFFE3, 0xFFE4, 0xFFE9, 0xFFEA, 0xFFEB, 0xFFEC];
|
|
for (const ks of mods) sendKeyUp(ks);
|
|
});
|
|
|
|
// Reset all modifier keys when window regains focus — handles Cmd+Tab
|
|
// and other cases where keyUp fires outside our page.
|
|
window.addEventListener("focus", () => {
|
|
if (!connected) return;
|
|
const mods = [0xFFE1, 0xFFE2, 0xFFE3, 0xFFE4, 0xFFE9, 0xFFEA, 0xFFEB, 0xFFEC];
|
|
for (const ks of mods) sendKeyUp(ks);
|
|
});
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener("beforeunload", () => {
|
|
if (connected) {
|
|
try { invoke("stream.close", {}); } catch {}
|
|
cleanup("user");
|
|
}
|
|
});
|
|
|
|
// Expose debug API on window
|
|
window.__bbViewDebug = {
|
|
events: debugEvents,
|
|
enable: () => setDebugEnabled(true, "console"),
|
|
disable: () => setDebugEnabled(false, "console"),
|
|
dump: () => debugEvents.map(debugLine).join("\n"),
|
|
};
|
|
|
|
// Initial UI state: dim tab/URL bar until connected
|
|
tabBar.classList.add("dimmed");
|
|
document.getElementById("url-bar").classList.add("dimmed");
|
|
|
|
// Initial tab fetch (works even before WebRTC connection)
|
|
fetchTabs();
|
|
</script>
|
|
</body>
|
|
</html>
|