From e6f77a78a7451de4407d16bc05531d2b42ecfea2 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 9 Apr 2026 11:17:55 +0400 Subject: [PATCH] feat(desktop): split main.rs into lib.rs for Tauri Mobile (Android/iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tauri 2.x Mobile links the app as a cdylib loaded from a Java Activity, so all of the Builder/command code has to live in a library crate. Move the existing logic verbatim into src/lib.rs::run() and reduce src/main.rs to a two-line desktop entry point that calls into it. Cargo.toml gets a [lib] section (crate-types: staticlib + cdylib + rlib, named wzp_desktop_lib) and the wzp-client dependency — which pulls CPAL + VoiceProcessingIO — is moved behind cfg(not(target_os = "android")) so the Android cdylib doesn't need an audio backend yet. Engine-backed Tauri commands (connect/disconnect/toggle_mic/toggle_speaker/get_status) get Android stubs that return clear "not yet wired" errors. The signaling commands (register_signal/place_call/answer_call/get_signal_status/ ping_relay/get_identity) are platform-independent and unchanged. Also: get_identity / register_signal now auto-create the seed if missing instead of erroring with "connect to a room first", and the identity dir resolves to /data/data/com.wzp.phone/files/.wzp on Android (proper app-internal storage) vs \$HOME/.wzp on desktop. Side note: src/main.rs was previously untracked — desktop builds were working only because it existed in the local worktree. This commit fixes that too. Step 1 of the Android rewrite plan (tauri-mobile scaffold). No audio yet. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/Cargo.toml | 16 +- desktop/src-tauri/src/lib.rs | 465 ++++++++++++++++++++++++++++++++++ desktop/src-tauri/src/main.rs | 10 + 3 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/lib.rs create mode 100644 desktop/src-tauri/src/main.rs diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a8663e7..26bc58f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -5,6 +5,16 @@ edition = "2024" description = "WarzonePhone Desktop — encrypted VoIP client" default-run = "wzp-desktop" +# Library target — required for Tauri mobile (Android/iOS link the app as a cdylib) +# and also used by the desktop binary below. +[lib] +name = "wzp_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[[bin]] +name = "wzp-desktop" +path = "src/main.rs" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -19,12 +29,16 @@ tracing-subscriber = "0.3" anyhow = "1" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } -# WarzonePhone crates +# WarzonePhone crates — protocol layer is platform-independent wzp-proto = { path = "../../crates/wzp-proto" } wzp-codec = { path = "../../crates/wzp-codec" } wzp-fec = { path = "../../crates/wzp-fec" } wzp-crypto = { path = "../../crates/wzp-crypto" } wzp-transport = { path = "../../crates/wzp-transport" } + +# wzp-client pulls CPAL + (on macOS) VoiceProcessingIO — desktop only. +# Android gets an oboe/AAudio backend in Step 3 of the Tauri mobile rewrite. +[target.'cfg(not(target_os = "android"))'.dependencies] wzp-client = { path = "../../crates/wzp-client", features = ["audio", "vpio"] } # Platform-specific diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..13a417d --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,465 @@ +// WarzonePhone Tauri backend — shared between desktop (macOS/Windows/Linux) +// and Tauri mobile (Android/iOS). Platform-specific audio is cfg-gated. + +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +// CPAL-backed audio engine — desktop only. On Android we'll plug in an +// oboe/AAudio backend in a later step. +#[cfg(not(target_os = "android"))] +mod engine; + +#[cfg(not(target_os = "android"))] +use engine::CallEngine; + +use serde::Serialize; +use std::sync::Arc; +use tauri::Emitter; +use tokio::sync::Mutex; +use wzp_proto::MediaTransport; + +#[derive(Clone, Serialize)] +struct CallEvent { + kind: String, + message: String, +} + +#[derive(Clone, Serialize)] +struct Participant { + fingerprint: String, + alias: Option, + relay_label: Option, +} + +#[derive(Clone, Serialize)] +struct CallStatus { + active: bool, + mic_muted: bool, + spk_muted: bool, + participants: Vec, + encode_fps: u64, + recv_fps: u64, + audio_level: u32, + call_duration_secs: f64, + fingerprint: String, + tx_codec: String, + rx_codec: String, +} + +struct AppState { + #[cfg(not(target_os = "android"))] + engine: Mutex>, + signal: Arc>, +} + +/// Ping result with RTT and server identity hash. +#[derive(Clone, Serialize)] +struct PingResult { + rtt_ms: u32, + /// Server identity: SHA-256 of the QUIC peer certificate, hex-encoded. + server_fingerprint: String, +} + +/// Ping a relay to check if it's online, measure RTT, and get server identity. +#[tauri::command] +async fn ping_relay(relay: String) -> Result { + let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?; + let _ = rustls::crypto::ring::default_provider().install_default(); + let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap(); + let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?; + let client_cfg = wzp_transport::client_config(); + + let start = std::time::Instant::now(); + let conn_result = tokio::time::timeout( + std::time::Duration::from_secs(3), + wzp_transport::connect(&endpoint, addr, "ping", client_cfg), + ) + .await; + + // Always close endpoint to prevent resource leaks + endpoint.close(0u32.into(), b"done"); + + match conn_result { + Ok(Ok(conn)) => { + let rtt_ms = start.elapsed().as_millis() as u32; + + let server_fingerprint = conn + .peer_identity() + .and_then(|id| id.downcast::>().ok()) + .and_then(|certs| certs.first().map(|c| { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + c.as_ref().hash(&mut hasher); + let h = hasher.finish(); + format!("{h:016x}") + })) + .unwrap_or_else(|| { + format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64) + }); + + conn.close(0u32.into(), b"ping"); + Ok(PingResult { rtt_ms, server_fingerprint }) + } + Ok(Err(e)) => Err(format!("{e}")), + Err(_) => Err("timeout (3s)".into()), + } +} + +/// Return the directory where identity/config should live. +/// +/// Desktop: `$HOME/.wzp` +/// Android: `/data/data/com.wzp.phone/files/.wzp` (app-internal storage) +fn identity_dir() -> std::path::PathBuf { + #[cfg(target_os = "android")] + { + // Android app-internal storage. The package id must match tauri.conf.json. + return std::path::PathBuf::from("/data/data/com.wzp.phone/files/.wzp"); + } + #[cfg(not(target_os = "android"))] + { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + std::path::PathBuf::from(home).join(".wzp") + } +} + +fn identity_path() -> std::path::PathBuf { + identity_dir().join("identity") +} + +/// Load the persisted seed, or generate-and-persist a new one if missing. +fn load_or_create_seed() -> Result { + let path = identity_path(); + if path.exists() { + let hex = std::fs::read_to_string(&path).map_err(|e| format!("read identity: {e}"))?; + return wzp_crypto::Seed::from_hex(hex.trim()).map_err(|e| format!("{e}")); + } + let seed = wzp_crypto::Seed::generate(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create identity dir: {e}"))?; + } + let hex: String = seed.0.iter().map(|b| format!("{b:02x}")).collect(); + std::fs::write(&path, hex).map_err(|e| format!("write identity: {e}"))?; + Ok(seed) +} + +/// Read fingerprint, generating a fresh identity if none exists yet. +#[tauri::command] +fn get_identity() -> Result { + let seed = load_or_create_seed()?; + Ok(seed.derive_identity().public_identity().fingerprint.to_string()) +} + +#[cfg(not(target_os = "android"))] +#[tauri::command] +async fn connect( + state: tauri::State<'_, Arc>, + app: tauri::AppHandle, + relay: String, + room: String, + alias: String, + os_aec: bool, + quality: String, +) -> Result { + let mut engine_lock = state.engine.lock().await; + if engine_lock.is_some() { + return Err("already connected".into()); + } + + let app_clone = app.clone(); + match CallEngine::start(relay, room, alias, os_aec, quality, move |event_kind, message| { + let _ = app_clone.emit( + "call-event", + CallEvent { + kind: event_kind.to_string(), + message: message.to_string(), + }, + ); + }) + .await + { + Ok(eng) => { + *engine_lock = Some(eng); + Ok("connected".into()) + } + Err(e) => Err(format!("{e}")), + } +} + +#[cfg(not(target_os = "android"))] +#[tauri::command] +async fn disconnect(state: tauri::State<'_, Arc>) -> Result { + let mut engine_lock = state.engine.lock().await; + if let Some(engine) = engine_lock.take() { + engine.stop().await; + Ok("disconnected".into()) + } else { + Err("not connected".into()) + } +} + +#[cfg(not(target_os = "android"))] +#[tauri::command] +async fn toggle_mic(state: tauri::State<'_, Arc>) -> Result { + let engine_lock = state.engine.lock().await; + if let Some(ref engine) = *engine_lock { + Ok(engine.toggle_mic()) + } else { + Err("not connected".into()) + } +} + +#[cfg(not(target_os = "android"))] +#[tauri::command] +async fn toggle_speaker(state: tauri::State<'_, Arc>) -> Result { + let engine_lock = state.engine.lock().await; + if let Some(ref engine) = *engine_lock { + Ok(engine.toggle_speaker()) + } else { + Err("not connected".into()) + } +} + +#[cfg(not(target_os = "android"))] +#[tauri::command] +async fn get_status(state: tauri::State<'_, Arc>) -> Result { + let engine_lock = state.engine.lock().await; + if let Some(ref engine) = *engine_lock { + let status = engine.status().await; + Ok(CallStatus { + active: true, + mic_muted: status.mic_muted, + spk_muted: status.spk_muted, + participants: status + .participants + .into_iter() + .map(|p| Participant { + fingerprint: p.fingerprint, + alias: p.alias, + relay_label: p.relay_label, + }) + .collect(), + encode_fps: status.frames_sent, + recv_fps: status.frames_received, + audio_level: status.audio_level, + call_duration_secs: status.call_duration_secs, + fingerprint: status.fingerprint, + tx_codec: status.tx_codec, + rx_codec: status.rx_codec, + }) + } else { + Ok(CallStatus { + active: false, + mic_muted: false, + spk_muted: false, + participants: vec![], + encode_fps: 0, + recv_fps: 0, + audio_level: 0, + call_duration_secs: 0.0, + fingerprint: String::new(), + tx_codec: String::new(), + rx_codec: String::new(), + }) + } +} + +// ─── Android stubs for engine-backed commands ──────────────────────────────── +// +// Step 1 of the Android rewrite: signal-only. Audio is wired up in Step 3. +// These keep the JS frontend happy (same `invoke` surface) without pulling +// in CPAL, which doesn't support Android. + +#[cfg(target_os = "android")] +#[tauri::command] +async fn connect( + _state: tauri::State<'_, Arc>, + _app: tauri::AppHandle, + _relay: String, + _room: String, + _alias: String, + _os_aec: bool, + _quality: String, +) -> Result { + Err("audio backend not yet wired on Android (step 3)".into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +async fn disconnect(_state: tauri::State<'_, Arc>) -> Result { + Ok("not connected".into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +async fn toggle_mic(_state: tauri::State<'_, Arc>) -> Result { + Err("not connected".into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +async fn toggle_speaker(_state: tauri::State<'_, Arc>) -> Result { + Err("not connected".into()) +} + +#[cfg(target_os = "android")] +#[tauri::command] +async fn get_status(_state: tauri::State<'_, Arc>) -> Result { + Ok(CallStatus { + active: false, + mic_muted: false, + spk_muted: false, + participants: vec![], + encode_fps: 0, + recv_fps: 0, + audio_level: 0, + call_duration_secs: 0.0, + fingerprint: String::new(), + tx_codec: String::new(), + rx_codec: String::new(), + }) +} + +// ─── Signaling commands — platform independent ─────────────────────────────── + +struct SignalState { + transport: Option>, + fingerprint: String, + signal_status: String, + incoming_call_id: Option, + incoming_caller_fp: Option, + incoming_caller_alias: Option, +} + +#[tauri::command] +async fn register_signal( + state: tauri::State<'_, Arc>, + app: tauri::AppHandle, + relay: String, +) -> Result { + use wzp_proto::SignalMessage; + + let addr: std::net::SocketAddr = relay.parse().map_err(|e| format!("bad address: {e}"))?; + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Load or create seed automatically — no need to "connect to a room first" + let seed = load_or_create_seed()?; + let pub_id = seed.derive_identity().public_identity(); + let fp = pub_id.fingerprint.to_string(); + let identity_pub = *pub_id.signing.as_bytes(); + + let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap(); + let endpoint = wzp_transport::create_endpoint(bind, None).map_err(|e| format!("{e}"))?; + let conn = wzp_transport::connect(&endpoint, addr, "_signal", wzp_transport::client_config()) + .await.map_err(|e| format!("{e}"))?; + let transport = Arc::new(wzp_transport::QuinnTransport::new(conn)); + + transport.send_signal(&SignalMessage::RegisterPresence { + identity_pub, signature: vec![], alias: None, + }).await.map_err(|e| format!("{e}"))?; + + match transport.recv_signal().await.map_err(|e| format!("{e}"))? { + Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {} + _ => return Err("registration failed".into()), + } + + { let mut sig = state.signal.lock().await; sig.transport = Some(transport.clone()); sig.fingerprint = fp.clone(); sig.signal_status = "registered".into(); } + + let signal_state = Arc::clone(&state.signal); + let app_clone = app.clone(); + tokio::spawn(async move { + loop { + match transport.recv_signal().await { + Ok(Some(SignalMessage::CallRinging { call_id })) => { + let mut sig = signal_state.lock().await; sig.signal_status = "ringing".into(); + let _ = app_clone.emit("signal-event", serde_json::json!({"type":"ringing","call_id":call_id})); + } + Ok(Some(SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. })) => { + let mut sig = signal_state.lock().await; sig.signal_status = "incoming".into(); + sig.incoming_call_id = Some(call_id.clone()); sig.incoming_caller_fp = Some(caller_fingerprint.clone()); sig.incoming_caller_alias = caller_alias.clone(); + let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias})); + } + Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr })) => { + let mut sig = signal_state.lock().await; sig.signal_status = "setup".into(); + let _ = app_clone.emit("signal-event", serde_json::json!({"type":"setup","call_id":call_id,"room":room,"relay_addr":relay_addr})); + } + Ok(Some(SignalMessage::Hangup { .. })) => { + let mut sig = signal_state.lock().await; sig.signal_status = "registered".into(); sig.incoming_call_id = None; + let _ = app_clone.emit("signal-event", serde_json::json!({"type":"hangup"})); + } + Ok(None) | Err(_) => break, + _ => {} + } + } + let mut sig = signal_state.lock().await; sig.signal_status = "idle".into(); sig.transport = None; + }); + Ok(fp) +} + +#[tauri::command] +async fn place_call(state: tauri::State<'_, Arc>, target_fp: String) -> Result<(), String> { + use wzp_proto::SignalMessage; + let sig = state.signal.lock().await; + let transport = sig.transport.as_ref().ok_or("not registered")?; + let call_id = format!("{:016x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); + transport.send_signal(&SignalMessage::DirectCallOffer { + caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp, + call_id, identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![], + supported_profiles: vec![wzp_proto::QualityProfile::GOOD], + }).await.map_err(|e| format!("{e}"))?; + Ok(()) +} + +#[tauri::command] +async fn answer_call(state: tauri::State<'_, Arc>, call_id: String, mode: i32) -> Result<(), String> { + use wzp_proto::SignalMessage; + let sig = state.signal.lock().await; + let transport = sig.transport.as_ref().ok_or("not registered")?; + let accept_mode = match mode { 0 => wzp_proto::CallAcceptMode::Reject, 1 => wzp_proto::CallAcceptMode::AcceptTrusted, _ => wzp_proto::CallAcceptMode::AcceptGeneric }; + transport.send_signal(&SignalMessage::DirectCallAnswer { + call_id, accept_mode, identity_pub: None, ephemeral_pub: None, signature: None, + chosen_profile: Some(wzp_proto::QualityProfile::GOOD), + }).await.map_err(|e| format!("{e}"))?; + Ok(()) +} + +#[tauri::command] +async fn get_signal_status(state: tauri::State<'_, Arc>) -> Result { + let sig = state.signal.lock().await; + Ok(serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp})) +} + +// ─── App entry point ───────────────────────────────────────────────────────── + +/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile +/// entry point below. +pub fn run() { + tracing_subscriber::fmt().init(); + + let state = Arc::new(AppState { + #[cfg(not(target_os = "android"))] + engine: Mutex::new(None), + signal: Arc::new(Mutex::new(SignalState { + transport: None, fingerprint: String::new(), signal_status: "idle".into(), + incoming_call_id: None, incoming_caller_fp: None, incoming_caller_alias: None, + })), + }); + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .manage(state) + .invoke_handler(tauri::generate_handler![ + ping_relay, get_identity, connect, disconnect, toggle_mic, toggle_speaker, get_status, + register_signal, place_call, answer_call, get_signal_status, + ]) + .run(tauri::generate_context!()) + .expect("error while running WarzonePhone"); +} + +/// Tauri mobile entry point (Android/iOS). On desktop this is a no-op — +/// `main.rs` calls `run()` directly. +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn mobile_entry() { + run(); +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..0d5fee1 --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,10 @@ +// Desktop binary entry point. All logic lives in `lib.rs` so the same +// code can be built as a cdylib for Android/iOS via `cargo tauri android build`. +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +fn main() { + wzp_desktop_lib::run(); +}