cat / goq
clone

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

refactor: split commands.rs into modules + fix input/AV1/config/FIDO/relay

Dami committed 2296fc0
- Split 1645-line commands.rs into commands/{state,auth,input,streaming,network}.rs
- Throttle client MouseMove to ~60fps; bound host enigo channel (sync_channel(64) + try_send)
- Fix AV1 keyframe detection: walk OBUs for OBU_SEQUENCE_HEADER instead of treating all frames as keyframes
- Persist encoder config to app data dir; restore on startup via setup hook
- Add 30s timeout on all FIDO spawn_blocking calls
- Drop hardcoded relay URL; let presets::N0 relay map handle geographic routing/fallback
- UI: dynamic PIN shortcut hints (underlined c/r/h shown only when PIN has content)
- UI: emit fido-done to transition overlay from "tap titan" to "connecting" after key tap
- UI: return to intro with error when connection fails from intro screen
commit
2296fc01edf217ad56591554b838d8aa20f2e83e
parent
9c6f4331f8c76505ef21ad6e4fcca8efc03e5f64
author
Dami <dami.etoile@gmail.com>
9 files changed, +1755 -1695
Dsrc-tauri/src/commands.rs1673------------------------------
Asrc-tauri/src/commands/auth.rs317++++++
Asrc-tauri/src/commands/input.rs223++++
Asrc-tauri/src/commands/mod.rs23+
Asrc-tauri/src/commands/network.rs364+++++++
Asrc-tauri/src/commands/state.rs81+
Asrc-tauri/src/commands/streaming.rs668++++++++++++
Msrc-tauri/src/main.rs51+-
Msrc/ui/index.html50+-
D src-tauri/src/commands.rs +0 -1673
@@ -1,1673 +0,0 @@
1-//! Tauri backend commands for Keyhome.2-//!3-//! Commands:4-//! - fido_device_info: enumerate FIDO2 devices and get info5-//! - fido_pin_retries: check PIN retry count6-//! - titan_derive_identity: derive Iroh node ID from Titan (no network)7-//! - iroh_host_start: derive identity from Titan, start hosting (frames + input)8-//! - iroh_host_status: check if host endpoint is running9-//! - iroh_client_connect: derive host ID from Titan, connect via relay (no address)10-//! - iroh_client_send_input: send input event to host for injection11-12-use anyhow::Context as _;13-use base64::Engine;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};22-use enigo::{Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings};23-use iroh::endpoint::{presets, Connection};24-use iroh::protocol::{ProtocolHandler, Router};25-use iroh::{Endpoint, SecretKey};26-use serde::{Deserialize, Serialize};27-use sha2::{Digest, Sha256};28-use openh264::encoder::Encoder;29-use openh264::formats::{RgbSliceU8, YUVBuffer, YUVSource};30-use openh264::nal_units;31-use std::io::Cursor;32-use std::sync::atomic::{AtomicU32, Ordering};33-use std::sync::{Arc, Mutex};34-use std::time::{Duration, Instant};35-use tauri::{AppHandle, Emitter, Manager, State};36-use tokio::sync::Mutex as TokioMutex;37-38-const FRAME_ALPN: &[u8] = b"keyhome/frame-stream/0";39-const INPUT_ALPN: &[u8] = b"keyhome/input-stream/0";40-const RPID: &str = "keyhome";41-const SALT_MESSAGE: &str = "keyhome-iroh-identity-v1";42-const N0_RELAY: &str = "https://usw1-1.relay.n0.iroh.link./";43-const KEYRING_SERVICE: &str = "keyhome";44-const KEYRING_ENTRY: &str = "host-identity";45-46-fn codec_to_byte(codec: &str) -> u8 {47- match codec {48- "h265" => 1,49- "av1" => 2,50- _ => 0,51- }52-}53-54-fn byte_to_codec(b: u8) -> &'static str {55- match b {56- 1 => "h265",57- 2 => "av1",58- _ => "h264",59- }60-}61-62-// ─── Keyring Persistence ─────────────────────────────────────────────────────63-64-fn store_identity_in_keyring(secret: &[u8; 32]) -> anyhow::Result<()> {65- let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)66- .context("Failed to create keyring entry")?;67- entry68- .set_secret(secret)69- .context("Failed to store identity in keyring")70-}71-72-fn load_identity_from_keyring() -> anyhow::Result<Option<[u8; 32]>> {73- let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)74- .context("Failed to create keyring entry")?;75- match entry.get_secret() {76- Ok(bytes) => {77- if bytes.len() == 32 {78- let mut arr = [0u8; 32];79- arr.copy_from_slice(&bytes);80- Ok(Some(arr))81- } else {82- anyhow::bail!("Keyring entry has wrong length: {}", bytes.len())83- }84- }85- Err(keyring::Error::NoEntry) => Ok(None),86- Err(e) => anyhow::bail!("Failed to read keyring: {:?}", e),87- }88-}89-90-fn clear_identity_from_keyring() -> anyhow::Result<()> {91- let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)92- .context("Failed to create keyring entry")?;93- match entry.delete_credential() {94- Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),95- Err(e) => anyhow::bail!("Failed to clear keyring: {:?}", e),96- }97-}98-99-// ─── Titan HMAC-Secret Derivation ────────────────────────────────────────────100-101-fn derive_secret_from_titan(pin: &str) -> anyhow::Result<[u8; 32]> {102- let cfg = Cfg::init();103- let device = FidoKeyHidFactory::create(&cfg)104- .context("Failed to open FIDO2 device")?;105-106- let salt: [u8; 32] = {107- let mut hasher = Sha256::new();108- hasher.update(SALT_MESSAGE.as_bytes());109- let result = hasher.finalize();110- let mut s = [0u8; 32];111- s.copy_from_slice(&result);112- s113- };114-115- // Try get_assertion without credential ID (uses resident key)116- let challenge = verifier::create_challenge();117- let get_args = GetAssertionArgsBuilder::new(RPID, &challenge)118- .pin(pin)119- .extensions(&[Gext::HmacSecret(Some(salt))])120- .build();121-122- match device.get_assertion_with_args(&get_args) {123- Ok(assertions) => {124- return extract_hmac_secret(&assertions);125- }126- Err(_) => {}127- }128-129- // No resident key — create one130- let user_entity = PublicKeyCredentialUserEntity::new(131- Some(b"keyhome-user"),132- Some("keyhome"),133- Some("Keyhome"),134- );135-136- let challenge = verifier::create_challenge();137- let make_args = MakeCredentialArgsBuilder::new(RPID, &challenge)138- .pin(pin)139- .user_entity(&user_entity)140- .resident_key()141- .extensions(&[Mext::HmacSecret(Some(true))])142- .build();143-144- let attestation = device.make_credential_with_args(&make_args)145- .context("make_credential failed")?;146-147- let verify_result = verifier::verify_attestation(RPID, &challenge, &attestation);148- if !verify_result.is_success {149- anyhow::bail!("Attestation verification failed");150- }151- let credential_id = verify_result.credential_id;152-153- // Get assertion with the new credential154- let challenge2 = verifier::create_challenge();155- let get_args = GetAssertionArgsBuilder::new(RPID, &challenge2)156- .pin(pin)157- .credential_id(&credential_id)158- .extensions(&[Gext::HmacSecret(Some(salt))])159- .build();160-161- let assertions = device.get_assertion_with_args(&get_args)162- .context("get_assertion failed")?;163-164- extract_hmac_secret(&assertions)165-}166-167-fn extract_hmac_secret(assertions: &[Assertion]) -> anyhow::Result<[u8; 32]> {168- for ext in &assertions[0].extensions {169- if let Gext::HmacSecret(Some(output)) = ext {170- let mut secret = [0u8; 32];171- secret.copy_from_slice(&output[..]);172- return Ok(secret);173- }174- }175- anyhow::bail!("No hmac-secret in assertion response")176-}177-178-fn derive_iroh_secret_from_titan(pin: &str) -> anyhow::Result<SecretKey> {179- let secret_bytes = derive_secret_from_titan(pin)?;180- Ok(SecretKey::from_bytes(&secret_bytes))181-}182-183-// ─── Input Event Protocol ───────────────────────────────────────────────────184-185-#[derive(Serialize, Deserialize, Debug, Clone)]186-#[serde(tag = "t")]187-pub enum InputEvent {188- #[serde(rename = "mm")]189- MouseMove { x: i32, y: i32 },190- #[serde(rename = "mc")]191- MouseClick { b: u8 },192- #[serde(rename = "md")]193- MouseDown { b: u8 },194- #[serde(rename = "mu")]195- MouseUp { b: u8 },196- #[serde(rename = "ms")]197- MouseScroll { dx: i32, dy: i32 },198- #[serde(rename = "kd")]199- KeyDown { k: String },200- #[serde(rename = "ku")]201- KeyUp { k: String },202- #[serde(rename = "kt")]203- KeyClick { k: String },204- #[serde(rename = "tx")]205- Text { s: String },206-}207-208-impl InputEvent {209- fn apply(&self, enigo: &mut Enigo) -> anyhow::Result<()> {210- match self {211- InputEvent::MouseMove { x, y } => {212- enigo213- .move_mouse(*x, *y, Coordinate::Abs)214- .map_err(|e| anyhow::anyhow!("mouse move: {:?}", e))?;215- }216- InputEvent::MouseClick { b } => {217- let btn = button_from_code(*b);218- enigo219- .button(btn, Direction::Click)220- .map_err(|e| anyhow::anyhow!("mouse click: {:?}", e))?;221- }222- InputEvent::MouseDown { b } => {223- let btn = button_from_code(*b);224- enigo225- .button(btn, Direction::Press)226- .map_err(|e| anyhow::anyhow!("mouse down: {:?}", e))?;227- }228- InputEvent::MouseUp { b } => {229- let btn = button_from_code(*b);230- enigo231- .button(btn, Direction::Release)232- .map_err(|e| anyhow::anyhow!("mouse up: {:?}", e))?;233- }234- InputEvent::MouseScroll { dx, dy } => {235- if *dy != 0 {236- enigo237- .scroll(*dy, enigo::Axis::Vertical)238- .map_err(|e| anyhow::anyhow!("scroll: {:?}", e))?;239- }240- if *dx != 0 {241- enigo242- .scroll(*dx, enigo::Axis::Horizontal)243- .map_err(|e| anyhow::anyhow!("scroll: {:?}", e))?;244- }245- }246- InputEvent::KeyDown { k } => {247- if let Some(key) = key_from_str(k) {248- enigo249- .key(key, Direction::Press)250- .map_err(|e| anyhow::anyhow!("key down: {:?}", e))?;251- }252- }253- InputEvent::KeyUp { k } => {254- if let Some(key) = key_from_str(k) {255- enigo256- .key(key, Direction::Release)257- .map_err(|e| anyhow::anyhow!("key up: {:?}", e))?;258- }259- }260- InputEvent::KeyClick { k } => {261- if let Some(key) = key_from_str(k) {262- enigo263- .key(key, Direction::Click)264- .map_err(|e| anyhow::anyhow!("key click: {:?}", e))?;265- }266- }267- InputEvent::Text { s } => {268- enigo269- .text(s)270- .map_err(|e| anyhow::anyhow!("text: {:?}", e))?;271- }272- }273- Ok(())274- }275-}276-277-fn button_from_code(b: u8) -> Button {278- match b {279- 2 => Button::Right,280- 3 => Button::Middle,281- _ => Button::Left,282- }283-}284-285-fn key_from_str(s: &str) -> Option<Key> {286- match s {287- "Enter" => Some(Key::Return),288- "Tab" => Some(Key::Tab),289- "Space" => Some(Key::Space),290- "Backspace" => Some(Key::Backspace),291- "Escape" => Some(Key::Escape),292- "Shift" => Some(Key::Shift),293- "Control" => Some(Key::Control),294- "Alt" => Some(Key::Alt),295- "Meta" => Some(Key::Meta),296- "Up" => Some(Key::UpArrow),297- "Down" => Some(Key::DownArrow),298- "Left" => Some(Key::LeftArrow),299- "Right" => Some(Key::RightArrow),300- "Home" => Some(Key::Home),301- "End" => Some(Key::End),302- "PageUp" => Some(Key::PageUp),303- "PageDown" => Some(Key::PageDown),304- "Delete" => Some(Key::Delete),305- _ => {306- let c = s.chars().next()?;307- if c.is_ascii() {308- Some(Key::Unicode(c))309- } else {310- None311- }312- }313- }314-}315-316-// ─── AppState ────────────────────────────────────────────────────────────────317-318-#[derive(Clone, Debug, Serialize, Deserialize)]319-pub struct EncoderConfig {320- pub codec: String, // "h264", "h265", "av1"321- pub backend: String, // "auto", "nvenc", "vaapi", "videotoolbox", "software"322- pub bitrate: String, // e.g. "8M"323- pub framerate: u32, // e.g. 30324- pub gop: u32, // keyframe interval325-}326-327-impl Default for EncoderConfig {328- fn default() -> Self {329- Self {330- codec: "h264".to_string(),331- backend: "auto".to_string(),332- bitrate: "8M".to_string(),333- framerate: 30,334- gop: 30,335- }336- }337-}338-339-pub struct AppState {340- pub host: Mutex<Option<HostState>>,341- pub host_connections: Arc<AtomicU32>,342- pub host_endpoint: TokioMutex<Option<Endpoint>>,343- pub input_send: TokioMutex<Option<tokio::sync::mpsc::UnboundedSender<InputEvent>>>,344- pub client_endpoint: TokioMutex<Option<Endpoint>>,345- pub webcodecs: std::sync::atomic::AtomicBool,346- pub daemon: bool,347- pub encoder_config: Mutex<EncoderConfig>,348-}349-350-pub struct HostState {351- pub node_id: String,352-}353-354-impl Default for AppState {355- fn default() -> Self {356- let daemon = std::env::args().any(|a| a == "--daemon");357- Self {358- host: Mutex::new(None),359- host_connections: Arc::new(AtomicU32::new(0)),360- host_endpoint: TokioMutex::new(None),361- input_send: TokioMutex::new(None),362- client_endpoint: TokioMutex::new(None),363- webcodecs: std::sync::atomic::AtomicBool::new(false),364- daemon,365- encoder_config: Mutex::new(EncoderConfig::default()),366- }367- }368-}369-370-// ─── FIDO2 ───────────────────────────────────────────────────────────────────371-372-#[derive(Serialize)]373-pub struct FidoDeviceInfo {374- pub found: bool,375- pub vid: u16,376- pub pid: u16,377- pub product: String,378- pub versions: Vec<String>,379- pub extensions: Vec<String>,380- pub options: Vec<(String, bool)>,381- pub max_msg_size: u32,382- pub pin_retries: u32,383- pub error: Option<String>,384-}385-386-impl Default for FidoDeviceInfo {387- fn default() -> Self {388- Self {389- found: false,390- vid: 0,391- pid: 0,392- product: String::new(),393- versions: vec![],394- extensions: vec![],395- options: vec![],396- max_msg_size: 0,397- pin_retries: 0,398- error: None,399- }400- }401-}402-403-#[tauri::command]404-pub fn fido_device_info() -> FidoDeviceInfo {405- let devices = ctap_hid_fido2::get_fidokey_devices();406- if devices.is_empty() {407- return FidoDeviceInfo {408- found: false,409- ..Default::default()410- };411- }412-413- let dev = &devices[0];414- let vid = dev.vid;415- let pid = dev.pid;416- let product = format!("{:?}", dev.info);417-418- let cfg = Cfg::init();419- match FidoKeyHidFactory::create(&cfg) {420- Ok(device) => {421- let info = match device.get_info() {422- Ok(i) => i,423- Err(e) => {424- return FidoDeviceInfo {425- found: true,426- vid,427- pid,428- product,429- error: Some(format!("get_info failed: {:?}", e)),430- ..Default::default()431- };432- }433- };434-435- let pin_retries = device.get_pin_retries().unwrap_or(0);436-437- FidoDeviceInfo {438- found: true,439- vid,440- pid,441- product,442- versions: info.versions.clone(),443- extensions: info.extensions.clone(),444- options: info.options.clone(),445- max_msg_size: info.max_msg_size as u32,446- pin_retries: pin_retries as u32,447- error: None,448- }449- }450- Err(e) => FidoDeviceInfo {451- found: true,452- vid,453- pid,454- product,455- error: Some(format!("Failed to open device: {:?}", e)),456- ..Default::default()457- },458- }459-}460-461-#[derive(Serialize)]462-pub struct PinRetries {463- pub retries: u32,464- pub error: Option<String>,465-}466-467-#[tauri::command]468-pub fn fido_pin_retries() -> PinRetries {469- let cfg = Cfg::init();470- match FidoKeyHidFactory::create(&cfg) {471- Ok(device) => match device.get_pin_retries() {472- Ok(n) => PinRetries {473- retries: n as u32,474- error: None,475- },476- Err(e) => PinRetries {477- retries: 0,478- error: Some(format!("{:?}", e)),479- },480- },481- Err(e) => PinRetries {482- retries: 0,483- error: Some(format!("Device not found: {:?}", e)),484- },485- }486-}487-488-// ─── Titan Identity Derivation ───────────────────────────────────────────────489-490-#[derive(Serialize)]491-pub struct TitanIdentity {492- pub node_id: String,493- pub error: Option<String>,494-}495-496-#[tauri::command]497-pub fn titan_derive_identity(pin: String) -> TitanIdentity {498- match derive_iroh_secret_from_titan(&pin) {499- Ok(secret) => TitanIdentity {500- node_id: secret.public().to_string(),501- error: None,502- },503- Err(e) => TitanIdentity {504- node_id: String::new(),505- error: Some(format!("{:?}", e)),506- },507- }508-}509-510-// ─── Daemon mode ─────────────────────────────────────────────────────────────511-512-#[tauri::command]513-pub fn is_daemon_mode(state: State<'_, AppState>) -> bool {514- state.daemon515-}516-517-#[tauri::command]518-pub fn set_webcodecs_available(state: State<'_, AppState>, available: bool) {519- state.webcodecs.store(available, Ordering::SeqCst);520-}521-522-#[tauri::command]523-pub fn is_webcodecs_available(state: State<'_, AppState>) -> bool {524- state.webcodecs.load(Ordering::SeqCst)525-}526-527-#[tauri::command]528-pub fn get_encoder_config(state: State<'_, AppState>) -> EncoderConfig {529- state.encoder_config.lock().unwrap().clone()530-}531-532-#[tauri::command]533-pub fn set_encoder_config(state: State<'_, AppState>, config: EncoderConfig) {534- *state.encoder_config.lock().unwrap() = config;535-}536-537-#[tauri::command]538-pub fn detect_available_encoders() -> Vec<String> {539- let encoders = std::process::Command::new("ffmpeg")540- .arg("-encoders")541- .output()542- .ok()543- .map(|o| String::from_utf8_lossy(&o.stdout).to_string())544- .unwrap_or_default();545-546- let all = [547- // H.264548- "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox", "libx264",549- // H.265550- "hevc_nvenc", "hevc_vaapi", "hevc_qsv", "hevc_amf", "hevc_videotoolbox", "libx265",551- // AV1552- "av1_nvenc", "av1_vaapi", "av1_qsv", "av1_amf", "av1_videotoolbox", "libsvtav1", "libaom-av1",553- ];554-555- all.iter()556- .filter(|name| encoders.contains(*name))557- .map(|s| s.to_string())558- .collect()559-}560-561-// ─── Host Registration (keyring) ─────────────────────────────────────────────562-563-#[derive(Serialize)]564-pub struct RegistrationStatus {565- pub registered: bool,566- pub node_id: Option<String>,567- pub error: Option<String>,568-}569-570-#[tauri::command]571-pub fn host_registration_status() -> RegistrationStatus {572- match load_identity_from_keyring() {573- Ok(Some(bytes)) => {574- let secret = SecretKey::from_bytes(&bytes);575- RegistrationStatus {576- registered: true,577- node_id: Some(secret.public().to_string()),578- error: None,579- }580- }581- Ok(None) => RegistrationStatus {582- registered: false,583- node_id: None,584- error: None,585- },586- Err(e) => RegistrationStatus {587- registered: false,588- node_id: None,589- error: Some(format!("{:?}", e)),590- },591- }592-}593-594-#[tauri::command]595-pub async fn titan_register_host(pin: String) -> Result<RegistrationStatus, String> {596- let secret = tokio::task::spawn_blocking(move || derive_secret_from_titan(&pin))597- .await598- .map_err(|e| format!("Titan derivation task failed: {}", e))?599- .map_err(|e| format!("Titan derivation failed: {:?}", e))?;600-601- let node_id = SecretKey::from_bytes(&secret).public().to_string();602-603- store_identity_in_keyring(&secret).map_err(|e| format!("Keyring store failed: {:?}", e))?;604-605- Ok(RegistrationStatus {606- registered: true,607- node_id: Some(node_id),608- error: None,609- })610-}611-612-#[tauri::command]613-pub fn host_unregister() -> Result<bool, String> {614- clear_identity_from_keyring().map_err(|e| format!("Keyring clear failed: {:?}", e))?;615- Ok(true)616-}617-618-// ─── Iroh Host ───────────────────────────────────────────────────────────────619-620-#[derive(Serialize)]621-pub struct HostStatus {622- pub running: bool,623- pub node_id: Option<String>,624- pub error: Option<String>,625-}626-627-#[tauri::command]628-pub async fn iroh_host_start(629- app: AppHandle,630- state: State<'_, AppState>,631-) -> Result<HostStatus, String> {632- // Read identity from keyring — no Titan needed633- let secret_bytes = load_identity_from_keyring()634- .map_err(|e| format!("Keyring read failed: {:?}", e))?635- .ok_or_else(|| "Not registered. Click 'Register' first.".to_string())?;636-637- let secret = SecretKey::from_bytes(&secret_bytes);638- let node_id = secret.public().to_string();639-640- let endpoint = Endpoint::builder(presets::N0)641- .secret_key(secret)642- .bind()643- .await644- .map_err(|e| format!("Failed to bind endpoint: {}", e))?;645-646- let _ = tokio::time::timeout(Duration::from_secs(5), endpoint.online()).await;647-648- let connections = state.host_connections.clone();649- let frame_handler = Arc::new(FrameStreamHandler {650- connections: connections.clone(),651- app: app.clone(),652- });653- let input_handler = Arc::new(InputStreamHandler {654- connections: connections.clone(),655- app: app.clone(),656- });657-658- let router = Router::builder(endpoint.clone())659- .accept(FRAME_ALPN, frame_handler)660- .accept(INPUT_ALPN, input_handler)661- .spawn();662-663- // Store endpoint and router for clean shutdown664- {665- let mut he = state.host_endpoint.lock().await;666- *he = Some(endpoint);667- }668- std::mem::forget(router);669-670- let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;671- *host = Some(HostState {672- node_id: node_id.clone(),673- });674-675- Ok(HostStatus {676- running: true,677- node_id: Some(node_id),678- error: None,679- })680-}681-682-#[tauri::command]683-pub async fn iroh_host_stop(state: State<'_, AppState>) -> Result<bool, String> {684- {685- let mut he = state.host_endpoint.lock().await;686- if let Some(endpoint) = he.take() {687- endpoint.close().await;688- }689- }690- {691- let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;692- *host = None;693- }694- Ok(true)695-}696-697-#[tauri::command]698-pub fn iroh_host_status(state: State<'_, AppState>) -> HostStatus {699- let host = state.host.lock().ok();700- match host {701- Some(h) => match &*h {702- Some(hs) => HostStatus {703- running: true,704- node_id: Some(hs.node_id.clone()),705- error: None,706- },707- None => HostStatus {708- running: false,709- node_id: None,710- error: None,711- },712- },713- None => HostStatus {714- running: false,715- node_id: None,716- error: Some("State lock poisoned".into()),717- },718- }719-}720-721-// ─── Frame Stream Handler (host side) ────────────────────────────────────────722-723-#[derive(Debug)]724-struct FrameStreamHandler {725- connections: Arc<AtomicU32>,726- app: AppHandle,727-}728-729-impl ProtocolHandler for FrameStreamHandler {730- async fn accept(&self, conn: Connection) -> Result<(), iroh::protocol::AcceptError> {731- let count = self.connections.fetch_add(1, Ordering::SeqCst) + 1;732- let _ = self.app.emit("host-connections", count);733- eprintln!("[host] client connected: {} (total: {})", conn.remote_id(), count);734- // Spawn stream_frames, race against connection close735- let conn_clone = conn.clone();736- let app_clone = self.app.clone();737- let mut stream_task = tokio::spawn(async move {738- stream_frames(conn_clone, &app_clone).await739- });740- // Wait for either stream to end or connection to close741- tokio::select! {742- result = &mut stream_task => {743- if let Ok(Err(e)) = result {744- eprintln!("[host] stream error: {}", e);745- }746- }747- _ = conn.closed() => {748- eprintln!("[host] connection closed by peer");749- stream_task.abort();750- }751- }752- let count = self.connections.fetch_sub(1, Ordering::SeqCst) - 1;753- let _ = self.app.emit("host-connections", count);754- eprintln!("[host] client disconnected (total: {})", count);755- Ok(())756- }757-}758-759-async fn stream_frames(conn: Connection, app: &AppHandle) -> anyhow::Result<()> {760- let (mut send, mut recv) = conn.accept_bi().await?;761-762- let mut start_buf = [0u8; 1];763- recv.read_exact(&mut start_buf).await?;764- if start_buf[0] != 1 {765- return Ok(());766- }767-768- // Detect screen dimensions (one-time xcap call, in spawn_blocking to keep future Send)769- let (w, h) = {770- tokio::task::spawn_blocking(|| -> anyhow::Result<(usize, usize)> {771- let monitors = xcap::Monitor::all().context("failed to enumerate monitors")?;772- let mon = monitors773- .into_iter()774- .next()775- .ok_or_else(|| anyhow::anyhow!("no monitors found"))?;776- let img = mon.capture_image()?;777- Ok((img.width() as usize, img.height() as usize))778- })779- .await??780- };781- eprintln!("[host] screen resolution: {}x{}", w, h);782-783- // Try ffmpeg subprocess first784- if ffmpeg_available() {785- let config = {786- let app_state = app.state::<AppState>();787- app_state.encoder_config.lock().unwrap().clone()788- };789- eprintln!(790- "[host] using ffmpeg: codec={} backend={}",791- config.codec, config.backend792- );793- match stream_frames_ffmpeg(&mut send, &mut recv, app, w, h, &config).await {794- Ok(()) => return Ok(()),795- Err(e) => {796- eprintln!("[host] ffmpeg failed: {}, falling back to xcap+openh264", e);797- }798- }799- }800-801- // Fallback: xcap + openh264802- eprintln!("[host] using xcap + openh264");803- stream_frames_xcap(&mut send, &mut recv, app).await804-}805-806-// ─── ffmpeg subprocess path ──────────────────────────────────────────────────807-808-fn ffmpeg_available() -> bool {809- std::process::Command::new("ffmpeg")810- .arg("-version")811- .stdout(std::process::Stdio::null())812- .stderr(std::process::Stdio::null())813- .status()814- .is_ok()815-}816-817-/// Resolve the ffmpeg encoder name based on codec + backend preference.818-fn resolve_encoder(codec: &str, backend: &str) -> String {819- let encoders = std::process::Command::new("ffmpeg")820- .arg("-encoders")821- .output()822- .ok()823- .map(|o| String::from_utf8_lossy(&o.stdout).to_string())824- .unwrap_or_default();825-826- let has = |name: &str| encoders.contains(name);827-828- // Backend preference order by platform829- let hw_backends: &[&str] = if cfg!(target_os = "macos") {830- &["videotoolbox"]831- } else if cfg!(target_os = "windows") {832- &["nvenc", "qsv", "amf"]833- } else {834- &["nvenc", "vaapi"]835- };836-837- let codec_prefix = match codec {838- "h265" => "hevc",839- "av1" => "av1",840- _ => "h264",841- };842-843- // If specific backend requested, try it844- if backend != "auto" {845- let name = if backend == "software" {846- match codec {847- "h265" => "libx265",848- "av1" => "libsvtav1",849- _ => "libx264",850- }851- } else {852- &format!("{}_{}", codec_prefix, backend)853- };854- if has(name) {855- return name.to_string();856- }857- }858-859- // Auto: try hardware backends in order860- for hw in hw_backends {861- let name = format!("{}_{}", codec_prefix, hw);862- if has(&name) {863- return name;864- }865- }866-867- // Software fallback868- match codec {869- "h265" => "libx265".to_string(),870- "av1" => {871- if has("libsvtav1") {872- "libsvtav1".to_string()873- } else {874- "libaom-av1".to_string()875- }876- }877- _ => "libx264".to_string(),878- }879-}880-881-/// Get the ffmpeg output format and BSF for a given codec.882-fn codec_format(codec: &str) -> (&'static str, &'static str) {883- match codec {884- "h265" => ("hevc", "hevc_metadata=aud=insert"),885- "av1" => ("av1", "av1_metadata=td=insert"),886- _ => ("h264", "h264_metadata=aud=insert"),887- }888-}889-890-/// Find the next frame delimiter in the NAL stream.891-/// H.264/H.265: AUD start code (NAL type 9 for H.264, type 35 for H.265)892-/// AV1: temporal delimiter OBU893-fn find_next_frame_delim(data: &[u8], from: usize, codec: &str) -> Option<usize> {894- let mut i = from;895- while i + 4 < data.len() {896- if data[i] == 0 && data[i + 1] == 0 {897- if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {898- // 4-byte start code899- if codec == "av1" {900- // AV1 temporal delimiter: OBU type 2 (0x10 in type field)901- if i + 4 < data.len() && (data[i + 4] & 0x38) == 0x10 {902- return Some(i);903- }904- } else if codec == "h265" {905- // HEVC NAL type is (byte >> 1) & 0x3f, AUD = 35906- if i + 4 < data.len() && ((data[i + 4] >> 1) & 0x3f) == 35 {907- return Some(i);908- }909- } else {910- // H.264 NAL type is byte & 0x1f, AUD = 9911- if i + 4 < data.len() && (data[i + 4] & 0x1f) == 9 {912- return Some(i);913- }914- }915- i += 4;916- continue;917- }918- if data[i + 2] == 1 {919- // 3-byte start code920- if codec == "av1" {921- if i + 3 < data.len() && (data[i + 3] & 0x38) == 0x10 {922- return Some(i);923- }924- } else if codec == "h265" {925- if i + 3 < data.len() && ((data[i + 3] >> 1) & 0x3f) == 35 {926- return Some(i);927- }928- } else {929- if i + 3 < data.len() && (data[i + 3] & 0x1f) == 9 {930- return Some(i);931- }932- }933- i += 3;934- continue;935- }936- }937- i += 1;938- }939- None940-}941-942-/// Check if a frame's NAL data contains a keyframe.943-fn frame_is_keyframe(data: &[u8], codec: &str) -> bool {944- match codec {945- "h265" => {946- // HEVC: IDR (type 19, 20) or VPS (32) or SPS (33)947- let mut i = 0;948- while i + 3 < data.len() {949- if data[i] == 0 && data[i + 1] == 0 {950- let (sc_len, nal_off) = if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {951- (4, 4)952- } else if data[i + 2] == 1 {953- (3, 3)954- } else {955- i += 1;956- continue;957- };958- if i + nal_off < data.len() {959- let nal_type = (data[i + nal_off] >> 1) & 0x3f;960- if nal_type == 19 || nal_type == 20 || nal_type == 32 || nal_type == 33 {961- return true;962- }963- }964- i += sc_len;965- } else {966- i += 1;967- }968- }969- false970- }971- "av1" => {972- // AV1: key frame has show_existing_frame=0 and frame_type=0 (KEY_FRAME)973- // Simplified: check for any OBU_FRAME with frame_type=0974- // For now, treat first frame as keyframe (AV1 temporal unit delimiters make this reliable)975- true976- }977- _ => {978- // H.264: IDR (type 5) or SPS (type 7)979- let mut i = 0;980- while i + 3 < data.len() {981- if data[i] == 0 && data[i + 1] == 0 {982- let (sc_len, nal_off) = if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {983- (4, 4)984- } else if data[i + 2] == 1 {985- (3, 3)986- } else {987- i += 1;988- continue;989- };990- if i + nal_off < data.len() {991- let nal_type = data[i + nal_off] & 0x1f;992- if nal_type == 5 || nal_type == 7 {993- return true;994- }995- }996- i += sc_len;997- } else {998- i += 1;999- }1000- }1001- false1002- }1003- }1004-}1005-1006-async fn stream_frames_ffmpeg(1007- send: &mut iroh::endpoint::SendStream,1008- recv: &mut iroh::endpoint::RecvStream,1009- app: &AppHandle,1010- width: usize,1011- height: usize,1012- config: &EncoderConfig,1013-) -> anyhow::Result<()> {1014- use std::process::Stdio;1015- use tokio::io::AsyncReadExt;1016- use tokio::process::Command as TokioCommand;1017-1018- let encoder = resolve_encoder(&config.codec, &config.backend);1019- let (fmt, bsf) = codec_format(&config.codec);1020- eprintln!(1021- "[host] ffmpeg: encoder={} format={} bsf={} bitrate={} fps={} gop={}",1022- encoder, fmt, bsf, config.bitrate, config.framerate, config.gop1023- );1024-1025- // Notify frontend of active codec for decoder configuration1026- let _ = app.emit("host-codec", &config.codec);1027-1028- let mut cmd = TokioCommand::new("ffmpeg");1029- cmd.stdin(Stdio::null())1030- .stdout(Stdio::piped())1031- .stderr(Stdio::piped())1032- .kill_on_drop(true);1033-1034- // Input: screen capture1035- if cfg!(target_os = "macos") {1036- cmd.arg("-f").arg("avfoundation")1037- .arg("-framerate").arg(config.framerate.to_string())1038- .arg("-capture_cursor").arg("1")1039- .arg("-i").arg("1:");1040- } else if cfg!(target_os = "windows") {1041- cmd.arg("-f").arg("gdigrab")1042- .arg("-framerate").arg(config.framerate.to_string())1043- .arg("-i").arg("desktop");1044- } else {1045- let display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());1046- cmd.arg("-f").arg("x11grab")1047- .arg("-framerate").arg(config.framerate.to_string())1048- .arg("-video_size").arg(format!("{}x{}", width, height))1049- .arg("-i").arg(&display);1050- }1051-1052- // Encoder-specific settings1053- match encoder.as_str() {1054- e if e.ends_with("_nvenc") => {1055- cmd.arg("-c:v").arg(&encoder)1056- .arg("-preset").arg("p1")1057- .arg("-tune").arg("ll")1058- .arg("-rc").arg("cbr")1059- .arg("-b:v").arg(&config.bitrate);1060- }1061- e if e.ends_with("_vaapi") => {1062- cmd.arg("-c:v").arg(&encoder)1063- .arg("-rc_mode").arg("CBR")1064- .arg("-b:v").arg(&config.bitrate);1065- }1066- e if e.ends_with("_videotoolbox") => {1067- cmd.arg("-c:v").arg(&encoder)1068- .arg("-realtime").arg("1")1069- .arg("-b:v").arg(&config.bitrate);1070- }1071- e if e.ends_with("_qsv") => {1072- cmd.arg("-c:v").arg(&encoder)1073- .arg("-preset").arg("veryfast")1074- .arg("-b:v").arg(&config.bitrate);1075- }1076- e if e.ends_with("_amf") => {1077- cmd.arg("-c:v").arg(&encoder)1078- .arg("-usage").arg("ultralowlatency")1079- .arg("-b:v").arg(&config.bitrate);1080- }1081- "libx264" => {1082- cmd.arg("-c:v").arg("libx264")1083- .arg("-preset").arg("ultrafast")1084- .arg("-tune").arg("zerolatency")1085- .arg("-b:v").arg(&config.bitrate);1086- }1087- "libx265" => {1088- cmd.arg("-c:v").arg("libx265")1089- .arg("-preset").arg("ultrafast")1090- .arg("-tune").arg("zerolatency")1091- .arg("-x265-params").arg("keyint=30:min-keyint=30")1092- .arg("-b:v").arg(&config.bitrate);1093- }1094- "libsvtav1" => {1095- cmd.arg("-c:v").arg("libsvtav1")1096- .arg("-preset").arg("8")1097- .arg("-crf").arg("35")1098- .arg("-g").arg(config.gop.to_string());1099- }1100- "libaom-av1" => {1101- cmd.arg("-c:v").arg("libaom-av1")1102- .arg("-cpu-used").arg("8")1103- .arg("-crf").arg("35")1104- .arg("-b:v").arg(&config.bitrate);1105- }1106- _ => {1107- return Err(anyhow::anyhow!("unsupported encoder: {}", encoder));1108- }1109- }1110-1111- // Common output settings (skip gop for libx265/libsvtav1 which set it above)1112- if encoder != "libx265" && encoder != "libsvtav1" {1113- cmd.arg("-g").arg(config.gop.to_string());1114- }1115- cmd.arg("-bf").arg("0")1116- .arg("-pix_fmt").arg("yuv420p")1117- .arg("-bsf:v").arg(bsf)1118- .arg("-f").arg(fmt)1119- .arg("-");1120-1121- let mut child = cmd.spawn().context("failed to spawn ffmpeg")?;1122- let mut stdout = child.stdout.take().context("no stdout from ffmpeg")?;1123- let mut stderr = child.stderr.take();1124- let mut stderr_buf = String::new();1125-1126- let mut buf: Vec<u8> = Vec::with_capacity(65536);1127- let mut frame_start: usize = 0;1128- let mut first_delim_seen = false;1129- let mut frame_count = 0u32;1130- let start = Instant::now();1131- let mut last_frame_time = Instant::now();1132-1133- loop {1134- let mut tmp = [0u8; 16384];1135- let n = stdout.read(&mut tmp).await?;1136- if n == 0 {1137- // Capture stderr for diagnostics1138- if let Some(ref mut stderr) = stderr {1139- use tokio::io::AsyncReadExt;1140- let _ = stderr.read_to_string(&mut stderr_buf).await;1141- }1142- eprintln!("[host] ffmpeg stdout closed");1143- if !stderr_buf.is_empty() {1144- eprintln!("[host] ffmpeg stderr: {}", stderr_buf);1145- }1146- break;1147- }1148- buf.extend_from_slice(&tmp[..n]);1149-1150- // Extract complete frames (delimited by AUD/TD NAL units)1151- loop {1152- let search_from = if first_delim_seen { frame_start + 6 } else { 0 };1153- match find_next_frame_delim(&buf, search_from, &config.codec) {1154- Some(delim_pos) => {1155- if first_delim_seen {1156- let frame_data = &buf[frame_start..delim_pos];1157- let is_keyframe = frame_is_keyframe(frame_data, &config.codec);1158- let frame_size = frame_data.len();1159-1160- let now = Instant::now();1161- let frame_ms = now.duration_since(last_frame_time).as_secs_f64() * 1000.0;1162- last_frame_time = now;1163-1164- // Header: width(4) + height(4) + size(4) + keyframe(1) + codec(1) = 14 bytes1165- let header = [1166- (width as u32).to_be_bytes(),1167- (height as u32).to_be_bytes(),1168- (frame_size as u32).to_be_bytes(),1169- ]1170- .concat();1171- let kf_byte = if is_keyframe { 1u8 } else { 0u8 };1172- let codec_byte = codec_to_byte(&config.codec);1173-1174- send.write_all(&header).await?;1175- send.write_all(&[kf_byte, codec_byte]).await?;1176- send.write_all(frame_data).await?;1177-1178- frame_count += 1;1179- let elapsed = start.elapsed();1180- let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);1181-1182- let _ = app.emit(1183- "host-encode-stats",1184- serde_json::json!({1185- "frame": frame_count,1186- "encode_ms": (frame_ms * 10.0).round() / 10.0,1187- "capture_ms": 0.0,1188- "size_bytes": frame_size,1189- "fps": (fps * 10.0).round() / 10.0,1190- "keyframe": is_keyframe,1191- "encoder": encoder.clone(),1192- }),1193- );1194-1195- eprintln!(1196- "[host] frame={} {}x{} {}={}B kf={} fps={:.1} ftime={:.1}ms",1197- frame_count, width, height, config.codec, frame_size, is_keyframe, fps, frame_ms1198- );1199- }1200- frame_start = delim_pos;1201- first_delim_seen = true;1202- }1203- None => break,1204- }1205- }1206-1207- // Trim consumed data from buffer1208- if frame_start > 0 && frame_start >= buf.len() / 2 {1209- buf.drain(..frame_start);1210- frame_start = 0;1211- }1212-1213- // Check for client disconnect1214- match tokio::time::timeout(Duration::from_millis(1), recv.read(&mut [0u8; 1])).await {1215- Ok(Ok(Some(_))) | Ok(Err(_)) => {1216- eprintln!("[host] client disconnected");1217- break;1218- }1219- _ => {}1220- }1221- }1222-1223- let _ = child.kill().await;1224- Ok(())1225-}1226-1227-// ─── xcap + openh264 fallback path ───────────────────────────────────────────1228-1229-async fn stream_frames_xcap(1230- send: &mut iroh::endpoint::SendStream,1231- recv: &mut iroh::endpoint::RecvStream,1232- app: &AppHandle,1233-) -> anyhow::Result<()> {1234- use std::sync::mpsc;1235-1236- // xcap::Monitor and openh264::Encoder are !Send, so run them on a dedicated thread1237- let (tx, rx) = mpsc::channel::<anyhow::Result<(usize, usize, Vec<u8>, bool, f64, f64)>>();1238-1239- let capture_thread = std::thread::spawn(move || -> anyhow::Result<()> {1240- let monitors = xcap::Monitor::all().context("failed to enumerate monitors")?;1241- let monitor = monitors1242- .into_iter()1243- .next()1244- .ok_or_else(|| anyhow::anyhow!("no monitors found"))?;1245- let mut encoder = Encoder::new().context("failed to create H.264 encoder")?;1246-1247- loop {1248- if tx.send(Ok({1249- let capture_start = Instant::now();1250- let image = monitor.capture_image()?;1251- let capture_ms = capture_start.elapsed().as_secs_f64() * 1000.0;1252-1253- let rgb_image = image::DynamicImage::ImageRgba8(image).to_rgb8();1254- let (w, h) = (rgb_image.width() as usize, rgb_image.height() as usize);1255-1256- let encode_start = Instant::now();1257- let rgb_source = RgbSliceU8::new(rgb_image.as_raw(), (w, h));1258- let yuv = YUVBuffer::from_rgb8_source(rgb_source);1259- let (h264_data, is_keyframe) = {1260- let bitstream = encoder.encode(&yuv).context("H.264 encode failed")?;1261- let kf = matches!(bitstream.frame_type(), openh264::encoder::FrameType::I | openh264::encoder::FrameType::IDR);1262- (bitstream.to_vec(), kf)1263- };1264- let encode_ms = encode_start.elapsed().as_secs_f64() * 1000.0;1265- (w, h, h264_data, is_keyframe, capture_ms, encode_ms)1266- })).is_err() {1267- break; // receiver dropped — stop1268- }1269- }1270- Ok(())1271- });1272-1273- let mut frame_count = 0u32;1274- let start = Instant::now();1275-1276- loop {1277- let (w, h, h264_data, is_keyframe, capture_ms, encode_ms) = match rx.recv() {1278- Ok(Ok(frame)) => frame,1279- Ok(Err(e)) => return Err(e),1280- Err(_) => {1281- eprintln!("[host] capture thread ended");1282- break;1283- }1284- };1285- let h264_size = h264_data.len();1286-1287- // Header: width(4) + height(4) + size(4) + keyframe(1) + codec(1) = 14 bytes1288- let header = [1289- (w as u32).to_be_bytes(),1290- (h as u32).to_be_bytes(),1291- (h264_size as u32).to_be_bytes(),1292- ]1293- .concat();1294- let kf_byte = if is_keyframe { 1u8 } else { 0u8 };1295-1296- send.write_all(&header).await?;1297- send.write_all(&[kf_byte, 0u8]).await?; // xcap path is always h2641298- send.write_all(&h264_data).await?;1299-1300- frame_count += 1;1301- let elapsed = start.elapsed();1302- let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);1303- eprintln!(1304- "[host] frame={} {}x{} h264={}B kf={} fps={:.1} enc={:.1}ms cap={:.1}ms",1305- frame_count, w, h, h264_size, is_keyframe, fps, encode_ms, capture_ms1306- );1307-1308- let _ = app.emit(1309- "host-encode-stats",1310- serde_json::json!({1311- "frame": frame_count,1312- "encode_ms": (encode_ms * 10.0).round() / 10.0,1313- "capture_ms": (capture_ms * 10.0).round() / 10.0,1314- "size_bytes": h264_size,1315- "fps": (fps * 10.0).round() / 10.0,1316- "keyframe": is_keyframe,1317- }),1318- );1319-1320- match tokio::time::timeout(Duration::from_millis(1), recv.read(&mut [0u8; 1])).await {1321- Ok(Ok(Some(_))) | Ok(Err(_)) => {1322- eprintln!("[host] client disconnected");1323- break;1324- }1325- _ => {}1326- }1327-1328- tokio::task::yield_now().await;1329- }1330-1331- // Drop rx to signal the capture thread to stop1332- drop(rx);1333- let _ = capture_thread.join();1334- Ok(())1335-}1336-1337-// ─── Input Stream Handler (host side) ─────────────────────────────────────────1338-1339-#[derive(Debug)]1340-struct InputStreamHandler {1341- connections: Arc<AtomicU32>,1342- app: AppHandle,1343-}1344-1345-impl ProtocolHandler for InputStreamHandler {1346- async fn accept(&self, conn: Connection) -> Result<(), iroh::protocol::AcceptError> {1347- if let Err(e) = handle_input(conn).await {1348- eprintln!("[host] input error: {}", e);1349- }1350- Ok(())1351- }1352-}1353-1354-async fn handle_input(conn: Connection) -> anyhow::Result<()> {1355- let (mut send, mut recv) = conn.accept_bi().await?;1356- eprintln!("[host] input client connected: {}", conn.remote_id());1357-1358- let mut start_buf = [0u8; 1];1359- recv.read_exact(&mut start_buf).await?;1360- if start_buf[0] != 1 {1361- return Ok(());1362- }1363-1364- let (tx, rx) = std::sync::mpsc::channel::<InputEvent>();1365- std::thread::spawn(move || {1366- let mut enigo = match Enigo::new(&Settings::default()) {1367- Ok(e) => e,1368- Err(e) => {1369- eprintln!("[host] enigo init: {:?}", e);1370- return;1371- }1372- };1373- for event in rx {1374- eprintln!("[host] input: {:?}", event);1375- if let Err(e) = event.apply(&mut enigo) {1376- eprintln!("[host] inject error: {}", e);1377- }1378- }1379- });1380-1381- let mut buf = Vec::new();1382- let mut chunk = [0u8; 1024];1383-1384- loop {1385- let n = match recv.read(&mut chunk).await {1386- Ok(Some(n)) => n,1387- Ok(None) => {1388- eprintln!("[host] input client disconnected");1389- break;1390- }1391- Err(e) => {1392- eprintln!("[host] input read error: {}", e);1393- break;1394- }1395- };1396- if n == 0 {1397- break;1398- }1399-1400- buf.extend_from_slice(&chunk[..n]);1401-1402- while let Some(pos) = buf.iter().position(|&b| b == b'\n') {1403- let line: Vec<u8> = buf.drain(..=pos).collect();1404- let line_str = String::from_utf8_lossy(&line[..line.len() - 1]);1405-1406- if line_str.trim().is_empty() {1407- continue;1408- }1409-1410- match serde_json::from_str::<InputEvent>(&line_str) {1411- Ok(event) => {1412- let _ = tx.send(event);1413- }1414- Err(e) => {1415- eprintln!("[host] parse error: {} (line: {})", e, line_str);1416- }1417- }1418- }1419- }1420-1421- drop(tx);1422- let _ = send.write_all(b"bye\n").await;1423- Ok(())1424-}1425-1426-// ─── Iroh Client ─────────────────────────────────────────────────────────────1427-1428-#[derive(Serialize, Clone)]1429-pub struct FramePayload {1430- pub width: u32,1431- pub height: u32,1432- pub data: String,1433- pub keyframe: bool,1434- pub codec: String,1435-}1436-1437-#[derive(Serialize)]1438-pub struct ConnectResult {1439- pub connected: bool,1440- pub host_node_id: Option<String>,1441- pub error: Option<String>,1442-}1443-1444-#[tauri::command]1445-pub async fn iroh_client_connect(1446- app: AppHandle,1447- state: State<'_, AppState>,1448- pin: String,1449-) -> Result<ConnectResult, String> {1450- // Derive the HOST's node ID from the Titan (same key = same node ID)1451- let host_secret = tokio::task::spawn_blocking(move || derive_iroh_secret_from_titan(&pin))1452- .await1453- .map_err(|e| format!("Titan derivation task failed: {}", e))?1454- .map_err(|e| format!("Titan derivation failed: {:?}", e))?;1455-1456- let host_node_id = host_secret.public();1457-1458- // Client uses a random identity (can't connect to yourself)1459- let client_secret = SecretKey::generate();1460- let endpoint = Endpoint::builder(presets::N0)1461- .secret_key(client_secret)1462- .bind()1463- .await1464- .map_err(|e| format!("Failed to bind endpoint: {}", e))?;1465-1466- let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;1467-1468- // Connect via relay using only the derived node ID — no address JSON1469- let addr = iroh::EndpointAddr::new(host_node_id)1470- .with_relay_url(N0_RELAY.parse().map_err(|e| format!("Invalid relay URL: {}", e))?);1471-1472- // Connect frame stream1473- let frame_conn = endpoint1474- .connect(addr.clone(), FRAME_ALPN)1475- .await1476- .map_err(|e| format!("Failed to connect frame stream: {}", e))?;1477-1478- let (mut frame_send, mut frame_recv) = frame_conn1479- .open_bi()1480- .await1481- .map_err(|e| format!("Failed to open frame stream: {}", e))?;1482-1483- frame_send1484- .write_all(&[1u8])1485- .await1486- .map_err(|e| format!("Failed to send start: {}", e))?;1487-1488- // Connect input stream1489- let input_conn = endpoint1490- .connect(addr, INPUT_ALPN)1491- .await1492- .map_err(|e| format!("Failed to connect input stream: {}", e))?;1493-1494- let (mut input_send, mut _input_recv) = input_conn1495- .open_bi()1496- .await1497- .map_err(|e| format!("Failed to open input stream: {}", e))?;1498-1499- input_send1500- .write_all(&[1u8])1501- .await1502- .map_err(|e| format!("Failed to send input start: {}", e))?;1503-1504- // Store input sender in state1505- let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();1506- {1507- let mut input_send_guard = state.input_send.lock().await;1508- *input_send_guard = Some(tx);1509- }1510-1511- // Store endpoint for disconnect1512- {1513- let mut ce = state.client_endpoint.lock().await;1514- *ce = Some(endpoint.clone());1515- }1516-1517- // Spawn input forwarder1518- let mut input_stream = input_send;1519- tokio::spawn(async move {1520- let mut rx = rx;1521- while let Some(event) = rx.recv().await {1522- let json = match serde_json::to_string(&event) {1523- Ok(j) => j + "\n",1524- Err(e) => {1525- eprintln!("[client] serialize error: {}", e);1526- continue;1527- }1528- };1529- if input_stream.write_all(json.as_bytes()).await.is_err() {1530- eprintln!("[client] input stream write failed, disconnecting");1531- break;1532- }1533- }1534- let _ = input_stream.finish();1535- });1536-1537- // Spawn frame reader — dual path based on WebCodecs availability1538- let use_webcodecs = state.webcodecs.load(Ordering::SeqCst);1539- tokio::spawn(async move {1540- let mut frame_count = 0u32;1541- let start = Instant::now();1542-1543- // Initialize software decoder only if WebCodecs is NOT available1544- let mut decoder = if use_webcodecs {1545- None1546- } else {1547- match openh264::decoder::Decoder::new() {1548- Ok(d) => Some(d),1549- Err(e) => {1550- let _ = app.emit("frame-error", format!("Decoder init failed: {}", e));1551- return;1552- }1553- }1554- };1555-1556- loop {1557- let mut header = [0u8; 14];1558- if frame_recv.read_exact(&mut header).await.is_err() {1559- let _ = app.emit("frame-error", "Connection lost");1560- break;1561- }1562-1563- let w = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);1564- let h = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);1565- let h264_len =1566- u32::from_be_bytes([header[8], header[9], header[10], header[11]]) as usize;1567- let is_keyframe = header[12] == 1;1568- let codec = byte_to_codec(header[13]).to_string();1569-1570- let mut h264_buf = vec![0u8; h264_len];1571- if frame_recv.read_exact(&mut h264_buf).await.is_err() {1572- let _ = app.emit("frame-error", "Connection lost");1573- break;1574- }1575-1576- if use_webcodecs {1577- // Path 1: Pass raw H.264 to frontend — browser decodes via WebCodecs1578- let b64 = base64::engine::general_purpose::STANDARD.encode(&h264_buf);1579- let _ = app.emit(1580- "frame",1581- FramePayload { width: w, height: h, data: b64, keyframe: is_keyframe, codec: codec.clone() },1582- );1583-1584- frame_count += 1;1585- let elapsed = start.elapsed();1586- let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);1587- let _ = app.emit(1588- "frame-stats",1589- serde_json::json!({ "fps": fps, "count": frame_count, "keyframe": is_keyframe }),1590- );1591- } else if let Some(ref mut dec) = decoder {1592- // Path 2: Software decode → JPEG1593- for nal in nal_units(&h264_buf) {1594- if let Ok(Some(yuv)) = dec.decode(nal) {1595- let (yw, yh) = yuv.dimensions();1596- let rgb_len = yuv.rgb8_len();1597- let mut rgb_raw = vec![0u8; rgb_len];1598- yuv.write_rgb8(&mut rgb_raw);1599-1600- let img = match image::RgbImage::from_raw(yw as u32, yh as u32, rgb_raw) {1601- Some(img) => img,1602- None => continue,1603- };1604- let mut jpeg_buf = Vec::with_capacity(30_000);1605- if image::DynamicImage::ImageRgb8(img)1606- .write_to(&mut Cursor::new(&mut jpeg_buf), image::ImageFormat::Jpeg)1607- .is_err()1608- {1609- continue;1610- }1611-1612- let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);1613- let _ = app.emit(1614- "frame",1615- FramePayload { width: yw as u32, height: yh as u32, data: b64, keyframe: is_keyframe, codec: codec.clone() },1616- );1617-1618- frame_count += 1;1619- let elapsed = start.elapsed();1620- let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);1621- let _ = app.emit(1622- "frame-stats",1623- serde_json::json!({ "fps": fps, "count": frame_count, "keyframe": is_keyframe }),1624- );1625- }1626- }1627- }1628-1629- tokio::task::yield_now().await;1630- }1631-1632- drop(endpoint);1633- });1634-1635- Ok(ConnectResult {1636- connected: true,1637- host_node_id: Some(host_node_id.to_string()),1638- error: None,1639- })1640-}1641-1642-#[tauri::command]1643-pub async fn iroh_client_send_input(1644- state: State<'_, AppState>,1645- event: InputEvent,1646-) -> Result<bool, String> {1647- let input_send = state.input_send.lock().await;1648- match &*input_send {1649- Some(tx) => {1650- tx.send(event)1651- .map_err(|e| format!("Input channel closed: {}", e))?;1652- Ok(true)1653- }1654- None => Err("Not connected to host".into()),1655- }1656-}1657-1658-#[tauri::command]1659-pub async fn iroh_client_disconnect(state: State<'_, AppState>) -> Result<bool, String> {1660- // Close endpoint — kills all streams1661- {1662- let mut ce = state.client_endpoint.lock().await;1663- if let Some(endpoint) = ce.take() {1664- endpoint.close().await;1665- }1666- }1667- // Clear input channel1668- {1669- let mut input_send = state.input_send.lock().await;1670- *input_send = None;1671- }1672- Ok(true)1673-}
A src-tauri/src/commands/auth.rs +317 -0
@@ -0,0 +1,317 @@
1+use anyhow::Context as _;2+use ctap_hid_fido2::fidokey::{3+ GetAssertionArgsBuilder, MakeCredentialArgsBuilder,4+ get_assertion::Extension as Gext,5+ get_assertion::get_assertion_params::Assertion,6+ make_credential::Extension as Mext,7+};8+use ctap_hid_fido2::public_key_credential_user_entity::PublicKeyCredentialUserEntity;9+use ctap_hid_fido2::{verifier, Cfg, FidoKeyHidFactory};10+use iroh::SecretKey;11+use serde::Serialize;12+use sha2::{Digest, Sha256};13+use std::time::Duration;14+use super::state::{KEYRING_ENTRY, KEYRING_SERVICE, RPID, SALT_MESSAGE};15+16+// ─── Keyring Persistence ─────────────────────────────────────────────────────17+18+pub fn store_identity_in_keyring(secret: &[u8; 32]) -> anyhow::Result<()> {19+ let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)20+ .context("Failed to create keyring entry")?;21+ entry22+ .set_secret(secret)23+ .context("Failed to store identity in keyring")24+}25+26+pub fn load_identity_from_keyring() -> anyhow::Result<Option<[u8; 32]>> {27+ let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)28+ .context("Failed to create keyring entry")?;29+ match entry.get_secret() {30+ Ok(bytes) => {31+ if bytes.len() == 32 {32+ let mut arr = [0u8; 32];33+ arr.copy_from_slice(&bytes);34+ Ok(Some(arr))35+ } else {36+ anyhow::bail!("Keyring entry has wrong length: {}", bytes.len())37+ }38+ }39+ Err(keyring::Error::NoEntry) => Ok(None),40+ Err(e) => anyhow::bail!("Failed to read keyring: {:?}", e),41+ }42+}43+44+pub fn clear_identity_from_keyring() -> anyhow::Result<()> {45+ let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ENTRY)46+ .context("Failed to create keyring entry")?;47+ match entry.delete_credential() {48+ Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),49+ Err(e) => anyhow::bail!("Failed to clear keyring: {:?}", e),50+ }51+}52+53+// ─── Titan HMAC-Secret Derivation ────────────────────────────────────────────54+55+pub fn derive_secret_from_titan(pin: &str) -> anyhow::Result<[u8; 32]> {56+ let cfg = Cfg::init();57+ let device = FidoKeyHidFactory::create(&cfg)58+ .context("Security key not found. Make sure it is plugged in.")?;59+60+ let salt: [u8; 32] = {61+ let mut hasher = Sha256::new();62+ hasher.update(SALT_MESSAGE.as_bytes());63+ let result = hasher.finalize();64+ let mut s = [0u8; 32];65+ s.copy_from_slice(&result);66+ s67+ };68+69+ // Try resident key first70+ let challenge = verifier::create_challenge();71+ let get_args = GetAssertionArgsBuilder::new(RPID, &challenge)72+ .pin(pin)73+ .extensions(&[Gext::HmacSecret(Some(salt))])74+ .build();75+76+ match device.get_assertion_with_args(&get_args) {77+ Ok(assertions) => return extract_hmac_secret(&assertions),78+ Err(_) => {}79+ }80+81+ // No resident key — create one82+ let user_entity = PublicKeyCredentialUserEntity::new(83+ Some(b"keyhome-user"),84+ Some("keyhome"),85+ Some("Keyhome"),86+ );87+88+ let challenge = verifier::create_challenge();89+ let make_args = MakeCredentialArgsBuilder::new(RPID, &challenge)90+ .pin(pin)91+ .user_entity(&user_entity)92+ .resident_key()93+ .extensions(&[Mext::HmacSecret(Some(true))])94+ .build();95+96+ let attestation = device97+ .make_credential_with_args(&make_args)98+ .context("make_credential failed")?;99+100+ let verify_result = verifier::verify_attestation(RPID, &challenge, &attestation);101+ if !verify_result.is_success {102+ anyhow::bail!("Attestation verification failed");103+ }104+ let credential_id = verify_result.credential_id;105+106+ let challenge2 = verifier::create_challenge();107+ let get_args = GetAssertionArgsBuilder::new(RPID, &challenge2)108+ .pin(pin)109+ .credential_id(&credential_id)110+ .extensions(&[Gext::HmacSecret(Some(salt))])111+ .build();112+113+ let assertions = device114+ .get_assertion_with_args(&get_args)115+ .context("get_assertion failed")?;116+117+ extract_hmac_secret(&assertions)118+}119+120+pub fn extract_hmac_secret(assertions: &[Assertion]) -> anyhow::Result<[u8; 32]> {121+ for ext in &assertions[0].extensions {122+ if let Gext::HmacSecret(Some(output)) = ext {123+ let mut secret = [0u8; 32];124+ secret.copy_from_slice(&output[..]);125+ return Ok(secret);126+ }127+ }128+ anyhow::bail!("No hmac-secret in assertion response")129+}130+131+pub fn derive_iroh_secret_from_titan(pin: &str) -> anyhow::Result<SecretKey> {132+ let secret_bytes = derive_secret_from_titan(pin)?;133+ Ok(SecretKey::from_bytes(&secret_bytes))134+}135+136+// ─── FIDO2 Tauri Commands ─────────────────────────────────────────────────────137+138+#[derive(Serialize)]139+pub struct FidoDeviceInfo {140+ pub found: bool,141+ pub vid: u16,142+ pub pid: u16,143+ pub product: String,144+ pub versions: Vec<String>,145+ pub extensions: Vec<String>,146+ pub options: Vec<(String, bool)>,147+ pub max_msg_size: u32,148+ pub pin_retries: u32,149+ pub error: Option<String>,150+}151+152+impl Default for FidoDeviceInfo {153+ fn default() -> Self {154+ Self {155+ found: false,156+ vid: 0,157+ pid: 0,158+ product: String::new(),159+ versions: vec![],160+ extensions: vec![],161+ options: vec![],162+ max_msg_size: 0,163+ pin_retries: 0,164+ error: None,165+ }166+ }167+}168+169+#[tauri::command]170+pub fn fido_device_info() -> FidoDeviceInfo {171+ let devices = ctap_hid_fido2::get_fidokey_devices();172+ if devices.is_empty() {173+ return FidoDeviceInfo { found: false, ..Default::default() };174+ }175+176+ let dev = &devices[0];177+ let vid = dev.vid;178+ let pid = dev.pid;179+ let product = format!("{:?}", dev.info);180+181+ let cfg = Cfg::init();182+ match FidoKeyHidFactory::create(&cfg) {183+ Ok(device) => {184+ let info = match device.get_info() {185+ Ok(i) => i,186+ Err(e) => {187+ return FidoDeviceInfo {188+ found: true,189+ vid,190+ pid,191+ product,192+ error: Some(format!("get_info failed: {:?}", e)),193+ ..Default::default()194+ };195+ }196+ };197+ let pin_retries = device.get_pin_retries().unwrap_or(0);198+ FidoDeviceInfo {199+ found: true,200+ vid,201+ pid,202+ product,203+ versions: info.versions.clone(),204+ extensions: info.extensions.clone(),205+ options: info.options.clone(),206+ max_msg_size: info.max_msg_size as u32,207+ pin_retries: pin_retries as u32,208+ error: None,209+ }210+ }211+ Err(e) => FidoDeviceInfo {212+ found: true,213+ vid,214+ pid,215+ product,216+ error: Some(format!("Failed to open device: {:?}", e)),217+ ..Default::default()218+ },219+ }220+}221+222+#[derive(Serialize)]223+pub struct PinRetries {224+ pub retries: u32,225+ pub error: Option<String>,226+}227+228+#[tauri::command]229+pub fn fido_pin_retries() -> PinRetries {230+ let cfg = Cfg::init();231+ match FidoKeyHidFactory::create(&cfg) {232+ Ok(device) => match device.get_pin_retries() {233+ Ok(n) => PinRetries { retries: n as u32, error: None },234+ Err(e) => PinRetries { retries: 0, error: Some(format!("{:?}", e)) },235+ },236+ Err(e) => PinRetries {237+ retries: 0,238+ error: Some(format!("Device not found: {:?}", e)),239+ },240+ }241+}242+243+#[derive(Serialize)]244+pub struct TitanIdentity {245+ pub node_id: String,246+ pub error: Option<String>,247+}248+249+#[tauri::command]250+pub async fn titan_derive_identity(pin: String) -> TitanIdentity {251+ let result = tokio::time::timeout(252+ Duration::from_secs(30),253+ tokio::task::spawn_blocking(move || derive_iroh_secret_from_titan(&pin)),254+ )255+ .await;256+ match result {257+ Err(_) => TitanIdentity {258+ node_id: String::new(),259+ error: Some("Security key timed out (30s). Check that your key is connected.".into()),260+ },261+ Ok(Err(e)) => TitanIdentity { node_id: String::new(), error: Some(format!("Task error: {}", e)) },262+ Ok(Ok(Err(e))) => TitanIdentity { node_id: String::new(), error: Some(format!("{:?}", e)) },263+ Ok(Ok(Ok(secret))) => TitanIdentity { node_id: secret.public().to_string(), error: None },264+ }265+}266+267+// ─── Registration Commands ────────────────────────────────────────────────────268+269+#[derive(Serialize)]270+pub struct RegistrationStatus {271+ pub registered: bool,272+ pub node_id: Option<String>,273+ pub error: Option<String>,274+}275+276+#[tauri::command]277+pub fn host_registration_status() -> RegistrationStatus {278+ match load_identity_from_keyring() {279+ Ok(Some(bytes)) => {280+ let secret = SecretKey::from_bytes(&bytes);281+ RegistrationStatus {282+ registered: true,283+ node_id: Some(secret.public().to_string()),284+ error: None,285+ }286+ }287+ Ok(None) => RegistrationStatus { registered: false, node_id: None, error: None },288+ Err(e) => RegistrationStatus {289+ registered: false,290+ node_id: None,291+ error: Some(format!("{:?}", e)),292+ },293+ }294+}295+296+#[tauri::command]297+pub async fn titan_register_host(pin: String) -> Result<RegistrationStatus, String> {298+ let secret = tokio::time::timeout(299+ Duration::from_secs(30),300+ tokio::task::spawn_blocking(move || derive_secret_from_titan(&pin)),301+ )302+ .await303+ .map_err(|_| "Security key timed out (30s). Make sure your key is connected.".to_string())?304+ .map_err(|e| format!("Task failed: {}", e))?305+ .map_err(|e| format!("FIDO2 error: {:?}", e))?;306+307+ let node_id = SecretKey::from_bytes(&secret).public().to_string();308+ store_identity_in_keyring(&secret).map_err(|e| format!("Keyring store failed: {:?}", e))?;309+310+ Ok(RegistrationStatus { registered: true, node_id: Some(node_id), error: None })311+}312+313+#[tauri::command]314+pub fn host_unregister() -> Result<bool, String> {315+ clear_identity_from_keyring().map_err(|e| format!("Keyring clear failed: {:?}", e))?;316+ Ok(true)317+}
A src-tauri/src/commands/input.rs +223 -0
@@ -0,0 +1,223 @@
1+use enigo::{Button, Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings};2+use iroh::{endpoint::Connection, protocol::ProtocolHandler};3+use serde::{Deserialize, Serialize};4+5+// ─── Input Event Protocol ─────────────────────────────────────────────────────6+7+#[derive(Serialize, Deserialize, Debug, Clone)]8+#[serde(tag = "t")]9+pub enum InputEvent {10+ #[serde(rename = "mm")]11+ MouseMove { x: i32, y: i32 },12+ #[serde(rename = "mc")]13+ MouseClick { b: u8 },14+ #[serde(rename = "md")]15+ MouseDown { b: u8 },16+ #[serde(rename = "mu")]17+ MouseUp { b: u8 },18+ #[serde(rename = "ms")]19+ MouseScroll { dx: i32, dy: i32 },20+ #[serde(rename = "kd")]21+ KeyDown { k: String },22+ #[serde(rename = "ku")]23+ KeyUp { k: String },24+ #[serde(rename = "kt")]25+ KeyClick { k: String },26+ #[serde(rename = "tx")]27+ Text { s: String },28+}29+30+impl InputEvent {31+ pub fn apply(&self, enigo: &mut Enigo) -> anyhow::Result<()> {32+ match self {33+ InputEvent::MouseMove { x, y } => {34+ enigo35+ .move_mouse(*x, *y, Coordinate::Abs)36+ .map_err(|e| anyhow::anyhow!("mouse move: {:?}", e))?;37+ }38+ InputEvent::MouseClick { b } => {39+ enigo40+ .button(button_from_code(*b), Direction::Click)41+ .map_err(|e| anyhow::anyhow!("mouse click: {:?}", e))?;42+ }43+ InputEvent::MouseDown { b } => {44+ enigo45+ .button(button_from_code(*b), Direction::Press)46+ .map_err(|e| anyhow::anyhow!("mouse down: {:?}", e))?;47+ }48+ InputEvent::MouseUp { b } => {49+ enigo50+ .button(button_from_code(*b), Direction::Release)51+ .map_err(|e| anyhow::anyhow!("mouse up: {:?}", e))?;52+ }53+ InputEvent::MouseScroll { dx, dy } => {54+ if *dy != 0 {55+ enigo56+ .scroll(*dy, enigo::Axis::Vertical)57+ .map_err(|e| anyhow::anyhow!("scroll: {:?}", e))?;58+ }59+ if *dx != 0 {60+ enigo61+ .scroll(*dx, enigo::Axis::Horizontal)62+ .map_err(|e| anyhow::anyhow!("scroll: {:?}", e))?;63+ }64+ }65+ InputEvent::KeyDown { k } => {66+ if let Some(key) = key_from_str(k) {67+ enigo68+ .key(key, Direction::Press)69+ .map_err(|e| anyhow::anyhow!("key down: {:?}", e))?;70+ }71+ }72+ InputEvent::KeyUp { k } => {73+ if let Some(key) = key_from_str(k) {74+ enigo75+ .key(key, Direction::Release)76+ .map_err(|e| anyhow::anyhow!("key up: {:?}", e))?;77+ }78+ }79+ InputEvent::KeyClick { k } => {80+ if let Some(key) = key_from_str(k) {81+ enigo82+ .key(key, Direction::Click)83+ .map_err(|e| anyhow::anyhow!("key click: {:?}", e))?;84+ }85+ }86+ InputEvent::Text { s } => {87+ enigo88+ .text(s)89+ .map_err(|e| anyhow::anyhow!("text: {:?}", e))?;90+ }91+ }92+ Ok(())93+ }94+}95+96+fn button_from_code(b: u8) -> Button {97+ match b {98+ 2 => Button::Right,99+ 3 => Button::Middle,100+ _ => Button::Left,101+ }102+}103+104+fn key_from_str(s: &str) -> Option<Key> {105+ match s {106+ "Enter" => Some(Key::Return),107+ "Tab" => Some(Key::Tab),108+ "Space" => Some(Key::Space),109+ "Backspace" => Some(Key::Backspace),110+ "Escape" => Some(Key::Escape),111+ "Shift" => Some(Key::Shift),112+ "Control" => Some(Key::Control),113+ "Alt" => Some(Key::Alt),114+ "Meta" => Some(Key::Meta),115+ "Up" => Some(Key::UpArrow),116+ "Down" => Some(Key::DownArrow),117+ "Left" => Some(Key::LeftArrow),118+ "Right" => Some(Key::RightArrow),119+ "Home" => Some(Key::Home),120+ "End" => Some(Key::End),121+ "PageUp" => Some(Key::PageUp),122+ "PageDown" => Some(Key::PageDown),123+ "Delete" => Some(Key::Delete),124+ _ => {125+ let c = s.chars().next()?;126+ if c.is_ascii() {127+ Some(Key::Unicode(c))128+ } else {129+ None130+ }131+ }132+ }133+}134+135+// ─── Input Stream Handler (host side) ─────────────────────────────────────────136+137+#[derive(Debug)]138+pub struct InputStreamHandler;139+140+impl ProtocolHandler for InputStreamHandler {141+ async fn accept(&self, conn: Connection) -> Result<(), iroh::protocol::AcceptError> {142+ if let Err(e) = handle_input(conn).await {143+ eprintln!("[host] input error: {}", e);144+ }145+ Ok(())146+ }147+}148+149+pub async fn handle_input(conn: Connection) -> anyhow::Result<()> {150+ let (mut send, mut recv) = conn.accept_bi().await?;151+ eprintln!("[host] input client connected: {}", conn.remote_id());152+153+ let mut start_buf = [0u8; 1];154+ recv.read_exact(&mut start_buf).await?;155+ if start_buf[0] != 1 {156+ return Ok(());157+ }158+159+ // Bounded channel: limits enigo backlog without blocking the async reader.160+ // try_send on full channel drops the event rather than stalling the stream.161+ let (tx, rx) = std::sync::mpsc::sync_channel::<InputEvent>(64);162+ std::thread::spawn(move || {163+ let mut enigo = match Enigo::new(&Settings::default()) {164+ Ok(e) => e,165+ Err(e) => {166+ eprintln!("[host] enigo init: {:?}", e);167+ return;168+ }169+ };170+ for event in rx {171+ eprintln!("[host] input: {:?}", event);172+ if let Err(e) = event.apply(&mut enigo) {173+ eprintln!("[host] inject error: {}", e);174+ }175+ }176+ });177+178+ let mut buf = Vec::new();179+ let mut chunk = [0u8; 1024];180+181+ loop {182+ let n = match recv.read(&mut chunk).await {183+ Ok(Some(n)) => n,184+ Ok(None) => {185+ eprintln!("[host] input client disconnected");186+ break;187+ }188+ Err(e) => {189+ eprintln!("[host] input read error: {}", e);190+ break;191+ }192+ };193+ if n == 0 {194+ break;195+ }196+197+ buf.extend_from_slice(&chunk[..n]);198+199+ while let Some(pos) = buf.iter().position(|&b| b == b'\n') {200+ let line: Vec<u8> = buf.drain(..=pos).collect();201+ let line_str = String::from_utf8_lossy(&line[..line.len() - 1]);202+203+ if line_str.trim().is_empty() {204+ continue;205+ }206+207+ match serde_json::from_str::<InputEvent>(&line_str) {208+ Ok(event) => {209+ if tx.try_send(event).is_err() {210+ eprintln!("[host] input dropped (enigo backlogged)");211+ }212+ }213+ Err(e) => {214+ eprintln!("[host] parse error: {} (line: {})", e, line_str);215+ }216+ }217+ }218+ }219+220+ drop(tx);221+ let _ = send.write_all(b"bye\n").await;222+ Ok(())223+}
A src-tauri/src/commands/mod.rs +23 -0
A src-tauri/src/commands/network.rs +364 -0
@@ -0,0 +1,364 @@
1+use base64::Engine;2+use iroh::{endpoint::presets, Endpoint, SecretKey};3+use iroh::protocol::Router;4+use openh264::{formats::YUVSource, nal_units};5+use serde::Serialize;6+use std::io::Cursor;7+use std::sync::Arc;8+use std::sync::atomic::Ordering;9+use std::time::{Duration, Instant};10+use tauri::{AppHandle, Emitter, State};11+use super::auth::{derive_iroh_secret_from_titan, load_identity_from_keyring};12+use super::input::{InputEvent, InputStreamHandler};13+use super::state::{AppState, HostState, INPUT_ALPN, FRAME_ALPN};14+use super::streaming::{byte_to_codec, FrameStreamHandler};15+16+// ─── Host commands ────────────────────────────────────────────────────────────17+18+#[derive(Serialize)]19+pub struct HostStatus {20+ pub running: bool,21+ pub node_id: Option<String>,22+ pub error: Option<String>,23+}24+25+#[tauri::command]26+pub async fn iroh_host_start(27+ app: AppHandle,28+ state: State<'_, AppState>,29+) -> Result<HostStatus, String> {30+ let secret_bytes = load_identity_from_keyring()31+ .map_err(|e| format!("Keyring read failed: {:?}", e))?32+ .ok_or_else(|| "Not registered. Click 'Register' first.".to_string())?;33+34+ let secret = SecretKey::from_bytes(&secret_bytes);35+ let node_id = secret.public().to_string();36+37+ let endpoint = Endpoint::builder(presets::N0)38+ .secret_key(secret)39+ .bind()40+ .await41+ .map_err(|e| format!("Failed to bind endpoint: {}", e))?;42+43+ let _ = tokio::time::timeout(Duration::from_secs(5), endpoint.online()).await;44+45+ let connections = state.host_connections.clone();46+ let frame_handler = Arc::new(FrameStreamHandler {47+ connections: connections.clone(),48+ app: app.clone(),49+ });50+ let input_handler = Arc::new(InputStreamHandler);51+52+ let router = Router::builder(endpoint.clone())53+ .accept(FRAME_ALPN, frame_handler)54+ .accept(INPUT_ALPN, input_handler)55+ .spawn();56+57+ {58+ let mut he = state.host_endpoint.lock().await;59+ *he = Some(endpoint);60+ }61+ std::mem::forget(router);62+63+ let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;64+ *host = Some(HostState { node_id: node_id.clone() });65+66+ Ok(HostStatus { running: true, node_id: Some(node_id), error: None })67+}68+69+#[tauri::command]70+pub async fn iroh_host_stop(state: State<'_, AppState>) -> Result<bool, String> {71+ {72+ let mut he = state.host_endpoint.lock().await;73+ if let Some(endpoint) = he.take() {74+ endpoint.close().await;75+ }76+ }77+ {78+ let mut host = state.host.lock().map_err(|e| format!("Lock error: {}", e))?;79+ *host = None;80+ }81+ Ok(true)82+}83+84+#[tauri::command]85+pub fn iroh_host_status(state: State<'_, AppState>) -> HostStatus {86+ let host = state.host.lock().ok();87+ match host {88+ Some(h) => match &*h {89+ Some(hs) => HostStatus { running: true, node_id: Some(hs.node_id.clone()), error: None },90+ None => HostStatus { running: false, node_id: None, error: None },91+ },92+ None => HostStatus {93+ running: false,94+ node_id: None,95+ error: Some("State lock poisoned".into()),96+ },97+ }98+}99+100+// ─── Client commands ──────────────────────────────────────────────────────────101+102+#[derive(Serialize, Clone)]103+pub struct FramePayload {104+ pub width: u32,105+ pub height: u32,106+ pub data: String,107+ pub keyframe: bool,108+ pub codec: String,109+}110+111+#[derive(Serialize)]112+pub struct ConnectResult {113+ pub connected: bool,114+ pub host_node_id: Option<String>,115+ pub error: Option<String>,116+}117+118+#[tauri::command]119+pub async fn iroh_client_connect(120+ app: AppHandle,121+ state: State<'_, AppState>,122+ pin: String,123+) -> Result<ConnectResult, String> {124+ // FIDO2 derivation — 30s timeout so a missing/stuck key surfaces quickly125+ let host_secret = tokio::time::timeout(126+ Duration::from_secs(30),127+ tokio::task::spawn_blocking(move || derive_iroh_secret_from_titan(&pin)),128+ )129+ .await130+ .map_err(|_| "Security key timed out (30s). Make sure your key is connected.".to_string())?131+ .map_err(|e| format!("Task failed: {}", e))?132+ .map_err(|e| format!("FIDO2 error: {:?}", e))?;133+134+ let host_node_id = host_secret.public();135+136+ // Key has been tapped — relay connection is next; update the UI overlay137+ let _ = app.emit("fido-done", ());138+139+ let client_secret = SecretKey::generate();140+ let endpoint = Endpoint::builder(presets::N0)141+ .secret_key(client_secret)142+ .bind()143+ .await144+ .map_err(|e| format!("Failed to bind endpoint: {}", e))?;145+146+ let _ = tokio::time::timeout(Duration::from_secs(10), endpoint.online()).await;147+148+ // Use just the node ID — the presets::N0 relay map handles geographic149+ // routing and fallback across all N0 relays automatically.150+ let addr = iroh::EndpointAddr::new(host_node_id);151+152+ let frame_conn = endpoint153+ .connect(addr.clone(), FRAME_ALPN)154+ .await155+ .map_err(|e| format!("Failed to connect frame stream: {}", e))?;156+157+ let (mut frame_send, mut frame_recv) = frame_conn158+ .open_bi()159+ .await160+ .map_err(|e| format!("Failed to open frame stream: {}", e))?;161+162+ frame_send163+ .write_all(&[1u8])164+ .await165+ .map_err(|e| format!("Failed to send start: {}", e))?;166+167+ let input_conn = endpoint168+ .connect(addr, INPUT_ALPN)169+ .await170+ .map_err(|e| format!("Failed to connect input stream: {}", e))?;171+172+ let (mut input_send, mut _input_recv) = input_conn173+ .open_bi()174+ .await175+ .map_err(|e| format!("Failed to open input stream: {}", e))?;176+177+ input_send178+ .write_all(&[1u8])179+ .await180+ .map_err(|e| format!("Failed to send input start: {}", e))?;181+182+ let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();183+ {184+ let mut input_send_guard = state.input_send.lock().await;185+ *input_send_guard = Some(tx);186+ }187+188+ {189+ let mut ce = state.client_endpoint.lock().await;190+ *ce = Some(endpoint.clone());191+ }192+193+ // Input forwarder: throttle MouseMove to ~60 fps to prevent queue growth194+ let mut input_stream = input_send;195+ tokio::spawn(async move {196+ let mut rx = rx;197+ let mut last_mouse_time = Instant::now();198+ const MOUSE_INTERVAL: Duration = Duration::from_millis(16);199+200+ while let Some(event) = rx.recv().await {201+ if matches!(event, InputEvent::MouseMove { .. }) {202+ let now = Instant::now();203+ if now.duration_since(last_mouse_time) < MOUSE_INTERVAL {204+ continue;205+ }206+ last_mouse_time = now;207+ }208+ let json = match serde_json::to_string(&event) {209+ Ok(j) => j + "\n",210+ Err(e) => {211+ eprintln!("[client] serialize error: {}", e);212+ continue;213+ }214+ };215+ if input_stream.write_all(json.as_bytes()).await.is_err() {216+ eprintln!("[client] input stream write failed, disconnecting");217+ break;218+ }219+ }220+ let _ = input_stream.finish();221+ });222+223+ // Frame reader — dual path: WebCodecs (raw bytes) or software JPEG decode224+ let use_webcodecs = state.webcodecs.load(Ordering::SeqCst);225+ tokio::spawn(async move {226+ let mut frame_count = 0u32;227+ let start = Instant::now();228+229+ let mut decoder = if use_webcodecs {230+ None231+ } else {232+ match openh264::decoder::Decoder::new() {233+ Ok(d) => Some(d),234+ Err(e) => {235+ let _ = app.emit("frame-error", format!("Decoder init failed: {}", e));236+ return;237+ }238+ }239+ };240+241+ loop {242+ let mut header = [0u8; 14];243+ if frame_recv.read_exact(&mut header).await.is_err() {244+ let _ = app.emit("frame-error", "Connection lost");245+ break;246+ }247+248+ let w = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);249+ let h = u32::from_be_bytes([header[4], header[5], header[6], header[7]]);250+ let frame_len =251+ u32::from_be_bytes([header[8], header[9], header[10], header[11]]) as usize;252+ let is_keyframe = header[12] == 1;253+ let codec = byte_to_codec(header[13]).to_string();254+255+ let mut frame_buf = vec![0u8; frame_len];256+ if frame_recv.read_exact(&mut frame_buf).await.is_err() {257+ let _ = app.emit("frame-error", "Connection lost");258+ break;259+ }260+261+ if use_webcodecs {262+ let b64 = base64::engine::general_purpose::STANDARD.encode(&frame_buf);263+ let _ = app.emit(264+ "frame",265+ FramePayload {266+ width: w,267+ height: h,268+ data: b64,269+ keyframe: is_keyframe,270+ codec: codec.clone(),271+ },272+ );273+ frame_count += 1;274+ let fps = frame_count as f64 / start.elapsed().as_secs_f64().max(0.001);275+ let _ = app.emit(276+ "frame-stats",277+ serde_json::json!({ "fps": fps, "count": frame_count, "keyframe": is_keyframe }),278+ );279+ } else if let Some(ref mut dec) = decoder {280+ for nal in nal_units(&frame_buf) {281+ if let Ok(Some(yuv)) = dec.decode(nal) {282+ let (yw, yh) = yuv.dimensions();283+ let rgb_len = yuv.rgb8_len();284+ let mut rgb_raw = vec![0u8; rgb_len];285+ yuv.write_rgb8(&mut rgb_raw);286+287+ let img = match image::RgbImage::from_raw(yw as u32, yh as u32, rgb_raw) {288+ Some(img) => img,289+ None => continue,290+ };291+ let mut jpeg_buf = Vec::with_capacity(30_000);292+ if image::DynamicImage::ImageRgb8(img)293+ .write_to(294+ &mut Cursor::new(&mut jpeg_buf),295+ image::ImageFormat::Jpeg,296+ )297+ .is_err()298+ {299+ continue;300+ }301+302+ let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg_buf);303+ let _ = app.emit(304+ "frame",305+ FramePayload {306+ width: yw as u32,307+ height: yh as u32,308+ data: b64,309+ keyframe: is_keyframe,310+ codec: codec.clone(),311+ },312+ );313+ frame_count += 1;314+ let fps = frame_count as f64 / start.elapsed().as_secs_f64().max(0.001);315+ let _ = app.emit(316+ "frame-stats",317+ serde_json::json!({ "fps": fps, "count": frame_count, "keyframe": is_keyframe }),318+ );319+ }320+ }321+ }322+323+ tokio::task::yield_now().await;324+ }325+326+ drop(endpoint);327+ });328+329+ Ok(ConnectResult {330+ connected: true,331+ host_node_id: Some(host_node_id.to_string()),332+ error: None,333+ })334+}335+336+#[tauri::command]337+pub async fn iroh_client_send_input(338+ state: State<'_, AppState>,339+ event: InputEvent,340+) -> Result<bool, String> {341+ let input_send = state.input_send.lock().await;342+ match &*input_send {343+ Some(tx) => {344+ tx.send(event).map_err(|e| format!("Input channel closed: {}", e))?;345+ Ok(true)346+ }347+ None => Err("Not connected to host".into()),348+ }349+}350+351+#[tauri::command]352+pub async fn iroh_client_disconnect(state: State<'_, AppState>) -> Result<bool, String> {353+ {354+ let mut ce = state.client_endpoint.lock().await;355+ if let Some(endpoint) = ce.take() {356+ endpoint.close().await;357+ }358+ }359+ {360+ let mut input_send = state.input_send.lock().await;361+ *input_send = None;362+ }363+ Ok(true)364+}
A src-tauri/src/commands/state.rs +81 -0
@@ -0,0 +1,81 @@
1+use serde::{Deserialize, Serialize};2+use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};3+use std::sync::{Arc, Mutex};4+use tokio::sync::Mutex as TokioMutex;5+6+pub const FRAME_ALPN: &[u8] = b"keyhome/frame-stream/0";7+pub const INPUT_ALPN: &[u8] = b"keyhome/input-stream/0";8+pub const RPID: &str = "keyhome";9+pub const SALT_MESSAGE: &str = "keyhome-iroh-identity-v1";10+pub const KEYRING_SERVICE: &str = "keyhome";11+pub const KEYRING_ENTRY: &str = "host-identity";12+13+#[derive(Clone, Debug, Serialize, Deserialize)]14+pub struct EncoderConfig {15+ pub codec: String,16+ pub backend: String,17+ pub bitrate: String,18+ pub framerate: u32,19+ pub gop: u32,20+}21+22+impl Default for EncoderConfig {23+ fn default() -> Self {24+ Self {25+ codec: "h264".to_string(),26+ backend: "auto".to_string(),27+ bitrate: "8M".to_string(),28+ framerate: 30,29+ gop: 30,30+ }31+ }32+}33+34+pub struct HostState {35+ pub node_id: String,36+}37+38+pub struct AppState {39+ pub host: Mutex<Option<HostState>>,40+ pub host_connections: Arc<AtomicU32>,41+ pub host_endpoint: TokioMutex<Option<iroh::Endpoint>>,42+ pub input_send:43+ TokioMutex<Option<tokio::sync::mpsc::UnboundedSender<super::input::InputEvent>>>,44+ pub client_endpoint: TokioMutex<Option<iroh::Endpoint>>,45+ pub webcodecs: AtomicBool,46+ pub daemon: bool,47+ pub encoder_config: Mutex<EncoderConfig>,48+}49+50+impl Default for AppState {51+ fn default() -> Self {52+ let daemon = std::env::args().any(|a| a == "--daemon");53+ Self {54+ host: Mutex::new(None),55+ host_connections: Arc::new(AtomicU32::new(0)),56+ host_endpoint: TokioMutex::new(None),57+ input_send: TokioMutex::new(None),58+ client_endpoint: TokioMutex::new(None),59+ webcodecs: AtomicBool::new(false),60+ daemon,61+ encoder_config: Mutex::new(EncoderConfig::default()),62+ }63+ }64+}65+66+// ─── Simple state Tauri commands ─────────────────────────────────────────────67+68+#[tauri::command]69+pub fn is_daemon_mode(state: tauri::State<'_, AppState>) -> bool {70+ state.daemon71+}72+73+#[tauri::command]74+pub fn set_webcodecs_available(state: tauri::State<'_, AppState>, available: bool) {75+ state.webcodecs.store(available, Ordering::SeqCst);76+}77+78+#[tauri::command]79+pub fn is_webcodecs_available(state: tauri::State<'_, AppState>) -> bool {80+ state.webcodecs.load(Ordering::SeqCst)81+}
A src-tauri/src/commands/streaming.rs +668 -0
@@ -0,0 +1,668 @@
1+use anyhow::Context as _;2+use iroh::{endpoint::Connection, protocol::ProtocolHandler};3+use openh264::{4+ encoder::Encoder,5+ formats::{RgbSliceU8, YUVBuffer},6+};7+use std::sync::Arc;8+use std::sync::atomic::Ordering;9+use std::time::{Duration, Instant};10+use tauri::{AppHandle, Emitter, Manager, State};11+use super::state::{AppState, EncoderConfig};12+13+// ─── Codec helpers ────────────────────────────────────────────────────────────14+15+pub fn codec_to_byte(codec: &str) -> u8 {16+ match codec {17+ "h265" => 1,18+ "av1" => 2,19+ _ => 0,20+ }21+}22+23+pub fn byte_to_codec(b: u8) -> &'static str {24+ match b {25+ 1 => "h265",26+ 2 => "av1",27+ _ => "h264",28+ }29+}30+31+// ─── Encoder config commands ──────────────────────────────────────────────────32+33+#[tauri::command]34+pub fn get_encoder_config(state: State<'_, AppState>) -> EncoderConfig {35+ state.encoder_config.lock().unwrap().clone()36+}37+38+#[tauri::command]39+pub fn set_encoder_config(app: AppHandle, state: State<'_, AppState>, config: EncoderConfig) {40+ *state.encoder_config.lock().unwrap() = config.clone();41+ if let Ok(data_dir) = app.path().app_data_dir() {42+ let _ = std::fs::create_dir_all(&data_dir);43+ let path = data_dir.join("encoder_config.json");44+ if let Ok(json) = serde_json::to_string_pretty(&config) {45+ let _ = std::fs::write(path, json);46+ }47+ }48+}49+50+#[tauri::command]51+pub fn detect_available_encoders() -> Vec<String> {52+ let encoders = std::process::Command::new("ffmpeg")53+ .arg("-encoders")54+ .output()55+ .ok()56+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())57+ .unwrap_or_default();58+59+ let all = [60+ "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox", "libx264",61+ "hevc_nvenc", "hevc_vaapi", "hevc_qsv", "hevc_amf", "hevc_videotoolbox", "libx265",62+ "av1_nvenc", "av1_vaapi", "av1_qsv", "av1_amf", "av1_videotoolbox", "libsvtav1",63+ "libaom-av1",64+ ];65+66+ all.iter()67+ .filter(|name| encoders.contains(*name))68+ .map(|s| s.to_string())69+ .collect()70+}71+72+// ─── Frame Stream Handler (host side) ────────────────────────────────────────73+74+#[derive(Debug)]75+pub struct FrameStreamHandler {76+ pub connections: Arc<std::sync::atomic::AtomicU32>,77+ pub app: AppHandle,78+}79+80+impl ProtocolHandler for FrameStreamHandler {81+ async fn accept(&self, conn: Connection) -> Result<(), iroh::protocol::AcceptError> {82+ let count = self.connections.fetch_add(1, Ordering::SeqCst) + 1;83+ let _ = self.app.emit("host-connections", count);84+ eprintln!("[host] client connected: {} (total: {})", conn.remote_id(), count);85+86+ let conn_clone = conn.clone();87+ let app_clone = self.app.clone();88+ let mut stream_task =89+ tokio::spawn(async move { stream_frames(conn_clone, &app_clone).await });90+91+ tokio::select! {92+ result = &mut stream_task => {93+ if let Ok(Err(e)) = result {94+ eprintln!("[host] stream error: {}", e);95+ }96+ }97+ _ = conn.closed() => {98+ eprintln!("[host] connection closed by peer");99+ stream_task.abort();100+ }101+ }102+103+ let count = self.connections.fetch_sub(1, Ordering::SeqCst) - 1;104+ let _ = self.app.emit("host-connections", count);105+ eprintln!("[host] client disconnected (total: {})", count);106+ Ok(())107+ }108+}109+110+async fn stream_frames(conn: Connection, app: &AppHandle) -> anyhow::Result<()> {111+ let (mut send, mut recv) = conn.accept_bi().await?;112+113+ let mut start_buf = [0u8; 1];114+ recv.read_exact(&mut start_buf).await?;115+ if start_buf[0] != 1 {116+ return Ok(());117+ }118+119+ let (w, h) = {120+ let monitors = xcap::Monitor::all().context("failed to enumerate monitors")?;121+ let mon = monitors122+ .into_iter()123+ .next()124+ .ok_or_else(|| anyhow::anyhow!("no monitors found"))?;125+ let img = mon.capture_image()?;126+ (img.width() as usize, img.height() as usize)127+ };128+ eprintln!("[host] screen resolution: {}x{}", w, h);129+130+ if ffmpeg_available() {131+ let config = {132+ let app_state = app.state::<AppState>();133+ app_state.encoder_config.lock().unwrap().clone()134+ };135+ eprintln!(136+ "[host] using ffmpeg: codec={} backend={}",137+ config.codec, config.backend138+ );139+ match stream_frames_ffmpeg(&mut send, &mut recv, app, w, h, &config).await {140+ Ok(()) => return Ok(()),141+ Err(e) => {142+ eprintln!("[host] ffmpeg failed: {}, falling back to xcap+openh264", e);143+ }144+ }145+ }146+147+ eprintln!("[host] using xcap + openh264");148+ stream_frames_xcap(&mut send, &mut recv, app).await149+}150+151+// ─── ffmpeg subprocess path ───────────────────────────────────────────────────152+153+fn ffmpeg_available() -> bool {154+ std::process::Command::new("ffmpeg")155+ .arg("-version")156+ .stdout(std::process::Stdio::null())157+ .stderr(std::process::Stdio::null())158+ .status()159+ .is_ok()160+}161+162+fn resolve_encoder(codec: &str, backend: &str) -> String {163+ let encoders = std::process::Command::new("ffmpeg")164+ .arg("-encoders")165+ .output()166+ .ok()167+ .map(|o| String::from_utf8_lossy(&o.stdout).to_string())168+ .unwrap_or_default();169+170+ let has = |name: &str| encoders.contains(name);171+172+ let hw_backends: &[&str] = if cfg!(target_os = "macos") {173+ &["videotoolbox"]174+ } else if cfg!(target_os = "windows") {175+ &["nvenc", "qsv", "amf"]176+ } else {177+ &["nvenc", "vaapi"]178+ };179+180+ let codec_prefix = match codec {181+ "h265" => "hevc",182+ "av1" => "av1",183+ _ => "h264",184+ };185+186+ if backend != "auto" {187+ let name = if backend == "software" {188+ match codec {189+ "h265" => "libx265",190+ "av1" => "libsvtav1",191+ _ => "libx264",192+ }193+ } else {194+ &format!("{}_{}", codec_prefix, backend)195+ };196+ if has(name) {197+ return name.to_string();198+ }199+ }200+201+ for hw in hw_backends {202+ let name = format!("{}_{}", codec_prefix, hw);203+ if has(&name) {204+ return name;205+ }206+ }207+208+ match codec {209+ "h265" => "libx265".to_string(),210+ "av1" => {211+ if has("libsvtav1") { "libsvtav1".to_string() } else { "libaom-av1".to_string() }212+ }213+ _ => "libx264".to_string(),214+ }215+}216+217+fn codec_format(codec: &str) -> (&'static str, &'static str) {218+ match codec {219+ "h265" => ("hevc", "hevc_metadata=aud=insert"),220+ "av1" => ("av1", "av1_metadata=td=insert"),221+ _ => ("h264", "h264_metadata=aud=insert"),222+ }223+}224+225+// ─── Frame / keyframe detection ───────────────────────────────────────────────226+227+pub fn find_next_frame_delim(data: &[u8], from: usize, codec: &str) -> Option<usize> {228+ let mut i = from;229+ while i + 4 < data.len() {230+ if data[i] == 0 && data[i + 1] == 0 {231+ if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {232+ // 4-byte start code233+ if codec == "av1" {234+ if i + 4 < data.len() && (data[i + 4] & 0x38) == 0x10 {235+ return Some(i);236+ }237+ } else if codec == "h265" {238+ if i + 4 < data.len() && ((data[i + 4] >> 1) & 0x3f) == 35 {239+ return Some(i);240+ }241+ } else if i + 4 < data.len() && (data[i + 4] & 0x1f) == 9 {242+ return Some(i);243+ }244+ i += 4;245+ continue;246+ }247+ if data[i + 2] == 1 {248+ // 3-byte start code249+ if codec == "av1" {250+ if i + 3 < data.len() && (data[i + 3] & 0x38) == 0x10 {251+ return Some(i);252+ }253+ } else if codec == "h265" {254+ if i + 3 < data.len() && ((data[i + 3] >> 1) & 0x3f) == 35 {255+ return Some(i);256+ }257+ } else if i + 3 < data.len() && (data[i + 3] & 0x1f) == 9 {258+ return Some(i);259+ }260+ i += 3;261+ continue;262+ }263+ }264+ i += 1;265+ }266+ None267+}268+269+pub fn frame_is_keyframe(data: &[u8], codec: &str) -> bool {270+ match codec {271+ "h265" => {272+ let mut i = 0;273+ while i + 3 < data.len() {274+ if data[i] == 0 && data[i + 1] == 0 {275+ let (sc_len, nal_off) =276+ if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {277+ (4, 4)278+ } else if data[i + 2] == 1 {279+ (3, 3)280+ } else {281+ i += 1;282+ continue;283+ };284+ if i + nal_off < data.len() {285+ let nal_type = (data[i + nal_off] >> 1) & 0x3f;286+ if nal_type == 19 || nal_type == 20 || nal_type == 32 || nal_type == 33 {287+ return true;288+ }289+ }290+ i += sc_len;291+ } else {292+ i += 1;293+ }294+ }295+ false296+ }297+ "av1" => {298+ // AV1: scan OBUs for OBU_SEQUENCE_HEADER (type 1), which only appears in keyframes.299+ // OBU header: forbidden(1) | obu_type(4) | extension_flag(1) | has_size(1) | reserved(1)300+ let mut i = 0;301+ while i < data.len() {302+ let header_byte = data[i];303+ let obu_type = (header_byte >> 3) & 0x0F;304+ let has_extension = (header_byte >> 2) & 1 == 1;305+ let has_size = (header_byte >> 1) & 1 == 1;306+ i += 1;307+ if has_extension {308+ if i >= data.len() {309+ break;310+ }311+ i += 1;312+ }313+ let obu_size = if has_size {314+ let mut size: usize = 0;315+ let mut shift = 0usize;316+ loop {317+ if i >= data.len() {318+ break;319+ }320+ let b = data[i];321+ i += 1;322+ size |= ((b & 0x7F) as usize) << shift;323+ shift += 7;324+ if (b & 0x80) == 0 {325+ break;326+ }327+ }328+ size329+ } else {330+ data.len().saturating_sub(i)331+ };332+ if obu_type == 1 {333+ // OBU_SEQUENCE_HEADER — this temporal unit is a keyframe334+ return true;335+ }336+ i = i.saturating_add(obu_size);337+ }338+ false339+ }340+ _ => {341+ // H.264: IDR (type 5) or SPS (type 7)342+ let mut i = 0;343+ while i + 3 < data.len() {344+ if data[i] == 0 && data[i + 1] == 0 {345+ let (sc_len, nal_off) =346+ if data[i + 2] == 0 && i + 3 < data.len() && data[i + 3] == 1 {347+ (4, 4)348+ } else if data[i + 2] == 1 {349+ (3, 3)350+ } else {351+ i += 1;352+ continue;353+ };354+ if i + nal_off < data.len() {355+ let nal_type = data[i + nal_off] & 0x1f;356+ if nal_type == 5 || nal_type == 7 {357+ return true;358+ }359+ }360+ i += sc_len;361+ } else {362+ i += 1;363+ }364+ }365+ false366+ }367+ }368+}369+370+// ─── ffmpeg streaming ─────────────────────────────────────────────────────────371+372+async fn stream_frames_ffmpeg(373+ send: &mut iroh::endpoint::SendStream,374+ recv: &mut iroh::endpoint::RecvStream,375+ app: &AppHandle,376+ width: usize,377+ height: usize,378+ config: &EncoderConfig,379+) -> anyhow::Result<()> {380+ use std::process::Stdio;381+ use tokio::io::AsyncReadExt;382+ use tokio::process::Command as TokioCommand;383+384+ let encoder = resolve_encoder(&config.codec, &config.backend);385+ let (fmt, bsf) = codec_format(&config.codec);386+ eprintln!(387+ "[host] ffmpeg: encoder={} format={} bsf={} bitrate={} fps={} gop={}",388+ encoder, fmt, bsf, config.bitrate, config.framerate, config.gop389+ );390+391+ let _ = app.emit("host-codec", &config.codec);392+393+ let mut cmd = TokioCommand::new("ffmpeg");394+ cmd.stdin(Stdio::null())395+ .stdout(Stdio::piped())396+ .stderr(Stdio::piped())397+ .kill_on_drop(true);398+399+ if cfg!(target_os = "macos") {400+ cmd.arg("-f").arg("avfoundation")401+ .arg("-framerate").arg(config.framerate.to_string())402+ .arg("-capture_cursor").arg("1")403+ .arg("-i").arg("1:");404+ } else if cfg!(target_os = "windows") {405+ cmd.arg("-f").arg("gdigrab")406+ .arg("-framerate").arg(config.framerate.to_string())407+ .arg("-i").arg("desktop");408+ } else {409+ let display = std::env::var("DISPLAY").unwrap_or_else(|_| ":0".to_string());410+ cmd.arg("-f").arg("x11grab")411+ .arg("-framerate").arg(config.framerate.to_string())412+ .arg("-video_size").arg(format!("{}x{}", width, height))413+ .arg("-i").arg(&display);414+ }415+416+ match encoder.as_str() {417+ e if e.ends_with("_nvenc") => {418+ cmd.arg("-c:v").arg(&encoder)419+ .arg("-preset").arg("p1")420+ .arg("-tune").arg("ll")421+ .arg("-rc").arg("cbr")422+ .arg("-b:v").arg(&config.bitrate);423+ }424+ e if e.ends_with("_vaapi") => {425+ cmd.arg("-c:v").arg(&encoder)426+ .arg("-rc_mode").arg("CBR")427+ .arg("-b:v").arg(&config.bitrate);428+ }429+ e if e.ends_with("_videotoolbox") => {430+ cmd.arg("-c:v").arg(&encoder)431+ .arg("-realtime").arg("1")432+ .arg("-b:v").arg(&config.bitrate);433+ }434+ e if e.ends_with("_qsv") => {435+ cmd.arg("-c:v").arg(&encoder)436+ .arg("-preset").arg("veryfast")437+ .arg("-b:v").arg(&config.bitrate);438+ }439+ e if e.ends_with("_amf") => {440+ cmd.arg("-c:v").arg(&encoder)441+ .arg("-usage").arg("ultralowlatency")442+ .arg("-b:v").arg(&config.bitrate);443+ }444+ "libx264" => {445+ cmd.arg("-c:v").arg("libx264")446+ .arg("-preset").arg("ultrafast")447+ .arg("-tune").arg("zerolatency")448+ .arg("-b:v").arg(&config.bitrate);449+ }450+ "libx265" => {451+ cmd.arg("-c:v").arg("libx265")452+ .arg("-preset").arg("ultrafast")453+ .arg("-tune").arg("zerolatency")454+ .arg("-x265-params").arg("keyint=30:min-keyint=30")455+ .arg("-b:v").arg(&config.bitrate);456+ }457+ "libsvtav1" => {458+ cmd.arg("-c:v").arg("libsvtav1")459+ .arg("-preset").arg("8")460+ .arg("-crf").arg("35")461+ .arg("-g").arg(config.gop.to_string());462+ }463+ "libaom-av1" => {464+ cmd.arg("-c:v").arg("libaom-av1")465+ .arg("-cpu-used").arg("8")466+ .arg("-crf").arg("35")467+ .arg("-b:v").arg(&config.bitrate);468+ }469+ _ => {470+ return Err(anyhow::anyhow!("unsupported encoder: {}", encoder));471+ }472+ }473+474+ if encoder != "libx265" && encoder != "libsvtav1" {475+ cmd.arg("-g").arg(config.gop.to_string());476+ }477+ cmd.arg("-bf").arg("0")478+ .arg("-pix_fmt").arg("yuv420p")479+ .arg("-bsf:v").arg(bsf)480+ .arg("-f").arg(fmt)481+ .arg("-");482+483+ let mut child = cmd.spawn().context("failed to spawn ffmpeg")?;484+ let mut stdout = child.stdout.take().context("no stdout from ffmpeg")?;485+ let mut stderr = child.stderr.take();486+ let mut stderr_buf = String::new();487+488+ let mut buf: Vec<u8> = Vec::with_capacity(65536);489+ let mut frame_start: usize = 0;490+ let mut first_delim_seen = false;491+ let mut frame_count = 0u32;492+ let start = Instant::now();493+ let mut last_frame_time = Instant::now();494+495+ loop {496+ let mut tmp = [0u8; 16384];497+ let n = stdout.read(&mut tmp).await?;498+ if n == 0 {499+ if let Some(ref mut stderr) = stderr {500+ let _ = stderr.read_to_string(&mut stderr_buf).await;501+ }502+ eprintln!("[host] ffmpeg stdout closed");503+ if !stderr_buf.is_empty() {504+ eprintln!("[host] ffmpeg stderr: {}", stderr_buf);505+ }506+ break;507+ }508+ buf.extend_from_slice(&tmp[..n]);509+510+ loop {511+ let search_from = if first_delim_seen { frame_start + 6 } else { 0 };512+ match find_next_frame_delim(&buf, search_from, &config.codec) {513+ Some(delim_pos) => {514+ if first_delim_seen {515+ let frame_data = &buf[frame_start..delim_pos];516+ let is_keyframe = frame_is_keyframe(frame_data, &config.codec);517+ let frame_size = frame_data.len();518+519+ let now = Instant::now();520+ let frame_ms =521+ now.duration_since(last_frame_time).as_secs_f64() * 1000.0;522+ last_frame_time = now;523+524+ let header = [525+ (width as u32).to_be_bytes(),526+ (height as u32).to_be_bytes(),527+ (frame_size as u32).to_be_bytes(),528+ ]529+ .concat();530+ let kf_byte = if is_keyframe { 1u8 } else { 0u8 };531+ let codec_byte = codec_to_byte(&config.codec);532+533+ send.write_all(&header).await?;534+ send.write_all(&[kf_byte, codec_byte]).await?;535+ send.write_all(frame_data).await?;536+537+ frame_count += 1;538+ let elapsed = start.elapsed();539+ let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);540+541+ let _ = app.emit(542+ "host-encode-stats",543+ serde_json::json!({544+ "frame": frame_count,545+ "encode_ms": (frame_ms * 10.0).round() / 10.0,546+ "capture_ms": 0.0,547+ "size_bytes": frame_size,548+ "fps": (fps * 10.0).round() / 10.0,549+ "keyframe": is_keyframe,550+ "encoder": encoder.clone(),551+ }),552+ );553+554+ eprintln!(555+ "[host] frame={} {}x{} {}={}B kf={} fps={:.1} ftime={:.1}ms",556+ frame_count, width, height, config.codec, frame_size,557+ is_keyframe, fps, frame_ms558+ );559+ }560+ frame_start = delim_pos;561+ first_delim_seen = true;562+ }563+ None => break,564+ }565+ }566+567+ if frame_start > 0 && frame_start >= buf.len() / 2 {568+ buf.drain(..frame_start);569+ frame_start = 0;570+ }571+572+ match tokio::time::timeout(Duration::from_millis(1), recv.read(&mut [0u8; 1])).await {573+ Ok(Ok(Some(_))) | Ok(Err(_)) => {574+ eprintln!("[host] client disconnected");575+ break;576+ }577+ _ => {}578+ }579+ }580+581+ let _ = child.kill().await;582+ Ok(())583+}584+585+// ─── xcap + openh264 fallback path ───────────────────────────────────────────586+587+async fn stream_frames_xcap(588+ send: &mut iroh::endpoint::SendStream,589+ recv: &mut iroh::endpoint::RecvStream,590+ app: &AppHandle,591+) -> anyhow::Result<()> {592+ let monitors = xcap::Monitor::all().context("failed to enumerate monitors")?;593+ let monitor = monitors594+ .into_iter()595+ .next()596+ .ok_or_else(|| anyhow::anyhow!("no monitors found"))?;597+598+ let mut encoder = Encoder::new().context("failed to create H.264 encoder")?;599+ let mut frame_count = 0u32;600+ let start = Instant::now();601+602+ loop {603+ let capture_start = Instant::now();604+ let image = monitor.capture_image()?;605+ let capture_ms = capture_start.elapsed().as_secs_f64() * 1000.0;606+607+ let rgb_image = image::DynamicImage::ImageRgba8(image).to_rgb8();608+ let (w, h) = (rgb_image.width() as usize, rgb_image.height() as usize);609+610+ let encode_start = Instant::now();611+ let rgb_source = RgbSliceU8::new(rgb_image.as_raw(), (w, h));612+ let yuv = YUVBuffer::from_rgb8_source(rgb_source);613+ let (h264_data, is_keyframe) = {614+ let bitstream = encoder.encode(&yuv).context("H.264 encode failed")?;615+ let kf = matches!(616+ bitstream.frame_type(),617+ openh264::encoder::FrameType::I | openh264::encoder::FrameType::IDR618+ );619+ (bitstream.to_vec(), kf)620+ };621+ let encode_ms = encode_start.elapsed().as_secs_f64() * 1000.0;622+ let h264_size = h264_data.len();623+624+ let header = [625+ (w as u32).to_be_bytes(),626+ (h as u32).to_be_bytes(),627+ (h264_size as u32).to_be_bytes(),628+ ]629+ .concat();630+ let kf_byte = if is_keyframe { 1u8 } else { 0u8 };631+632+ send.write_all(&header).await?;633+ send.write_all(&[kf_byte, 0u8]).await?;634+ send.write_all(&h264_data).await?;635+636+ frame_count += 1;637+ let elapsed = start.elapsed();638+ let fps = frame_count as f64 / elapsed.as_secs_f64().max(0.001);639+ eprintln!(640+ "[host] frame={} {}x{} h264={}B kf={} fps={:.1} enc={:.1}ms cap={:.1}ms",641+ frame_count, w, h, h264_size, is_keyframe, fps, encode_ms, capture_ms642+ );643+644+ let _ = app.emit(645+ "host-encode-stats",646+ serde_json::json!({647+ "frame": frame_count,648+ "encode_ms": (encode_ms * 10.0).round() / 10.0,649+ "capture_ms": (capture_ms * 10.0).round() / 10.0,650+ "size_bytes": h264_size,651+ "fps": (fps * 10.0).round() / 10.0,652+ "keyframe": is_keyframe,653+ }),654+ );655+656+ match tokio::time::timeout(Duration::from_millis(1), recv.read(&mut [0u8; 1])).await {657+ Ok(Ok(Some(_))) | Ok(Err(_)) => {658+ eprintln!("[host] client disconnected");659+ break;660+ }661+ _ => {}662+ }663+664+ tokio::task::yield_now().await;665+ }666+667+ Ok(())668+}
M src-tauri/src/main.rs +33 -18
@@ -9,25 +9,40 @@ fn main() {
99 tauri::Builder::default()1010 .plugin(tauri_plugin_log::Builder::default().build())1111 .manage(AppState::default())12+ .setup(|app| {13+ use tauri::Manager;14+ if let Ok(data_dir) = app.path().app_data_dir() {15+ let path = data_dir.join("encoder_config.json");16+ if let Ok(contents) = std::fs::read_to_string(&path) {17+ if let Ok(config) =18+ serde_json::from_str::<commands::EncoderConfig>(&contents)19+ {20+ let state = app.state::<commands::AppState>();21+ *state.encoder_config.lock().unwrap() = config;22+ }23+ }24+ }25+ Ok(())26+ })1227 .invoke_handler(tauri::generate_handler![13- commands::fido_device_info,14- commands::fido_pin_retries,15- commands::titan_derive_identity,16- commands::is_daemon_mode,17- commands::set_webcodecs_available,18- commands::is_webcodecs_available,19- commands::get_encoder_config,20- commands::set_encoder_config,21- commands::detect_available_encoders,22- commands::host_registration_status,23- commands::titan_register_host,24- commands::host_unregister,25- commands::iroh_host_start,26- commands::iroh_host_stop,27- commands::iroh_host_status,28- commands::iroh_client_connect,29- commands::iroh_client_disconnect,30- commands::iroh_client_send_input,28+ commands::auth::fido_device_info,29+ commands::auth::fido_pin_retries,30+ commands::auth::titan_derive_identity,31+ commands::state::is_daemon_mode,32+ commands::state::set_webcodecs_available,33+ commands::state::is_webcodecs_available,34+ commands::streaming::get_encoder_config,35+ commands::streaming::set_encoder_config,36+ commands::streaming::detect_available_encoders,37+ commands::auth::host_registration_status,38+ commands::auth::titan_register_host,39+ commands::auth::host_unregister,40+ commands::network::iroh_host_start,41+ commands::network::iroh_host_stop,42+ commands::network::iroh_host_status,43+ commands::network::iroh_client_connect,44+ commands::network::iroh_client_disconnect,45+ commands::network::iroh_client_send_input,3146 ])3247 .run(tauri::generate_context!())3348 .expect("error while running tauri application");
M src/ui/index.html +46 -4
@@ -414,8 +414,8 @@
414414 <!-- Tap titan overlay -->415415 <div class="overlay-screen hidden" id="tap-overlay" style="z-index: 200;">416416 <div class="overlay-content">417- <h1>tap titan</h1>418- <p>Touch the sensor on your key when it blinks.</p>417+ <h1 id="tap-overlay-title">tap titan</h1>418+ <p id="tap-overlay-desc">Touch the sensor on your key when it blinks.</p>419419 <div class="overlay-status pending" id="tap-status">waiting...</div>420420 </div>421421 </div>
@@ -424,7 +424,7 @@
424424 <div class="overlay-screen hidden" id="intro">425425 <div class="overlay-content">426426 <h1>keyhome</h1>427- <p id="intro-desc">Type PIN, then c=connect · r=register · h=host</p>427+ <p id="intro-desc">Type PIN</p>428428 429429 <!-- Unregistered: register or connect -->430430 <div id="intro-unregistered">
@@ -622,7 +622,12 @@
622622 }, 50);623623 }624624 function hideIntro() { document.getElementById('intro').classList.add('hidden'); }625- function showTap() { document.getElementById('tap-overlay').classList.remove('hidden'); }625+ function showTap() {626+ document.getElementById('tap-overlay-title').textContent = 'tap titan';627+ document.getElementById('tap-overlay-desc').textContent = 'Touch the sensor on your key when it blinks.';628+ document.getElementById('tap-status').textContent = 'waiting...';629+ document.getElementById('tap-overlay').classList.remove('hidden');630+ }626631 function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }627632 628633 // ─── Registration ───────────────────────────────────────────────────
@@ -715,6 +720,11 @@
715720 document.getElementById('pin-input').value = pin;716721 await connectHost();717722 hideTap();723+ if (!connected) {724+ showIntro();725+ status.className = 'overlay-status err';726+ status.textContent = 'connection failed';727+ }718728 }719729 720730 async function introConnectReg() {
@@ -730,6 +740,11 @@
730740 document.getElementById('pin-input').value = pin;731741 await connectHost();732742 hideTap();743+ if (!connected) {744+ showIntro();745+ status.className = 'overlay-status err';746+ status.textContent = 'connection failed';747+ }733748 }734749 735750 async function introHost() {
@@ -1582,6 +1597,33 @@
15821597 }15831598 });15841599 1600+ // ─── PIN shortcut hints ─────────────────────────────────────────────1601+ function updateIntroDes() {1602+ const unregVisible = document.getElementById('intro-unregistered').style.display !== 'none';1603+ const pinEl = unregVisible1604+ ? document.getElementById('intro-pin-unreg')1605+ : document.getElementById('intro-pin-reg');1606+ const desc = document.getElementById('intro-desc');1607+ if (!pinEl || !pinEl.value.trim()) {1608+ desc.textContent = 'Type PIN';1609+ return;1610+ }1611+ if (unregVisible) {1612+ desc.innerHTML = 'Type PIN · <u>c</u>=connect · <u>r</u>=register';1613+ } else {1614+ desc.innerHTML = 'Type PIN · <u>c</u>=connect · <u>h</u>=host';1615+ }1616+ }1617+ document.getElementById('intro-pin-unreg').addEventListener('input', updateIntroDes);1618+ document.getElementById('intro-pin-reg').addEventListener('input', updateIntroDes);1619+1620+ // ─── Fido done — key tapped, now waiting for relay ──────────────────1621+ listen('fido-done', () => {1622+ document.getElementById('tap-overlay-title').textContent = 'connecting';1623+ document.getElementById('tap-overlay-desc').textContent = 'Key recognised. Waiting for host to respond.';1624+ document.getElementById('tap-status').textContent = 'please wait...';1625+ });1626+15851627 // ─── Init ───────────────────────────────────────────────────────────15861628 checkRegistration();15871629 scanFido();