Compare commits
2
Commits
458444d700
...
3a3077529b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3077529b | ||
|
|
876ecc4bdf |
@@ -0,0 +1,421 @@
|
||||
//! CDP input bridge — forwards companion remote input into the local kiosk
|
||||
//! Chromium as *trusted*, browser-level input via the DevTools protocol.
|
||||
//!
|
||||
//! Why this exists: the web relay path (`remote-relay.ts`) synthesizes DOM
|
||||
//! events in the top document, and synthetic events can never cross into a
|
||||
//! cross-origin iframe — so on the kiosk, companion taps/keys/scrolls died at
|
||||
//! the border of every containerized app's frame. CDP `Input.dispatch*`
|
||||
//! events enter the browser's real input pipeline: they hit-test through any
|
||||
//! frame, move focus, and insert text exactly like a physical device, which
|
||||
//! is the only correct way to drive app iframes (tracked in the unified task
|
||||
//! tracker; supersedes the earlier "no CDP" note in
|
||||
//! `docs/tv-input-iframe-apps.md`, which was about gamepad *keys* only).
|
||||
//!
|
||||
//! The kiosk launcher opens Chromium with `--remote-debugging-port=9222`
|
||||
//! bound to loopback. This keeper task discovers the page target, holds one
|
||||
//! WebSocket to it, and reconnects whenever the kiosk restarts (the launcher
|
||||
//! supervises Chromium in a loop, so the debugger URL changes under us).
|
||||
//! When the bridge is not connected (non-kiosk installs, kiosk booting),
|
||||
//! `is_active()` is false and callers fall back to the web relay unchanged.
|
||||
//!
|
||||
//! Security: the CDP port is loopback-only and Chromium's default origin
|
||||
//! check stands (we deliberately do NOT pass `--remote-allow-origins`, so
|
||||
//! browser pages can't open the debug socket; our raw client sends no
|
||||
//! Origin header and is accepted).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
const CDP_HTTP: &str = "http://127.0.0.1:9222";
|
||||
/// Marker whose presence means this node drives a local kiosk display.
|
||||
const KIOSK_UNIT: &str = "/etc/systemd/system/archipelago-kiosk.service";
|
||||
/// One relay scroll step ≈ this many CSS pixels (matches remote-relay.ts).
|
||||
const SCROLL_STEP_PX: f64 = 100.0;
|
||||
|
||||
/// Cloneable handle the WS handlers use to feed validated relay JSON into
|
||||
/// the keeper task.
|
||||
#[derive(Clone)]
|
||||
pub struct CdpBridge {
|
||||
tx: mpsc::Sender<String>,
|
||||
connected: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl CdpBridge {
|
||||
/// Spawn the session keeper and return the shared handle.
|
||||
pub fn spawn() -> Self {
|
||||
let (tx, rx) = mpsc::channel::<String>(256);
|
||||
let connected = Arc::new(AtomicBool::new(false));
|
||||
tokio::spawn(run_keeper(rx, connected.clone()));
|
||||
Self { tx, connected }
|
||||
}
|
||||
|
||||
/// True only while a live CDP session to the kiosk Chromium exists.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.connected.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Queue a validated relay input message (the exact JSON that goes to the
|
||||
/// broadcast channel) for CDP dispatch. Best-effort: if the keeper is
|
||||
/// behind or gone the message is dropped — input is transient by nature.
|
||||
pub fn send(&self, relay_json: &str) {
|
||||
let _ = self.tx.try_send(relay_json.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual cursor state, server-side. The web relay keeps this in the kiosk
|
||||
/// page (`cursorX`/`cursorY`); CDP needs its own copy because trusted mouse
|
||||
/// events carry absolute viewport coordinates.
|
||||
struct Cursor {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
h: f64,
|
||||
}
|
||||
|
||||
async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>) {
|
||||
loop {
|
||||
// Cheap gate: no kiosk unit on this node → nothing to drive. Keep
|
||||
// draining queued input so the channel never backs up.
|
||||
if tokio::fs::metadata(KIOSK_UNIT).await.is_err() {
|
||||
drain_for(&mut rx, Duration::from_secs(60)).await;
|
||||
continue;
|
||||
}
|
||||
let Some(ws_url) = discover_page_target().await else {
|
||||
// Kiosk configured but Chromium not up (or CDP flag not rolled
|
||||
// out yet) — retry gently.
|
||||
drain_for(&mut rx, Duration::from_secs(15)).await;
|
||||
continue;
|
||||
};
|
||||
match drive_session(&ws_url, &mut rx, &connected).await {
|
||||
Ok(()) => info!("CDP kiosk input session ended cleanly"),
|
||||
Err(e) => debug!(error = %e, "CDP kiosk input session dropped — will re-discover"),
|
||||
}
|
||||
connected.store(false, Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard queued input for `d` — used while no kiosk session exists so the
|
||||
/// bounded channel can't fill with stale events.
|
||||
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
|
||||
let _ = tokio::time::timeout(d, async {
|
||||
while rx.recv().await.is_some() {}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
|
||||
/// localhost (the kiosk app) over e.g. devtools/extension targets.
|
||||
async fn discover_page_target() -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()
|
||||
.ok()?;
|
||||
let list: Vec<Value> = client
|
||||
.get(format!("{CDP_HTTP}/json/list"))
|
||||
.send()
|
||||
.await
|
||||
.ok()?
|
||||
.json()
|
||||
.await
|
||||
.ok()?;
|
||||
let pages: Vec<&Value> = list
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(Value::as_str) == Some("page"))
|
||||
.collect();
|
||||
let preferred = pages
|
||||
.iter()
|
||||
.find(|t| {
|
||||
t.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|u| u.contains("localhost") || u.contains("127.0.0.1"))
|
||||
})
|
||||
.or_else(|| pages.first());
|
||||
preferred?
|
||||
.get("webSocketDebuggerUrl")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
async fn drive_session(
|
||||
ws_url: &str,
|
||||
rx: &mut mpsc::Receiver<String>,
|
||||
connected: &Arc<AtomicBool>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (ws, _) = tokio_tungstenite::connect_async(ws_url).await?;
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
let mut next_id: u64 = 0;
|
||||
let mut id = move || {
|
||||
next_id += 1;
|
||||
next_id
|
||||
};
|
||||
|
||||
// Viewport size for cursor clamping. Best-effort: fall back to 1080p if
|
||||
// the metrics call fails — clamping is a nicety, not a correctness need.
|
||||
sink.send(Message::Text(
|
||||
json!({"id": id(), "method": "Page.getLayoutMetrics"}).to_string(),
|
||||
))
|
||||
.await?;
|
||||
let (mut vw, mut vh) = (1920.0_f64, 1080.0_f64);
|
||||
if let Ok(Some(Ok(Message::Text(txt)))) =
|
||||
tokio::time::timeout(Duration::from_secs(3), stream.next()).await
|
||||
{
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&txt) {
|
||||
if let Some(vp) = v.pointer("/result/cssLayoutViewport") {
|
||||
vw = vp.get("clientWidth").and_then(Value::as_f64).unwrap_or(vw);
|
||||
vh = vp.get("clientHeight").and_then(Value::as_f64).unwrap_or(vh);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cursor = Cursor {
|
||||
x: vw / 2.0,
|
||||
y: vh / 2.0,
|
||||
w: vw,
|
||||
h: vh,
|
||||
};
|
||||
|
||||
connected.store(true, Ordering::Relaxed);
|
||||
info!(viewport = %format!("{vw}x{vh}"), "CDP kiosk input bridge connected");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = rx.recv() => {
|
||||
let Some(cmd) = cmd else { return Ok(()) };
|
||||
for frame in translate(&cmd, &mut cursor, &mut id) {
|
||||
sink.send(Message::Text(frame.to_string())).await?;
|
||||
}
|
||||
}
|
||||
msg = stream.next() => {
|
||||
match msg {
|
||||
// Responses/events — nothing to correlate, but a read
|
||||
// error or close means Chromium restarted.
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => return Err(e.into()),
|
||||
None => anyhow::bail!("CDP socket closed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate one validated relay input message into CDP command frames.
|
||||
fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Vec<Value> {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(raw) else {
|
||||
return vec![];
|
||||
};
|
||||
match msg.get("t").and_then(Value::as_str) {
|
||||
Some("m") => {
|
||||
let dx = msg.get("x").and_then(Value::as_i64).unwrap_or(0) as f64;
|
||||
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64;
|
||||
cursor.x = (cursor.x + dx).clamp(0.0, cursor.w - 1.0);
|
||||
cursor.y = (cursor.y + dy).clamp(0.0, cursor.h - 1.0);
|
||||
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
|
||||
}
|
||||
Some("c") => {
|
||||
let b = msg.get("b").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
|
||||
let (button, buttons) = match b {
|
||||
2 => ("middle", 4),
|
||||
3 => ("right", 2),
|
||||
_ => ("left", 1),
|
||||
};
|
||||
vec![
|
||||
// Hover first so the press lands on current hit-test state.
|
||||
mouse_event(id(), "mouseMoved", cursor, "none", 0, 1),
|
||||
mouse_event(id(), "mousePressed", cursor, button, buttons, 1),
|
||||
mouse_event(id(), "mouseReleased", cursor, button, 0, 1),
|
||||
]
|
||||
}
|
||||
Some("s") => {
|
||||
let dy = msg.get("y").and_then(Value::as_i64).unwrap_or(0) as f64 * SCROLL_STEP_PX;
|
||||
vec![json!({
|
||||
"id": id(),
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
"params": {
|
||||
"type": "mouseWheel",
|
||||
"x": cursor.x, "y": cursor.y,
|
||||
"deltaX": 0.0, "deltaY": dy,
|
||||
"pointerType": "mouse",
|
||||
}
|
||||
})]
|
||||
}
|
||||
Some("k") => {
|
||||
let Some(k) = msg.get("k").and_then(Value::as_str) else {
|
||||
return vec![];
|
||||
};
|
||||
key_events(k, id)
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_event(id: u64, kind: &str, cursor: &Cursor, button: &str, buttons: u32, clicks: u32) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"method": "Input.dispatchMouseEvent",
|
||||
"params": {
|
||||
"type": kind,
|
||||
"x": cursor.x, "y": cursor.y,
|
||||
"button": button,
|
||||
"buttons": buttons,
|
||||
"clickCount": if kind == "mousePressed" || kind == "mouseReleased" { clicks } else { 0 },
|
||||
"pointerType": "mouse",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// xdotool named key → (DOM key, DOM code, Windows virtual-key code).
|
||||
fn named_key(k: &str) -> Option<(&'static str, &'static str, i32)> {
|
||||
Some(match k {
|
||||
"Return" => ("Enter", "Enter", 13),
|
||||
"BackSpace" => ("Backspace", "Backspace", 8),
|
||||
"Escape" => ("Escape", "Escape", 27),
|
||||
"Tab" => ("Tab", "Tab", 9),
|
||||
"Delete" => ("Delete", "Delete", 46),
|
||||
"Up" => ("ArrowUp", "ArrowUp", 38),
|
||||
"Down" => ("ArrowDown", "ArrowDown", 40),
|
||||
"Left" => ("ArrowLeft", "ArrowLeft", 37),
|
||||
"Right" => ("ArrowRight", "ArrowRight", 39),
|
||||
"Home" => ("Home", "Home", 36),
|
||||
"End" => ("End", "End", 35),
|
||||
"Prior" => ("PageUp", "PageUp", 33),
|
||||
"Next" => ("PageDown", "PageDown", 34),
|
||||
"F1" => ("F1", "F1", 112),
|
||||
"F2" => ("F2", "F2", 113),
|
||||
"F3" => ("F3", "F3", 114),
|
||||
"F4" => ("F4", "F4", 115),
|
||||
"F5" => ("F5", "F5", 116),
|
||||
"F6" => ("F6", "F6", 117),
|
||||
"F7" => ("F7", "F7", 118),
|
||||
"F8" => ("F8", "F8", 119),
|
||||
"F9" => ("F9", "F9", 120),
|
||||
"F10" => ("F10", "F10", 121),
|
||||
"F11" => ("F11", "F11", 122),
|
||||
"F12" => ("F12", "F12", 123),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// xdotool symbol name → printable char (the relay whitelist speaks xdotool).
|
||||
fn symbol_char(k: &str) -> Option<char> {
|
||||
Some(match k {
|
||||
"space" => ' ',
|
||||
"exclam" => '!',
|
||||
"at" => '@',
|
||||
"numbersign" => '#',
|
||||
"dollar" => '$',
|
||||
"percent" => '%',
|
||||
"asciicircum" => '^',
|
||||
"ampersand" => '&',
|
||||
"asterisk" => '*',
|
||||
"parenleft" => '(',
|
||||
"parenright" => ')',
|
||||
"underscore" => '_',
|
||||
"plus" => '+',
|
||||
"braceleft" => '{',
|
||||
"braceright" => '}',
|
||||
"bar" => '|',
|
||||
"colon" => ':',
|
||||
"quotedbl" => '"',
|
||||
"less" => '<',
|
||||
"greater" => '>',
|
||||
"question" => '?',
|
||||
"asciitilde" => '~',
|
||||
"minus" => '-',
|
||||
"equal" => '=',
|
||||
"bracketleft" => '[',
|
||||
"bracketright" => ']',
|
||||
"backslash" => '\\',
|
||||
"semicolon" => ';',
|
||||
"apostrophe" => '\'',
|
||||
"grave" => '`',
|
||||
"comma" => ',',
|
||||
"period" => '.',
|
||||
"slash" => '/',
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the CDP frame pair (keyDown, keyUp) for one relay key name,
|
||||
/// including `modifier+base` combos. A keyDown that carries `text` both
|
||||
/// fires real keydown/keypress AND inserts the character — exactly how a
|
||||
/// physical keystroke behaves, so games see the key and fields get the text.
|
||||
fn key_events(k: &str, id: &mut impl FnMut() -> u64) -> Vec<Value> {
|
||||
let (modifiers, base) = match k.split_once('+') {
|
||||
Some((m, b)) => (
|
||||
match m {
|
||||
"alt" => 1,
|
||||
"ctrl" => 2,
|
||||
"super" => 4,
|
||||
"shift" => 8,
|
||||
_ => 0,
|
||||
},
|
||||
b,
|
||||
),
|
||||
None => (0, k),
|
||||
};
|
||||
|
||||
let (key, code, vk, text): (String, Option<&str>, i32, Option<String>) =
|
||||
if let Some((key, code, vk)) = named_key(base) {
|
||||
// Enter carries "\r" like a real keyboard so single-line inputs
|
||||
// submit and textareas newline.
|
||||
let text = (key == "Enter").then(|| "\r".to_string());
|
||||
(key.to_string(), Some(code), vk, text)
|
||||
} else {
|
||||
let ch = if base.chars().count() == 1 {
|
||||
base.chars().next()
|
||||
} else {
|
||||
symbol_char(base)
|
||||
};
|
||||
let Some(mut ch) = ch else {
|
||||
return vec![];
|
||||
};
|
||||
if modifiers == 8 && ch.is_ascii_alphabetic() {
|
||||
ch = ch.to_ascii_uppercase();
|
||||
}
|
||||
let vk = ch.to_ascii_uppercase() as i32;
|
||||
// Ctrl/Alt/Super chords are shortcuts, not typing — no text.
|
||||
let text = (modifiers & !8 == 0).then(|| ch.to_string());
|
||||
(ch.to_string(), None, vk, text)
|
||||
};
|
||||
|
||||
let mut down = json!({
|
||||
"id": id(),
|
||||
"method": "Input.dispatchKeyEvent",
|
||||
"params": {
|
||||
"type": "keyDown",
|
||||
"key": key,
|
||||
"modifiers": modifiers,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
}
|
||||
});
|
||||
if let Some(code) = code {
|
||||
down["params"]["code"] = json!(code);
|
||||
}
|
||||
if let Some(t) = &text {
|
||||
down["params"]["text"] = json!(t);
|
||||
down["params"]["unmodifiedText"] = json!(t);
|
||||
}
|
||||
let mut up = json!({
|
||||
"id": id(),
|
||||
"method": "Input.dispatchKeyEvent",
|
||||
"params": {
|
||||
"type": "keyUp",
|
||||
"key": key,
|
||||
"modifiers": modifiers,
|
||||
"windowsVirtualKeyCode": vk,
|
||||
"nativeVirtualKeyCode": vk,
|
||||
}
|
||||
});
|
||||
if let Some(code) = code {
|
||||
up["params"]["code"] = json!(code);
|
||||
}
|
||||
vec![down, up]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod blob;
|
||||
mod cdp;
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod model_proxy;
|
||||
@@ -51,6 +52,10 @@ pub struct ApiHandler {
|
||||
/// to the phone's default browser. Lets "open in external browser" apps —
|
||||
/// which the kiosk can't usefully open itself — launch on the controller.
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
/// Bridge that dispatches companion input into the local kiosk Chromium
|
||||
/// as trusted CDP events (reaches inside cross-origin app iframes).
|
||||
/// Inert (never connects) on nodes without a kiosk.
|
||||
cdp_bridge: cdp::CdpBridge,
|
||||
/// Content-addressed blob store for attachments shared over mesh/federation.
|
||||
blob_store: Arc<BlobStore>,
|
||||
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
|
||||
@@ -79,6 +84,7 @@ impl ApiHandler {
|
||||
);
|
||||
let (input_relay_tx, _) = broadcast::channel(64);
|
||||
let (external_open_tx, _) = broadcast::channel(16);
|
||||
let cdp_bridge = cdp::CdpBridge::spawn();
|
||||
|
||||
// Derive a blob-store capability key from the node's Ed25519 signing
|
||||
// key. SHA-256 domain-separated so rotating the identity rotates
|
||||
@@ -109,6 +115,7 @@ impl ApiHandler {
|
||||
session_store,
|
||||
input_relay_tx,
|
||||
external_open_tx,
|
||||
cdp_bridge,
|
||||
blob_store,
|
||||
self_pubkey_hex,
|
||||
})
|
||||
@@ -402,6 +409,7 @@ impl ApiHandler {
|
||||
req,
|
||||
self.input_relay_tx.clone(),
|
||||
self.external_open_tx.subscribe(),
|
||||
self.cdp_bridge.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -416,6 +424,7 @@ impl ApiHandler {
|
||||
req,
|
||||
self.input_relay_tx.subscribe(),
|
||||
self.external_open_tx.clone(),
|
||||
self.cdp_bridge.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -212,6 +212,7 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
relay_tx: broadcast::Sender<String>,
|
||||
mut external_open_rx: broadcast::Receiver<String>,
|
||||
cdp_bridge: super::cdp::CdpBridge,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// Extract optional player ID from query string: /ws/remote-input?p=1
|
||||
let player_id: Option<u8> = req
|
||||
@@ -317,9 +318,20 @@ impl ApiHandler {
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
let _ = relay_tx.send(relay_text);
|
||||
let validation = handle_input(&text).await;
|
||||
let _ = relay_tx.send(relay_text.clone());
|
||||
// Trusted-input path: while the kiosk CDP
|
||||
// bridge is live, also dispatch validated
|
||||
// input into the kiosk Chromium so it
|
||||
// lands inside cross-origin app iframes
|
||||
// (the web relay above can't cross that
|
||||
// boundary; the kiosk subscriber mutes its
|
||||
// own DOM synthesis — remote_relay.rs).
|
||||
if matches!(validation, Ok(None)) && cdp_bridge.is_active() {
|
||||
cdp_bridge.send(&relay_text);
|
||||
}
|
||||
|
||||
match handle_input(&text).await {
|
||||
match validation {
|
||||
Ok(Some(reply)) => {
|
||||
let _ = tx.send(Message::Text(reply)).await;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,16 @@ impl ApiHandler {
|
||||
req: Request<hyper::Body>,
|
||||
mut relay_rx: broadcast::Receiver<String>,
|
||||
external_open_tx: broadcast::Sender<String>,
|
||||
cdp_bridge: super::cdp::CdpBridge,
|
||||
) -> Result<Response<hyper::Body>> {
|
||||
// The kiosk browser self-identifies with ?kiosk=1 so we can suppress
|
||||
// its DOM-synthesis path while the CDP bridge delivers trusted input
|
||||
// (otherwise every key/click/scroll would apply twice). A remote
|
||||
// browser claiming kiosk=1 only mutes its own input — harmless.
|
||||
let is_kiosk = req
|
||||
.uri()
|
||||
.query()
|
||||
.is_some_and(|q| q.split('&').any(|s| s == "kiosk=1"));
|
||||
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
|
||||
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
|
||||
|
||||
@@ -60,6 +69,19 @@ impl ApiHandler {
|
||||
msg = relay_rx.recv() => {
|
||||
match msg {
|
||||
Ok(text) => {
|
||||
// Kiosk + live CDP bridge: keys/clicks/
|
||||
// scrolls arrive as trusted browser input
|
||||
// via CDP; forward only cursor moves (the
|
||||
// on-screen cursor is drawn by the page)
|
||||
// so nothing applies twice.
|
||||
if is_kiosk && cdp_bridge.is_active() {
|
||||
let tag = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.get("t").and_then(|t| t.as_str().map(str::to_string)));
|
||||
if !matches!(tag.as_deref(), Some("m") | Some("o") | Some("p")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if tx.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -66,9 +66,16 @@ packaging docs; never required for an app to be usable.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- ❌ CDP (`--remote-debugging-port` + Input.dispatchKeyEvent): works but adds
|
||||
a privileged debug port to the kiosk and a daemon↔browser coupling; the
|
||||
uinput route gets the same result at kernel level with no attack surface.
|
||||
- ⚠️ **Scope update 2026-08-16:** the "no CDP" rule below still holds for
|
||||
*gamepad keys* (uinput remains their path). But companion **pointer input**
|
||||
(tap at coordinates, scroll, focus-then-type inside cross-origin app
|
||||
iframes) has no uinput equivalent that survives hit-testing across frames,
|
||||
so the kiosk now runs `--remote-debugging-port=9222` (loopback-only, default
|
||||
origin check intact) feeding the backend CDP bridge in
|
||||
`core/archipelago/src/api/handler/cdp.rs`.
|
||||
- ❌ CDP for gamepad keys (`Input.dispatchKeyEvent` for the NES pad): the
|
||||
uinput route gets the same result at kernel level with no daemon↔browser
|
||||
coupling; keep gamepads on uinput.
|
||||
- ❌ Per-app nav scripts injected into iframes: cross-origin makes this
|
||||
impossible for most apps, and it's exactly the per-app hack the requirement
|
||||
rules out.
|
||||
|
||||
@@ -291,7 +291,14 @@ while true; do
|
||||
--disable-metrics-reporting \
|
||||
--disable-domain-reliability \
|
||||
--js-flags="--max-old-space-size=256" \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/var/lib/archipelago/chromium-kiosk
|
||||
# --remote-debugging-port binds to 127.0.0.1 only. It feeds the
|
||||
# backend's CDP input bridge (api/handler/cdp.rs), which dispatches
|
||||
# companion-app remote input as TRUSTED browser events — the only way
|
||||
# taps/keys/scrolls reach inside cross-origin app iframes. Chromium's
|
||||
# default origin check stays on (no --remote-allow-origins), so web
|
||||
# pages cannot open the debug socket.
|
||||
sleep 3
|
||||
done
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@
|
||||
<MeshDeviceSetupModal />
|
||||
<ExternalExplorerModal />
|
||||
|
||||
<!-- In-app confirm() replacement — native browser dialogs block the JS
|
||||
event loop and freeze companion remote input (see useAppConfirm) -->
|
||||
<AppConfirmModal />
|
||||
|
||||
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
|
||||
<LndSeedBackupPrompt />
|
||||
|
||||
@@ -102,6 +106,7 @@ import HelpGuideModal from './components/HelpGuideModal.vue'
|
||||
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
|
||||
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
|
||||
import ExternalExplorerModal from './components/ExternalExplorerModal.vue'
|
||||
import AppConfirmModal from './components/AppConfirmModal.vue'
|
||||
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
|
||||
import LightningRequiredModal from './components/LightningRequiredModal.vue'
|
||||
import { useMeshStore } from './stores/mesh'
|
||||
|
||||
@@ -53,15 +53,30 @@ let cursorX = typeof window !== 'undefined' ? window.innerWidth / 2 : 0
|
||||
let cursorY = typeof window !== 'undefined' ? window.innerHeight / 2 : 0
|
||||
let cursorHideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/**
|
||||
* While the companion is actively driving input, suppress the other visible
|
||||
* pointer: the native OS cursor sitting wherever the physical mouse left it
|
||||
* reads as a second, dead cursor next to the companion's virtual one
|
||||
* (user request 2026-08-16). Restores automatically when the companion goes
|
||||
* quiet (same 30s window as `companionActive`).
|
||||
*/
|
||||
function setNativeCursorSuppressed(on: boolean) {
|
||||
document.documentElement.classList.toggle('companion-input-active', on)
|
||||
}
|
||||
|
||||
function markCompanionActive() {
|
||||
companionActive.value = true
|
||||
companionInputActive.value = true
|
||||
setNativeCursorSuppressed(true)
|
||||
|
||||
if (inputFlickerTimeout) clearTimeout(inputFlickerTimeout)
|
||||
inputFlickerTimeout = setTimeout(() => { companionInputActive.value = false }, 200)
|
||||
|
||||
if (companionTimeout) clearTimeout(companionTimeout)
|
||||
companionTimeout = setTimeout(() => { companionActive.value = false }, 30_000)
|
||||
companionTimeout = setTimeout(() => {
|
||||
companionActive.value = false
|
||||
setNativeCursorSuppressed(false)
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
function createCursor(): HTMLDivElement {
|
||||
@@ -333,7 +348,14 @@ function doConnect() {
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${protocol}//${window.location.host}/ws/remote-relay`
|
||||
// The kiosk self-identifies so the backend can mute this subscriber's
|
||||
// key/click/scroll messages while the CDP bridge delivers them as trusted
|
||||
// browser input (cursor moves still arrive so the on-screen cursor draws).
|
||||
// Same three-way predicate as App.vue's isKiosk.
|
||||
const isKiosk = localStorage.getItem('kiosk') === 'true'
|
||||
|| new URLSearchParams(window.location.search).has('kiosk')
|
||||
|| window.location.pathname === '/kiosk'
|
||||
const url = `${protocol}//${window.location.host}/ws/remote-relay${isKiosk ? '?kiosk=1' : ''}`
|
||||
|
||||
ws = new WebSocket(url)
|
||||
|
||||
@@ -405,6 +427,7 @@ export function stopRemoteRelay() {
|
||||
if (cursorHideTimer) { clearTimeout(cursorHideTimer); cursorHideTimer = null }
|
||||
if (ws) { ws.onclose = null; ws.close(); ws = null }
|
||||
if (cursorEl) { cursorEl.remove(); cursorEl = null }
|
||||
setNativeCursorSuppressed(false)
|
||||
relayConnected.value = false
|
||||
companionActive.value = false
|
||||
companionInputActive.value = false
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="!!state"
|
||||
:title="state?.title || 'Are you sure?'"
|
||||
z-index="z-[4000]"
|
||||
@close="resolveConfirm(false)"
|
||||
>
|
||||
<p class="text-white/80 text-sm whitespace-pre-line">{{ state?.message }}</p>
|
||||
<template #footer>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium py-2.5 transition-colors"
|
||||
@click="resolveConfirm(false)"
|
||||
>
|
||||
{{ state?.cancelLabel || t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 glass-button rounded-lg text-sm font-semibold py-2.5"
|
||||
:class="state?.danger ? 'text-orange-400 border-orange-400/30' : ''"
|
||||
@click="resolveConfirm(true)"
|
||||
>
|
||||
{{ state?.confirmLabel || 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { confirmState as state, resolveConfirm } from '@/composables/useAppConfirm'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Promise-based in-app replacement for window.confirm(). Native browser
|
||||
* modals block the JS event loop while open, which freezes the companion
|
||||
* remote-input relay (and every other async path) — a remote user could
|
||||
* raise a confirm they can never dismiss, since the synthetic events that
|
||||
* would dismiss it are queued behind the very dialog they need to close.
|
||||
*
|
||||
* State is module-level so the single <AppConfirmModal /> mounted in
|
||||
* App.vue serves every caller:
|
||||
*
|
||||
* const ok = await appConfirm('Clear everything? This cannot be undone.')
|
||||
* const ok = await appConfirm({ message: '…', confirmLabel: 'Delete', danger: true })
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface AppConfirmOptions {
|
||||
title?: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** Styles the confirm button as destructive (orange/warning). */
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
interface ActiveConfirm extends AppConfirmOptions {
|
||||
_resolve: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const confirmState = ref<ActiveConfirm | null>(null)
|
||||
|
||||
export function appConfirm(options: AppConfirmOptions | string): Promise<boolean> {
|
||||
const opts = typeof options === 'string' ? { message: options } : options
|
||||
// A second confirm while one is open cancels the first — the browser
|
||||
// primitive this replaces could only ever show one at a time.
|
||||
confirmState.value?._resolve(false)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
confirmState.value = { ...opts, _resolve: resolve }
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveConfirm(result: boolean) {
|
||||
confirmState.value?._resolve(result)
|
||||
confirmState.value = null
|
||||
}
|
||||
@@ -1730,6 +1730,15 @@ html.kiosk-mode::before {
|
||||
will-change: auto !important;
|
||||
}
|
||||
|
||||
/* While the companion app is actively driving input, hide the native OS
|
||||
pointer — it sits dead wherever the physical mouse left it and reads as
|
||||
a second cursor next to the companion's virtual one. remote-relay.ts
|
||||
toggles this class and clears it 30s after the last companion event. */
|
||||
html.companion-input-active,
|
||||
html.companion-input-active * {
|
||||
cursor: none !important;
|
||||
}
|
||||
|
||||
/* Dashboard: full viewport width, no letterboxing, no body scroll */
|
||||
body.dashboard-active {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -125,6 +125,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import { appConfirm } from '@/composables/useAppConfirm'
|
||||
import { type ContainerStatus as ContainerStatusData } from '@/api/container-client'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
@@ -325,7 +326,12 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(t('apps.uninstallConfirm', { name: appName.value }))) {
|
||||
const ok = await appConfirm({
|
||||
message: t('apps.uninstallConfirm', { name: appName.value }),
|
||||
confirmLabel: t('apps.uninstallTitle'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { useAppLauncherStore } from '../stores/appLauncher'
|
||||
import { appConfirm } from '@/composables/useAppConfirm'
|
||||
import AppSession from '@/views/AppSession.vue'
|
||||
import { useLoginTransitionStore } from '../stores/loginTransition'
|
||||
import { playDashboardLoadOomph } from '@/composables/useLoginSounds'
|
||||
@@ -376,9 +377,10 @@ function handleKioskShortcuts(e: KeyboardEvent) {
|
||||
router.push('/dashboard')
|
||||
} else if (e.key === 'Q' || e.key === 'q') {
|
||||
e.preventDefault()
|
||||
if (confirm('Reboot the server?')) {
|
||||
appConfirm({ title: 'Reboot', message: 'Reboot the server?', confirmLabel: 'Reboot', danger: true }).then((ok) => {
|
||||
if (!ok) return
|
||||
fetch('/rpc/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'system.reboot' }) }).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import MeshDevicePanel from '@/views/mesh/MeshDevicePanel.vue'
|
||||
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
||||
import HopVizModal from '@/views/mesh/HopVizModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { appConfirm } from '@/composables/useAppConfirm'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
|
||||
import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
|
||||
@@ -281,7 +282,13 @@ async function refreshOutboxCount() {
|
||||
}
|
||||
|
||||
async function clearAllMesh() {
|
||||
if (!window.confirm('Clear all mesh peers, messages, and chat history? This cannot be undone.')) return
|
||||
const ok = await appConfirm({
|
||||
title: 'Clear mesh data',
|
||||
message: 'Clear all mesh peers, messages, and chat history? This cannot be undone.',
|
||||
confirmLabel: 'Clear everything',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await rpcClient.call({ method: 'mesh.clear-all' })
|
||||
await mesh.refreshAll()
|
||||
@@ -1419,7 +1426,13 @@ function clearPendingEdit() {
|
||||
}
|
||||
async function deleteOwnMessage(msg: MeshMessage) {
|
||||
if (msg.direction !== 'sent' || msg.sender_seq == null || !activeChatPeer.value) return
|
||||
if (!window.confirm('Delete this message? Peers already received it — this only marks it as deleted.')) return
|
||||
const ok = await appConfirm({
|
||||
title: 'Delete message',
|
||||
message: 'Delete this message? Peers already received it — this only marks it as deleted.',
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await mesh.deleteMessage(activeChatPeer.value.contact_id, msg.sender_seq)
|
||||
} catch (e) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { appConfirm } from '@/composables/useAppConfirm'
|
||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -114,7 +115,12 @@ async function restoreBackup() {
|
||||
}
|
||||
|
||||
async function deleteBackup(id: string) {
|
||||
if (!confirm(t('settings.deleteBackupConfirm'))) return
|
||||
const ok = await appConfirm({
|
||||
message: t('settings.deleteBackupConfirm'),
|
||||
confirmLabel: t('common.delete'),
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
deletingBackupId.value = id
|
||||
try {
|
||||
await rpcClient.call({ method: 'backup.delete', params: { id } })
|
||||
@@ -229,7 +235,11 @@ async function backupToUsb(backupId: string) {
|
||||
return
|
||||
}
|
||||
const label = target.label || target.device
|
||||
if (!confirm(`Copy backup to USB drive "${label}" at ${target.mount_point}?`)) return
|
||||
const ok = await appConfirm({
|
||||
message: `Copy backup to USB drive "${label}" at ${target.mount_point}?`,
|
||||
confirmLabel: 'Copy',
|
||||
})
|
||||
if (!ok) return
|
||||
await rpcClient.call({ method: 'backup.to-usb', params: { id: backupId, mount_point: target.mount_point } })
|
||||
showBackupStatus(t('settings.backupCopiedToUsb', { path: target.mount_point }), 'success')
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user