diff --git a/node_modules/lucarne/dist/cdp.d.ts b/node_modules/lucarne/dist/cdp.d.ts index 9f706c0..a713375 100644 --- a/node_modules/lucarne/dist/cdp.d.ts +++ b/node_modules/lucarne/dist/cdp.d.ts @@ -1,6 +1,6 @@ export interface CdpConn { - /** Fire-and-forget (no result needed) — used on the hot input/screencast path. */ - send(method: string, params?: Record): void; + /** Fire-and-forget (no result needed) — true when accepted by the live CDP socket. */ + send(method: string, params?: Record): boolean; /** Request/response — resolves with `result`, rejects on CDP `error`. */ call(method: string, params?: Record): Promise; on(method: string, cb: (params: any) => void): void; diff --git a/node_modules/lucarne/dist/cdp.js b/node_modules/lucarne/dist/cdp.js index b4d8cf5..b1b6e2b 100644 --- a/node_modules/lucarne/dist/cdp.js +++ b/node_modules/lucarne/dist/cdp.js @@ -151,10 +151,15 @@ async function connectCdp(wsUrl) { ws.onerror = handleClose; return { send(method, params = {}) { + if (closed || ws.readyState !== 1) + return false; try { ws.send(JSON.stringify({ id: id++, method, params })); + return true; + } + catch { + return false; } - catch { /* closed */ } }, call(method, params = {}) { if (closed) diff --git a/node_modules/lucarne/dist/engine.js b/node_modules/lucarne/dist/engine.js index c802b43..84e2183 100644 --- a/node_modules/lucarne/dist/engine.js +++ b/node_modules/lucarne/dist/engine.js @@ -102,6 +102,30 @@ function rebindForbidden(headers) { } return false; } +/** Privacy-safe input telemetry: never echo typed or pasted content to viewers. */ +function inputFeedbackEvent(event) { + if (event.t === "down" || event.t === "move" || event.t === "up") { + return { buttons: event.buttons, kind: event.t, x: event.x, y: event.y }; + } + if (event.t === "wheel") + return { kind: "wheel", label: "Scroll", x: event.x, y: event.y }; + if (event.t === "paste") + return { kind: "paste" }; + if (event.t === "ime") + return event.phase === "commit" ? { kind: "typing" } : undefined; + if (event.t !== "keydown" || event.repeat) + return undefined; + const modifiers = [ + event.mod && event.mod & 1 ? "Alt" : undefined, + event.mod && event.mod & 2 ? "Ctrl" : undefined, + event.mod && event.mod & 4 ? "⌘" : undefined, + event.mod && event.mod & 8 ? "Shift" : undefined, + ].filter(Boolean); + const printable = event.key !== undefined && event.key.length === 1; + return printable && modifiers.length === 0 + ? { kind: "typing" } + : { kind: "typing", label: [...modifiers, printable ? event.key.toUpperCase() : event.key ?? "Key"].join(" + ") }; +} /** * Reject a non-positive / NaN numeric config option AT CONSTRUCTION with a clear * message naming the offending option (lucarne's fail-closed ethos), rather than @@ -203,6 +227,7 @@ export class Lucarne { // stay peelable; the router delegates to each in turn. globalServices; wss = new WebSocketServer({ noServer: true }); + portholePeers = new Map(); server; reaper; registryFile; @@ -887,6 +912,26 @@ v.addEventListener('ended',()=>{i++;play()});play();}); return true; return headerAuth !== undefined && safeEqual(headerAuth, `Bearer ${this.token}`); } + addPortholePeer(sessionId, socket) { + const peers = this.portholePeers.get(sessionId) ?? new Set(); + peers.add(socket); + this.portholePeers.set(sessionId, peers); + return () => { + peers.delete(socket); + if (peers.size === 0) + this.portholePeers.delete(sessionId); + }; + } + broadcastInputFeedback(sessionId, event) { + const feedback = inputFeedbackEvent(event); + if (feedback === undefined) + return; + const payload = JSON.stringify({ event: feedback, t: "input-feedback" }); + for (const peer of this.portholePeers.get(sessionId) ?? []) { + if (peer.readyState === peer.OPEN && peer.bufferedAmount < 64_000) + peer.send(payload); + } + } listen() { const send = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); @@ -1234,7 +1279,8 @@ v.addEventListener('ended',()=>{i++;play()});play();}); socket.destroy(); return; } - const s = this.sessions.get(wm[1]); + const sessionId = wm[1]; + const s = this.sessions.get(sessionId); if (!s) { socket.destroy(); return; @@ -1243,6 +1289,7 @@ v.addEventListener('ended',()=>{i++;play()});play();}); // merely hidden — a read-only viewer genuinely cannot drive the browser. const interactable = new URL(url, "http://x").searchParams.get("interactable") !== "0"; this.wss.handleUpgrade(req, socket, head, (ws) => { + const removePeer = this.addPortholePeer(sessionId, ws); const cur = s.media.frames.get(); if (cur) ws.send(cur); @@ -1251,13 +1298,21 @@ v.addEventListener('ended',()=>{i++;play()});play();}); // socket would otherwise accrete JPEGs forever). const unsub = s.media.frames.subscribe((f) => { if (ws.readyState === ws.OPEN && ws.bufferedAmount < 1_000_000) ws.send(f); }); - ws.on("message", (d) => { if (!interactable) - return; s.lastActivityMs = Date.now(); try { - s.media.onInput(JSON.parse(d.toString())); - } - catch { /* ignore */ } }); - ws.on("close", unsub); - ws.on("error", unsub); + ws.on("message", (d) => { + if (!interactable) + return; + try { + const event = JSON.parse(d.toString()); + if (s.media.onInput(event)) { + s.lastActivityMs = Date.now(); + this.broadcastInputFeedback(sessionId, event); + } + } + catch { /* ignore */ } + }); + const cleanup = () => { unsub(); removePeer(); }; + ws.on("close", cleanup); + ws.on("error", cleanup); }); }); return new Promise((resolve, reject) => { diff --git a/node_modules/lucarne/dist/porthole.d.ts b/node_modules/lucarne/dist/porthole.d.ts index 8f9516d..015c9cc 100644 --- a/node_modules/lucarne/dist/porthole.d.ts +++ b/node_modules/lucarne/dist/porthole.d.ts @@ -18,6 +18,9 @@ export interface InputEvent { clickCount?: number; dx?: number; dy?: number; + /** Dimensions of the rendered source frame used to derive x/y. */ + frameHeight?: number; + frameWidth?: number; key?: string; code?: string; repeat?: boolean; diff --git a/node_modules/lucarne/dist/porthole.js b/node_modules/lucarne/dist/porthole.js index b852e00..846c0fe 100644 --- a/node_modules/lucarne/dist/porthole.js +++ b/node_modules/lucarne/dist/porthole.js @@ -7,40 +7,60 @@ */ export function portholeHtml(viewport) { return ` - +.controls #bar{display:flex}.controls #c{height:calc(100vh - 36px)} +#feedback-toggle{align-items:center;background:rgba(17,24,39,.82);border:1px solid rgba(255,255,255,.28);border-radius:7px;color:#fff;cursor:pointer;display:none;height:30px;justify-content:center;padding:0;position:fixed;right:9px;top:9px;width:30px;z-index:3} +.feedback-available #feedback-toggle{display:flex}.controls #feedback-toggle{top:45px}#feedback-toggle svg{height:16px;width:16px} +#feedback-pointer{border:2px solid #fff;border-radius:50%;box-shadow:0 1px 5px rgba(0,0,0,.8);height:18px;opacity:0;pointer-events:none;position:fixed;transform:translate(-50%,-50%);transition:opacity .16s;width:18px;z-index:2} +#feedback-pointer.visible{opacity:1}#feedback-pointer.dragging{border-color:#67e8f9;box-shadow:0 0 0 4px rgba(103,232,249,.2)} +#feedback-click{border:3px solid #67e8f9;border-radius:50%;height:18px;opacity:0;pointer-events:none;position:fixed;transform:translate(-50%,-50%) scale(.45);width:18px;z-index:2} +#feedback-click.pulse{animation:feedback-click .45s ease-out}@keyframes feedback-click{0%{opacity:1;transform:translate(-50%,-50%) scale(.45)}100%{opacity:0;transform:translate(-50%,-50%) scale(2.25)}} +#feedback-key{background:rgba(17,24,39,.9);border:1px solid rgba(255,255,255,.3);border-radius:7px;bottom:10px;color:#fff;font:600 13px system-ui;left:50%;max-width:calc(100vw - 24px);opacity:0;overflow:hidden;padding:6px 10px;pointer-events:none;position:fixed;text-overflow:ellipsis;transform:translate(-50%,8px);transition:opacity .15s,transform .15s;white-space:nowrap;z-index:2} +#feedback-key.visible{opacity:1;transform:translate(-50%,0)}
-