Compare commits

...
2 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 3a3077529b feat(kiosk): companion remote drives app iframes via trusted CDP input
Demo images / Build & push demo images (push) Successful in 3m11s
Companion tap/scroll/type now works INSIDE cross-origin app iframes and
kiosk tabs. The web relay synthesizes untrusted DOM events in the top
document, which can never cross an origin boundary — so apps served
through the appgate were dead to the remote. The kiosk Chromium now
exposes a loopback-only CDP port (default origin check intact, no
--remote-allow-origins) and a backend bridge (api/handler/cdp.rs)
dispatches validated companion input as Input.dispatchKeyEvent /
dispatchMouseEvent / mouseWheel — trusted events that hit-test through
any frame, move real focus, and insert text like a physical device.

- Session keeper self-heals across kiosk Chromium restarts; inert on
  nodes without a kiosk unit (falls back to the existing relay path).
- The kiosk relay subscriber self-tags (?kiosk=1) and the backend mutes
  its key/click/scroll messages while the bridge is live, so input never
  applies twice; cursor moves still flow for the on-screen cursor.
- While companion input is active the native OS pointer is hidden
  (cursor:none, auto-restores 30s after the last event) so the dead
  physical-mouse cursor doesn't sit next to the virtual one.
- docs/tv-input-iframe-apps.md scope note updated: gamepad keys stay on
  uinput; CDP is for companion pointer/typing only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 05:28:53 -04:00
archipelagoandClaude Fable 5 876ecc4bdf fix(ui): replace native confirm() dialogs with the global in-app modal
window.confirm blocks the JS event loop, which froze companion remote
input while open — the remote user could raise the mesh "Clear" prompt
(or reboot / backup-delete / uninstall confirms) and then never dismiss
it, because the synthetic events that would dismiss it queue behind the
dialog itself.

New promise-based appConfirm() (useAppConfirm.ts) + one AppConfirmModal
mounted globally in App.vue, built on BaseModal (Teleport-to-body,
full-viewport backdrop, glass card — the canonical modal contract). All
six native confirm() call sites migrated: mesh clear-all, mesh message
delete, dashboard reboot, backup delete, backup USB copy, app uninstall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 05:28:53 -04:00
15 changed files with 641 additions and 14 deletions
+421
View File
@@ -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]
}
+9
View File
@@ -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;
}
+10 -3
View File
@@ -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
+5
View File
@@ -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'
+25 -2
View File
@@ -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>
+44
View File
@@ -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
}
+9
View File
@@ -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;
+7 -1
View File
@@ -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
}
+4 -2
View File
@@ -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(() => {})
}
})
}
}
}
+15 -2
View File
@@ -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) {
+12 -2
View File
@@ -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 {