cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

Wire Titan derivation into Tauri app — no address copy/paste

dami etoile committed 85b3879
- iroh_host_start: takes PIN, derives SecretKey from Titan via hmac-secret
  with resident keys, uses it as endpoint identity
- iroh_client_connect: takes PIN (not addr_json), derives host's node ID
  from same Titan, connects via N0 relay — zero address exchange
- titan_derive_identity: standalone command to preview derived node ID
- Frontend: removed address input field, added PIN input + 'Connect via
  Titan' button. Same PIN field used for both host and client
- Titan derivation runs in spawn_blocking to avoid blocking the async
  runtime during FIDO2 operations (PIN entry, key tap)
- sha2 dep added for salt hashing
commit
85b3879726e6e73af5321a7544f1a945b3d413a1
parent
e6deae6cb6e68bcbce4bfdefafd8442f20c3ce78
author
dami etoile <dami.etoile@gmail.com>
5 files changed, +202 -123
Msrc-tauri/Cargo.lock1+
Msrc-tauri/Cargo.toml1+
Msrc-tauri/src/commands.rs196++++++++++++++++++++++++------
Msrc-tauri/src/main.rs1+
Msrc/ui/index.html126++++++-------------
M src-tauri/Cargo.lock +1 -0
M src-tauri/Cargo.toml +1 -0
M src-tauri/src/commands.rs +158 -38
@@ -3,19 +3,28 @@
33 //! Commands:44 //! - fido_device_info: enumerate FIDO2 devices and get info55 //! - fido_pin_retries: check PIN retry count6-//! - iroh_host_start: start Iroh endpoint, stream screen frames + accept input6+//! - titan_derive_identity: derive Iroh node ID from Titan (no network)7+//! - iroh_host_start: derive identity from Titan, start hosting (frames + input)78 //! - iroh_host_status: check if host endpoint is running8-//! - iroh_client_connect: connect to host, receive frames, emit events9+//! - iroh_client_connect: derive host ID from Titan, connect via relay (no address)910 //! - iroh_client_send_input: send input event to host for injection1011 1112 use anyhow::Context as _;1213 use base64::Engine;13-use ctap_hid_fido2::{Cfg, FidoKeyHidFactory};14+use ctap_hid_fido2::fidokey::{15+ GetAssertionArgsBuilder, MakeCredentialArgsBuilder,16+ get_assertion::Extension as Gext,17+ get_assertion::get_assertion_params::Assertion,18+ make_credential::Extension as Mext,19+};20+use ctap_hid_fido2::public_key_credential_user_entity::PublicKeyCredentialUserEntity;21+use ctap_hid_fido2::{verifier, Cfg, FidoKeyHidFactory};1422 use enigo::{Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings};1523 use iroh::endpoint::{presets, Connection};1624 use iroh::protocol::{ProtocolHandler, Router};17-use iroh::{Endpoint, EndpointAddr, SecretKey};25+use iroh::{Endpoint, SecretKey};1826 use serde::{Deserialize, Serialize};27+use sha2::{Digest, Sha256};1928 use std::io::Cursor;2029 use std::sync::{Arc, Mutex};2130 use std::time::{Duration, Instant};
@@ -24,6 +33,93 @@ use tokio::sync::Mutex as TokioMutex;
2433 2534 const FRAME_ALPN: &[u8] = b"keyhome/frame-stream/0";2635 const INPUT_ALPN: &[u8] = b"keyhome/input-stream/0";36+const RPID: &str = "keyhome";37+const SALT_MESSAGE: &str = "keyhome-iroh-identity-v1";38+const N0_RELAY: &str = "https://usw1-1.relay.n0.iroh.link./";39+40+// ─── Titan HMAC-Secret Derivation ────────────────────────────────────────────41+42+fn derive_secret_from_titan(pin: &str) -> anyhow::Result<[u8; 32]> {43+ let cfg = Cfg::init();44+ let device = FidoKeyHidFactory::create(&cfg)45+ .context("Failed to open FIDO2 device")?;46+47+ let salt: [u8; 32] = {48+ let mut hasher = Sha256::new();49+ hasher.update(SALT_MESSAGE.as_bytes());50+ let result = hasher.finalize();51+ let mut s = [0u8; 32];52+ s.copy_from_slice(&result);53+ s54+ };55+56+ // Try get_assertion without credential ID (uses resident key)57+ let challenge = verifier::create_challenge();58+ let get_args = GetAssertionArgsBuilder::new(RPID, &challenge)59+ .pin(pin)60+ .extensions(&[Gext::HmacSecret(Some(salt))])61+ .build();62+63+ match device.get_assertion_with_args(&get_args) {64+ Ok(assertions) => {65+ return extract_hmac_secret(&assertions);66+ }67+ Err(_) => {}68+ }69+70+ // No resident key — create one71+ let user_entity = PublicKeyCredentialUserEntity::new(72+ Some(b"keyhome-user"),73+ Some("keyhome"),74+ Some("Keyhome"),75+ );76+77+ let challenge = verifier::create_challenge();78+ let make_args = MakeCredentialArgsBuilder::new(RPID, &challenge)79+ .pin(pin)80+ .user_entity(&user_entity)81+ .resident_key()82+ .extensions(&[Mext::HmacSecret(Some(true))])83+ .build();84+85+ let attestation = device.make_credential_with_args(&make_args)86+ .context("make_credential failed")?;87+88+ let verify_result = verifier::verify_attestation(RPID, &challenge, &attestation);89+ if !verify_result.is_success {90+ anyhow::bail!("Attestation verification failed");91+ }92+ let credential_id = verify_result.credential_id;93+94+ // Get assertion with the new credential95+ let challenge2 = verifier::create_challenge();96+ let get_args = GetAssertionArgsBuilder::new(RPID, &challenge2)97+ .pin(pin)98+ .credential_id(&credential_id)99+ .extensions(&[Gext::HmacSecret(Some(salt))])100+ .build();101+102+ let assertions = device.get_assertion_with_args(&get_args)103+ .context("get_assertion failed")?;104+105+ extract_hmac_secret(&assertions)106+}107+108+fn extract_hmac_secret(assertions: &[Assertion]) -> anyhow::Result<[u8; 32]> {109+ for ext in &assertions[0].extensions {110+ if let Gext::HmacSecret(Some(output)) = ext {111+ let mut secret = [0u8; 32];112+ secret.copy_from_slice(&output[..]);113+ return Ok(secret);114+ }115+ }116+ anyhow::bail!("No hmac-secret in assertion response")117+}118+119+fn derive_iroh_secret_from_titan(pin: &str) -> anyhow::Result<SecretKey> {120+ let secret_bytes = derive_secret_from_titan(pin)?;121+ Ok(SecretKey::from_bytes(&secret_bytes))122+}27123 28124 // ─── Input Event Protocol ───────────────────────────────────────────────────29125
@@ -166,7 +262,7 @@ pub struct AppState {
166262 }167263 168264 pub struct HostState {169- pub addr_json: String,265+ pub node_id: String,170266 }171267 172268 impl Default for AppState {
@@ -296,18 +392,47 @@ pub fn fido_pin_retries() -> PinRetries {
296392 }297393 }298394 395+// ─── Titan Identity Derivation ───────────────────────────────────────────────396+397+#[derive(Serialize)]398+pub struct TitanIdentity {399+ pub node_id: String,400+ pub error: Option<String>,401+}402+403+#[tauri::command]404+pub fn titan_derive_identity(pin: String) -> TitanIdentity {405+ match derive_iroh_secret_from_titan(&pin) {406+ Ok(secret) => TitanIdentity {407+ node_id: secret.public().to_string(),408+ error: None,409+ },410+ Err(e) => TitanIdentity {411+ node_id: String::new(),412+ error: Some(format!("{:?}", e)),413+ },414+ }415+}416+299417 // ─── Iroh Host ───────────────────────────────────────────────────────────────300418 301419 #[derive(Serialize)]302420 pub struct HostStatus {303421 pub running: bool,304- pub addr_json: Option<String>,422+ pub node_id: Option<String>,305423 pub error: Option<String>,306424 }307425 308426 #[tauri::command]309-pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, String> {310- let secret = SecretKey::generate();427+pub async fn iroh_host_start(state: State<'_, AppState>, pin: String) -> Result<HostStatus, String> {428+ // Derive identity from Titan (blocking — runs in Tauri's async pool)429+ let secret = tokio::task::spawn_blocking(move || derive_iroh_secret_from_titan(&pin))430+ .await431+ .map_err(|e| format!("Titan derivation task failed: {}", e))?432+ .map_err(|e| format!("Titan derivation failed: {:?}", e))?;433+434+ let node_id = secret.public().to_string();435+311436 let endpoint = Endpoint::builder(presets::N0)312437 .secret_key(secret)313438 .bind()
@@ -316,13 +441,7 @@ pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, S
316441 317442 let _ = tokio::time::timeout(Duration::from_secs(5), endpoint.online()).await;318443 319- let addr = endpoint.addr();320- let addr_json =321- serde_json::to_string(&addr).map_err(|e| format!("Failed to serialize addr: {}", e))?;322-323- // Frame stream handler (existing)324444 let frame_handler = Arc::new(FrameStreamHandler);325- // Input handler (new)326445 let input_handler = Arc::new(InputStreamHandler);327446 328447 let router = Router::builder(endpoint.clone())
@@ -330,18 +449,17 @@ pub async fn iroh_host_start(state: State<'_, AppState>) -> Result<HostStatus, S
330449 .accept(INPUT_ALPN, input_handler)331450 .spawn();332451 333- // Keep endpoint and router alive by leaking — MVP tradeoff334452 std::mem::forget(endpoint);335453 std::mem::forget(router);336454 337455 let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;338456 *host = Some(HostState {339- addr_json: addr_json.clone(),457+ node_id: node_id.clone(),340458 });341459 342460 Ok(HostStatus {343461 running: true,344- addr_json: Some(addr_json),462+ node_id: Some(node_id),345463 error: None,346464 })347465 }
@@ -353,18 +471,18 @@ pub fn iroh_host_status(state: State<'_, AppState>) -> HostStatus {
353471 Some(h) => match &*h {354472 Some(hs) => HostStatus {355473 running: true,356- addr_json: Some(hs.addr_json.clone()),474+ node_id: Some(hs.node_id.clone()),357475 error: None,358476 },359477 None => HostStatus {360478 running: false,361- addr_json: None,479+ node_id: None,362480 error: None,363481 },364482 },365483 None => HostStatus {366484 running: false,367- addr_json: None,485+ node_id: None,368486 error: Some("State lock poisoned".into()),369487 },370488 }
@@ -387,14 +505,12 @@ impl ProtocolHandler for FrameStreamHandler {
387505 async fn stream_frames(conn: Connection) -> anyhow::Result<()> {388506 let (mut send, mut recv) = conn.accept_bi().await?;389507 390- // Wait for client start signal391508 let mut start_buf = [0u8; 1];392509 recv.read_exact(&mut start_buf).await?;393510 if start_buf[0] != 1 {394511 return Ok(());395512 }396513 397- // Capture screen398514 let monitors = xcap::Monitor::all().context("failed to enumerate monitors")?;399515 let monitor = monitors400516 .into_iter()
@@ -431,7 +547,6 @@ async fn stream_frames(conn: Connection) -> anyhow::Result<()> {
431547 frame_count, w, h, jpeg_size, fps432548 );433549 434- // Check for client disconnect435550 match tokio::time::timeout(Duration::from_millis(1), recv.read(&mut [0u8; 1])).await {436551 Ok(Ok(Some(_))) => {437552 eprintln!("[host] client disconnected");
@@ -464,15 +579,12 @@ async fn handle_input(conn: Connection) -> anyhow::Result<()> {
464579 let (mut send, mut recv) = conn.accept_bi().await?;465580 eprintln!("[host] input client connected: {}", conn.remote_id());466581 467- // Wait for start signal468582 let mut start_buf = [0u8; 1];469583 recv.read_exact(&mut start_buf).await?;470584 if start_buf[0] != 1 {471585 return Ok(());472586 }473587 474- // Enigo is not Send on macOS (holds NonNull<CGEventSource>).475- // Run it in a dedicated blocking thread, fed via a std channel.476588 let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();477589 std::thread::spawn(move || {478590 let mut enigo = match Enigo::new(&Settings::default()) {
@@ -511,7 +623,6 @@ async fn handle_input(conn: Connection) -> anyhow::Result<()> {
511623 512624 buf.extend_from_slice(&chunk[..n]);513625 514- // Process complete newline-delimited JSON events515626 while let Some(pos) = buf.iter().position(|&b| b == b'\n') {516627 let line: Vec<u8> = buf.drain(..=pos).collect();517628 let line_str = String::from_utf8_lossy(&line[..line.len() - 1]);
@@ -531,7 +642,6 @@ async fn handle_input(conn: Connection) -> anyhow::Result<()> {
531642 }532643 }533644 534- // Drop tx to signal the blocking thread to exit535645 drop(tx);536646 let _ = send.write_all(b"bye\n").await;537647 Ok(())
@@ -543,12 +653,13 @@ async fn handle_input(conn: Connection) -> anyhow::Result<()> {
543653 pub struct FramePayload {544654 pub width: u32,545655 pub height: u32,546- pub data: String, // base64 JPEG656+ pub data: String,547657 }548658 549659 #[derive(Serialize)]550660 pub struct ConnectResult {551661 pub connected: bool,662+ pub host_node_id: Option<String>,552663 pub error: Option<String>,553664 }554665
@@ -556,20 +667,30 @@ pub struct ConnectResult {
556667 pub async fn iroh_client_connect(557668 app: AppHandle,558669 state: State<'_, AppState>,559- addr_json: String,670+ pin: String,560671 ) -> Result<ConnectResult, String> {561- let addr: EndpointAddr = serde_json::from_str(&addr_json)562- .map_err(|e| format!("Invalid address: {}", e))?;672+ // Derive the HOST's node ID from the Titan (same key = same node ID)673+ let host_secret = tokio::task::spawn_blocking(move || derive_iroh_secret_from_titan(&pin))674+ .await675+ .map_err(|e| format!("Titan derivation task failed: {}", e))?676+ .map_err(|e| format!("Titan derivation failed: {:?}", e))?;677+678+ let host_node_id = host_secret.public();563679 564- let secret = SecretKey::generate();680+ // Client uses a random identity (can't connect to yourself)681+ let client_secret = SecretKey::generate();565682 let endpoint = Endpoint::builder(presets::N0)566- .secret_key(secret)683+ .secret_key(client_secret)567684 .bind()568685 .await569686 .map_err(|e| format!("Failed to bind endpoint: {}", e))?;570687 571688 let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;572689 690+ // Connect via relay using only the derived node ID — no address JSON691+ let addr = iroh::EndpointAddr::new(host_node_id)692+ .with_relay_url(N0_RELAY.parse().map_err(|e| format!("Invalid relay URL: {}", e))?);693+573694 // Connect frame stream574695 let frame_conn = endpoint575696 .connect(addr.clone(), FRAME_ALPN)
@@ -581,7 +702,6 @@ pub async fn iroh_client_connect(
581702 .await582703 .map_err(|e| format!("Failed to open frame stream: {}", e))?;583704 584- // Send start signal585705 frame_send586706 .write_all(&[1u8])587707 .await
@@ -598,7 +718,6 @@ pub async fn iroh_client_connect(
598718 .await599719 .map_err(|e| format!("Failed to open input stream: {}", e))?;600720 601- // Send start signal for input602721 input_send603722 .write_all(&[1u8])604723 .await
@@ -611,7 +730,7 @@ pub async fn iroh_client_connect(
611730 *input_send_guard = Some(tx);612731 }613732 614- // Spawn input forwarder: reads from channel, writes to Iroh stream733+ // Spawn input forwarder615734 let mut input_stream = input_send;616735 tokio::spawn(async move {617736 let mut rx = rx;
@@ -631,7 +750,7 @@ pub async fn iroh_client_connect(
631750 let _ = input_stream.finish();632751 });633752 634- // Spawn background task to read frames and emit events753+ // Spawn frame reader635754 tokio::spawn(async move {636755 let mut frame_count = 0u32;637756 let start = Instant::now();
@@ -678,6 +797,7 @@ pub async fn iroh_client_connect(
678797 679798 Ok(ConnectResult {680799 connected: true,800+ host_node_id: Some(host_node_id.to_string()),681801 error: None,682802 })683803 }
M src-tauri/src/main.rs +1 -0
M src/ui/index.html +41 -85
@@ -62,19 +62,7 @@
6262 background: rgba(88,166,255,0.1); border-color: rgba(88,166,255,0.3);6363 color: var(--accent);6464 }65- .btn-danger {66- background: rgba(248,81,73,0.1); border-color: rgba(248,81,73,0.3);67- color: var(--red);68- }6965 .btn-bar { display: flex; gap: 8px; margin-top: 12px; }70- .addr-box {71- margin-top: 12px; padding: 8px 12px;72- background: rgba(88,166,255,0.05); border: 1px solid rgba(88,166,255,0.2);73- border-radius: 6px; font-family: var(--mono); font-size: 11px;74- word-break: break-all; color: var(--accent); display: none;75- }76- .addr-box.visible { display: block; }77- .connect-section { margin-top: 16px; }7866 .input {7967 width: 100%; background: var(--bg); border: 1px solid var(--border);8068 color: var(--text); padding: 8px 12px; border-radius: 6px;
@@ -112,11 +100,6 @@
112100 border-radius: 4px; display: none;113101 }114102 .viewer-stats.visible { display: block; }115- .copy-btn {116- background: none; border: none; color: var(--text-dim); cursor: pointer;117- font-size: 12px; margin-left: 8px;118- }119- .copy-btn:hover { color: var(--accent); }120103 .control-bar {121104 display: flex; align-items: center; gap: 12px; margin-top: 12px;122105 }
@@ -138,11 +121,14 @@
138121 .control-hint {139122 color: var(--text-dim); font-size: 11px;140123 }124+ .titan-hint {125+ color: var(--text-dim); font-size: 11px; margin-top: 8px;126+ }141127 </style>142128 </head>143129 <body>144130 <h1>Keyhome</h1>145- <p class="subtitle">FIDO2-secured remote desktop via Iroh</p>131+ <p class="subtitle">FIDO2-secured remote desktop via Iroh — no address sharing, just tap your key</p>146132 147133 <div class="grid">148134 <!-- FIDO2 Device Card -->
@@ -177,35 +163,35 @@
177163 </div>178164 </div>179165 180- <!-- Iroh Host Card -->166+ <!-- Host Card -->181167 <div class="card">182- <h2>Iroh Host (Server)</h2>168+ <h2>Host (Server)</h2>183169 <div class="row">184170 <span class="label">Status</span>185171 <span id="host-status" class="status-badge status-pending">Not started</span>186172 </div>187173 <div class="row">188- <span class="label">Endpoint ID</span>174+ <span class="label">Node ID</span>189175 <span id="host-id" class="value">—</span>190176 </div>191177 <div class="btn-bar">192- <button class="btn btn-primary" onclick="startHost()">Start host</button>178+ <button class="btn btn-primary" onclick="startHost()">Start hosting</button>193179 </div>194- <div id="host-addr-box" class="addr-box"></div>195- <div class="btn-bar" id="copy-bar" style="display:none;">196- <button class="btn" onclick="copyAddr()">Copy address</button>180+ <div class="titan-hint">181+ Tap Titan when it blinks. Identity derived from key — no address to share.197182 </div>198183 </div>199184 200- <!-- Iroh Client Card -->185+ <!-- Client Card -->201186 <div class="card" style="grid-column: 1 / -1;">202187 <h2>Connect to Host (Client)</h2>203188 <p style="color: var(--text-dim); font-size: 12px; margin-bottom: 8px;">204- Paste the host's address JSON below to connect and view the remote screen.189+ Enter your Titan PIN and tap the key. The host's identity is derived from190+ the same key — no address needed.205191 </p>206- <input class="input" id="connect-addr" placeholder='{"id":"...","addrs":[...]}' />207- <div class="btn-bar">208- <button class="btn btn-primary" onclick="connectHost()">Connect</button>192+ <div class="btn-bar" style="margin-top: 0;">193+ <input class="input" id="pin-input" type="password" placeholder="Titan PIN" style="flex: 1; margin-top: 0;" />194+ <button class="btn btn-primary" onclick="connectHost()">Connect via Titan</button>209195 </div>210196 <div id="connect-error" class="error-msg"></div>211197 </div>
@@ -216,8 +202,8 @@
216202 <div class="viewer-wrap">217203 <canvas id="frame-canvas"></canvas>218204 <div id="viewer-placeholder" class="viewer-placeholder">219- No stream connected. Start a host on one machine, copy the address,220- and connect from another.205+ No stream connected. Start hosting on one machine, then connect from another206+ with the same Titan key.221207 </div>222208 </div>223209 <div class="control-bar">
@@ -231,11 +217,9 @@
231217 </div>232218 233219 <script>234- let hostAddrJson = '';235220 let controlMode = false;236221 let connected = false;237222 238- // Access Tauri API lazily — avoids TDZ errors if __TAURI__ isn't ready yet239223 const invoke = (...args) => window.__TAURI__.core.invoke(...args);240224 const listen = (...args) => window.__TAURI__.event.listen(...args);241225
@@ -272,21 +256,22 @@
272256 273257 // ─── Host ──────────────────────────────────────────────────────────274258 async function startHost() {259+ const pin = document.getElementById('pin-input').value.trim();260+ if (!pin) {261+ alert('Enter Titan PIN first');262+ return;263+ }264+275265 const status = document.getElementById('host-status');276266 status.className = 'status-badge status-pending';277- status.innerHTML = '<span class="spinner"></span> Starting...';267+ status.innerHTML = '<span class="spinner"></span> Tap Titan...';278268 try {279- const result = await invoke('iroh_host_start');269+ const result = await invoke('iroh_host_start', { pin });280270 if (result.running) {281271 status.className = 'status-badge status-ok';282- status.textContent = 'Running';283- hostAddrJson = result.addr_json;284- const addrBox = document.getElementById('host-addr-box');285- addrBox.textContent = result.addr_json;286- addrBox.classList.add('visible');287- document.getElementById('copy-bar').style.display = 'flex';288- const addr = JSON.parse(result.addr_json);289- document.getElementById('host-id').textContent = addr.id.substring(0, 16) + '...';272+ status.textContent = 'Hosting';273+ document.getElementById('host-id').textContent =274+ result.node_id.substring(0, 16) + '...';290275 } else {291276 status.className = 'status-badge status-err';292277 status.textContent = 'Failed';
@@ -297,37 +282,28 @@
297282 }298283 }299284 300- function copyAddr() {301- navigator.clipboard.writeText(hostAddrJson).then(() => {302- const btn = document.querySelector('#copy-bar .btn');303- const orig = btn.textContent;304- btn.textContent = 'Copied!';305- setTimeout(() => { btn.textContent = orig; }, 1500);306- });307- }308-309285 // ─── Client ────────────────────────────────────────────────────────310286 async function connectHost() {311- const addr = document.getElementById('connect-addr').value.trim();287+ const pin = document.getElementById('pin-input').value.trim();312288 const errBox = document.getElementById('connect-error');313289 errBox.classList.remove('visible');314290 315- if (!addr) {316- errBox.textContent = 'Please paste a host address';291+ if (!pin) {292+ errBox.textContent = 'Enter Titan PIN';317293 errBox.classList.add('visible');318294 return;319295 }320296 321297 errBox.style.color = 'var(--yellow)';322- errBox.textContent = 'Connecting...';298+ errBox.textContent = 'Tap Titan when it blinks...';323299 errBox.classList.add('visible');324300 325301 try {326- const result = await invoke('iroh_client_connect', { addrJson: addr });302+ const result = await invoke('iroh_client_connect', { pin });327303 if (result.connected) {328304 connected = true;329305 errBox.style.color = 'var(--green)';330- errBox.textContent = 'Connected! Streaming frames...';306+ errBox.textContent = 'Connected to host ' + result.host_node_id.substring(0, 16) + '...';331307 errBox.classList.add('visible');332308 document.getElementById('frame-canvas').style.display = 'block';333309 document.getElementById('viewer-placeholder').style.display = 'none';
@@ -404,7 +380,7 @@
404380 if (controlMode) {405381 toggle.classList.add('active');406382 label.textContent = 'Controlling';407- hint.textContent = 'Mouse and keyboard are being sent. Click to toggle off.';383+ hint.textContent = 'Mouse and keyboard sent to host. Click to toggle off.';408384 canvas.classList.add('control-active');409385 } else {410386 toggle.classList.remove('active');
@@ -418,7 +394,6 @@
418394 }419395 }420396 421- // Scale canvas click coordinates to remote frame coordinates422397 function scaleCoords(clientX, clientY) {423398 const rect = canvas.getBoundingClientRect();424399 const scaleX = frameWidth / rect.width;
@@ -429,29 +404,14 @@
429404 };430405 }431406 432- // Map JS key names to enigo key names433407 function mapKey(e) {434408 const key = e.key;435- // Special keys436409 const map = {437- 'ArrowUp': 'Up',438- 'ArrowDown': 'Down',439- 'ArrowLeft': 'Left',440- 'ArrowRight': 'Right',441- ' ': 'Space',442- 'Delete': 'Delete',443- 'Backspace': 'Backspace',444- 'Enter': 'Enter',445- 'Tab': 'Tab',446- 'Escape': 'Escape',447- 'Shift': 'Shift',448- 'Control': 'Control',449- 'Alt': 'Alt',450- 'Meta': 'Meta',451- 'Home': 'Home',452- 'End': 'End',453- 'PageUp': 'PageUp',454- 'PageDown': 'PageDown',410+ 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',411+ ' ': 'Space', 'Delete': 'Delete', 'Backspace': 'Backspace',412+ 'Enter': 'Enter', 'Tab': 'Tab', 'Escape': 'Escape',413+ 'Shift': 'Shift', 'Control': 'Control', 'Alt': 'Alt', 'Meta': 'Meta',414+ 'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',455415 };456416 return map[key] || (key.length === 1 ? key : null);457417 }
@@ -460,12 +420,10 @@
460420 try {461421 await invoke('iroh_client_send_input', { event });462422 } catch (e) {463- // Silently ignore — input errors shouldn't disrupt the stream464423 console.warn('input send failed:', e);465424 }466425 }467426 468- // Mouse events on canvas469427 canvas.addEventListener('mousemove', (e) => {470428 if (!controlMode) return;471429 const { x, y } = scaleCoords(e.clientX, e.clientY);
@@ -497,7 +455,6 @@
497455 sendInput({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });498456 }, { passive: false });499457 500- // Keyboard events — capture on window when control mode is active501458 window.addEventListener('keydown', (e) => {502459 if (!controlMode) return;503460 e.preventDefault();
@@ -512,7 +469,6 @@
512469 if (k) sendInput({ t: 'ku', k });513470 });514471 515- // Text input for printable characters516472 window.addEventListener('keypress', (e) => {517473 if (!controlMode) return;518474 if (e.key.length === 1) {