mirror of
https://github.com/epiral/bb-browser.git
synced 2026-09-14 13:45:45 +08:00
f8b2fb5662
@pinixai/core is a private package, not available on esm.sh.
Use direct fetch to /api/{command} which the Hub's subdomain
handler proxies to provider invoke.
406 lines
12 KiB
HTML
406 lines
12 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: 6px 12px; background: #1a1a1a; border-bottom: 1px solid #2a2a2a; flex-shrink: 0; }
|
|
#toolbar button { padding: 4px 14px; border: 1px solid #444; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; cursor: pointer; font-size: 13px; }
|
|
#toolbar button:hover { background: #3a3a3a; }
|
|
#toolbar button.connected { border-color: #e55; background: #3a1a1a; }
|
|
#status { font-size: 12px; color: #888; margin-left: auto; }
|
|
#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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div id="toolbar">
|
|
<button id="btn-connect">Connect</button>
|
|
<span id="status">Disconnected</span>
|
|
</div>
|
|
<div id="video-container">
|
|
<video id="video" autoplay playsinline muted></video>
|
|
</div>
|
|
</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/view.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 videoEl = document.getElementById("video");
|
|
let btnConnect = document.getElementById("btn-connect");
|
|
let statusEl = document.getElementById("status");
|
|
let imeInput = document.getElementById("ime-input");
|
|
let videoContainer = document.getElementById("video-container");
|
|
let isComposing = false;
|
|
|
|
// --- UI ---
|
|
function setStatus(text) { statusEl.textContent = text; }
|
|
|
|
function setConnected(state) {
|
|
connected = state;
|
|
btnConnect.textContent = state ? "Disconnect" : "Connect";
|
|
btnConnect.classList.toggle("connected", state);
|
|
setStatus(state ? "Connected" : "Disconnected");
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
// --- 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) {
|
|
// 0=left, 1=middle, 2=right
|
|
if (e.button === 0) return 1;
|
|
if (e.button === 1) return 2;
|
|
if (e.button === 2) return 3;
|
|
return 1;
|
|
}
|
|
|
|
// --- Event handlers ---
|
|
function onMouseMove(e) {
|
|
if (!connected) return;
|
|
const [x, y] = videoCoords(e);
|
|
sendMouseMove(x, y);
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
if (!connected) return;
|
|
e.preventDefault();
|
|
imeInput.focus();
|
|
const [x, y] = videoCoords(e);
|
|
sendMouseMove(x, y);
|
|
sendButtonDown(mapButton(e));
|
|
}
|
|
|
|
function onMouseUp(e) {
|
|
if (!connected) return;
|
|
e.preventDefault();
|
|
sendButtonUp(mapButton(e));
|
|
}
|
|
|
|
function onWheel(e) {
|
|
if (!connected) return;
|
|
e.preventDefault();
|
|
const dx = Math.round(e.deltaX);
|
|
const dy = Math.round(e.deltaY);
|
|
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 prevent Tab if not connected
|
|
e.preventDefault();
|
|
const ks = keyToKeysym(e);
|
|
if (ks) sendKeyDown(ks);
|
|
}
|
|
|
|
function onKeyUp(e) {
|
|
if (!connected) return;
|
|
if (e.isComposing || e.keyCode === 229) return;
|
|
e.preventDefault();
|
|
const ks = keyToKeysym(e);
|
|
if (ks) sendKeyUp(ks);
|
|
}
|
|
|
|
function onCompositionStart() { isComposing = true; }
|
|
function onCompositionEnd(e) {
|
|
isComposing = false;
|
|
if (e.data) sendInsertText(e.data);
|
|
imeInput.value = "";
|
|
}
|
|
|
|
// --- WebRTC ---
|
|
async function connect() {
|
|
if (connected) return;
|
|
setStatus("Requesting offer...");
|
|
let offer;
|
|
try {
|
|
offer = await invoke("view.start", {});
|
|
} catch (err) {
|
|
setStatus("Failed: " + (err.message || err));
|
|
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);
|
|
|
|
pc.ontrack = (e) => {
|
|
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";
|
|
dc.onopen = () => { setConnected(true); imeInput.focus(); };
|
|
dc.onclose = () => { cleanup(); };
|
|
};
|
|
|
|
pc.oniceconnectionstatechange = () => {
|
|
setStatus("ICE: " + pc.iceConnectionState);
|
|
if (pc.iceConnectionState === "failed" || pc.iceConnectionState === "disconnected") {
|
|
cleanup();
|
|
}
|
|
};
|
|
|
|
// Set remote offer
|
|
setStatus("Setting remote offer...");
|
|
try {
|
|
await pc.setRemoteDescription({ type: "offer", sdp: offer.offer_sdp });
|
|
} catch (err) {
|
|
setStatus("SDP error: " + 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) {
|
|
console.warn("Failed to add remote candidate:", err);
|
|
}
|
|
}
|
|
|
|
// Create answer
|
|
setStatus("Creating answer...");
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
|
|
// 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 || "",
|
|
});
|
|
}
|
|
};
|
|
});
|
|
|
|
// Send answer
|
|
setStatus("Sending answer...");
|
|
try {
|
|
await invoke("view.answer", {
|
|
answer_sdp: pc.localDescription.sdp,
|
|
candidates: localCandidates,
|
|
});
|
|
} catch (err) {
|
|
setStatus("Answer failed: " + (err.message || err));
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
setStatus("Waiting for connection...");
|
|
}
|
|
|
|
async function disconnect() {
|
|
try { await invoke("view.close", {}); } catch {}
|
|
cleanup();
|
|
}
|
|
|
|
function cleanup() {
|
|
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;
|
|
}
|
|
setConnected(false);
|
|
}
|
|
|
|
// --- Event binding ---
|
|
videoEl.addEventListener("mousemove", onMouseMove);
|
|
videoEl.addEventListener("mousedown", onMouseDown);
|
|
videoEl.addEventListener("mouseup", onMouseUp);
|
|
videoEl.addEventListener("wheel", onWheel, { passive: false });
|
|
videoEl.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();
|
|
else connect();
|
|
});
|
|
|
|
// Focus IME input on video click
|
|
videoEl.addEventListener("click", () => { imeInput.focus(); });
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener("beforeunload", () => {
|
|
if (connected) {
|
|
try { invoke("view.close", {}); } catch {}
|
|
cleanup();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|