Files
wz-phone/desktop/src-tauri/src/lib.rs
Siavash Sameni 2427630472 fix(connect): make peerLocalAddrs optional + skip handshake on direct P2P
Two regressions from Phase 5.5/5.6:

1. Room connect broken: the connect Tauri command required
   peerLocalAddrs as a Vec<String>, but the room-join JS path
   doesn't pass it (only the direct-call setup handler does).
   Error: "invalid args 'peerLocalAddrs' for command 'connect':
   command connect missing required key peerLocalAddrs".

   Fix: change to Option<Vec<String>>, unwrap_or_default() at
   usage sites. Room connect works again with zero peer addrs.

2. Direct P2P call connects but then CallEngine fails with
   "expected CallAnswer, got Discriminant(0)". Root cause: after
   the dual-path race picked a direct P2P transport, CallEngine
   still ran perform_handshake() on it. That handshake is a
   relay-specific protocol — sends a CallOffer signal and waits
   for CallAnswer back. On a direct QUIC connection to a phone,
   there's nobody running accept_handshake, so the handshake
   reads garbage from the peer's first media packet and errors.

   Fix: track is_direct_p2p = pre_connected_transport.is_some()
   and skip perform_handshake when true. The direct connection
   is already TLS-encrypted by QUIC, and both peers' identities
   were verified through the signal channel (DirectCallOffer/
   Answer carry identity_pub + ephemeral_pub + signature). Both
   android and desktop branches updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:09:32 +04:00

1688 lines
71 KiB
Rust

// 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"
)]
// Call engine — now compiled on every platform. On desktop it runs the real
// CPAL/VPIO audio pipeline; on Android the engine calls into the standalone
// wzp-native cdylib (via the wzp_native module) for Oboe-backed audio.
mod engine;
// Android runtime binding to libwzp_native.so (Oboe audio backend, built as
// a standalone cdylib with cargo-ndk to avoid the Tauri staticlib symbol
// leak — see docs/incident-tauri-android-init-tcb.md).
#[cfg(target_os = "android")]
mod wzp_native;
// Android AudioManager bridge (routing earpiece / speaker / BT).
#[cfg(target_os = "android")]
mod android_audio;
// Direct-call history store (persisted JSON in app data dir).
mod history;
// CallEngine has a unified impl on both targets now — the Android branch of
// CallEngine::start() routes audio through the standalone wzp-native cdylib
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
use engine::CallEngine;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use tauri::{Emitter, Manager};
use tokio::sync::Mutex;
use wzp_proto::MediaTransport;
// ─── Call-flow debug logs (GUI-gated) ────────────────────────────────
//
// Runtime-toggleable verbose logging for every step in the
// signaling + call setup path. When the user enables "Call flow
// debug logs" in the settings panel, `emit_call_debug!` fires a
// `call-debug-log` Tauri event that JS picks up and renders into a
// rolling debug panel so the user can see exactly where a call
// progressed or stalled — no logcat parsing needed.
//
// Mirrors the existing `wzp_codec::dred_verbose_logs` pattern.
static CALL_DEBUG_LOGS: AtomicBool = AtomicBool::new(false);
#[inline]
fn call_debug_logs_enabled() -> bool {
CALL_DEBUG_LOGS.load(Ordering::Relaxed)
}
fn set_call_debug_logs_internal(on: bool) {
CALL_DEBUG_LOGS.store(on, Ordering::Relaxed);
}
/// Emit a `call-debug-log` event to the JS side IF the flag is on.
/// Also mirrors to `tracing::info!` so logcat keeps its copy
/// regardless of the flag — the toggle only controls the GUI
/// overlay, not the underlying Android log stream.
pub(crate) fn emit_call_debug(
app: &tauri::AppHandle,
step: &str,
details: serde_json::Value,
) {
tracing::info!(step, ?details, "call-debug");
if !call_debug_logs_enabled() {
return;
}
let payload = serde_json::json!({
"ts_ms": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
"step": step,
"details": details,
});
let _ = app.emit("call-debug-log", payload);
}
/// Short git hash captured at compile time by build.rs.
const GIT_HASH: &str = env!("WZP_GIT_HASH");
/// Resolved by `setup()` once we have a Tauri AppHandle. Holds the
/// platform-correct app data dir (e.g. `/data/data/com.wzp.desktop/files` on
/// Android, `~/Library/Application Support/com.wzp.desktop` on macOS).
static APP_DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Adjective list — keep in sync with the noun list below. Both are powers of
/// 2 friendly so the modulo bias is negligible.
const ALIAS_ADJECTIVES: &[&str] = &[
"Swift", "Silent", "Brave", "Calm", "Dark", "Fierce", "Ghost",
"Iron", "Lucky", "Noble", "Quick", "Sharp", "Storm", "Wild",
"Cold", "Bright", "Lone", "Red", "Grey", "Frosty", "Dusty",
"Rusty", "Neon", "Void", "Solar", "Lunar", "Cyber", "Pixel",
"Sonic", "Hyper", "Turbo", "Nano", "Mega", "Ultra", "Zinc",
];
const ALIAS_NOUNS: &[&str] = &[
"Wolf", "Hawk", "Fox", "Bear", "Lynx", "Crow", "Viper",
"Cobra", "Tiger", "Eagle", "Shark", "Raven", "Falcon", "Otter",
"Mantis", "Panda", "Jackal", "Badger", "Heron", "Bison",
"Condor", "Coyote", "Gecko", "Hornet", "Marten", "Osprey",
"Parrot", "Puma", "Raptor", "Stork", "Toucan", "Walrus",
];
/// Derive a stable human-readable alias from the seed bytes. Same seed →
/// same alias forever, different seeds → effectively random aliases.
fn derive_alias(seed: &wzp_crypto::Seed) -> String {
let adj_idx = (u16::from_le_bytes([seed.0[0], seed.0[1]]) as usize) % ALIAS_ADJECTIVES.len();
let noun_idx = (u16::from_le_bytes([seed.0[2], seed.0[3]]) as usize) % ALIAS_NOUNS.len();
format!("{} {}", ALIAS_ADJECTIVES[adj_idx], ALIAS_NOUNS[noun_idx])
}
#[derive(Clone, Serialize)]
struct CallEvent {
kind: String,
message: String,
}
#[derive(Clone, Serialize)]
struct Participant {
fingerprint: String,
alias: Option<String>,
relay_label: Option<String>,
}
#[derive(Clone, Serialize)]
struct CallStatus {
active: bool,
mic_muted: bool,
spk_muted: bool,
participants: Vec<Participant>,
encode_fps: u64,
recv_fps: u64,
audio_level: u32,
call_duration_secs: f64,
fingerprint: String,
tx_codec: String,
rx_codec: String,
}
struct AppState {
engine: Mutex<Option<CallEngine>>,
signal: Arc<Mutex<SignalState>>,
}
/// 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,
}
/// Toggle DRED verbose logging at runtime (gates the chatty per-frame
/// reconstruction + parse logs in opus_enc and engine.rs). Wired to the
/// "DRED debug logs" checkbox in the GUI settings panel.
#[tauri::command]
fn set_dred_verbose_logs(enabled: bool) {
wzp_codec::set_dred_verbose_logs(enabled);
tracing::info!(enabled, "DRED verbose logs toggled");
}
/// Read the current DRED verbose logging flag (so the GUI can hydrate
/// its checkbox on startup without trusting localStorage alone).
#[tauri::command]
fn get_dred_verbose_logs() -> bool {
wzp_codec::dred_verbose_logs()
}
/// Phase 3.5 call-flow debug logs toggle. Gates the live
/// `call-debug-log` Tauri events that the GUI renders into a
/// rolling debug panel. Does NOT affect logcat — tracing::info
/// always runs regardless so the Android log stream keeps its
/// copy.
#[tauri::command]
fn set_call_debug_logs(enabled: bool) {
set_call_debug_logs_internal(enabled);
tracing::info!(enabled, "call-flow debug logs toggled");
}
#[tauri::command]
fn get_call_debug_logs() -> bool {
call_debug_logs_enabled()
}
/// Ping a relay to check if it's online, measure RTT, and get server identity.
#[tauri::command]
async fn ping_relay(relay: String) -> Result<PingResult, String> {
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::<Vec<rustls::pki_types::CertificateDer>>().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.
///
/// Resolved at startup from Tauri's `path().app_data_dir()` API which gives
/// us the platform-correct app-private location:
/// - Android: `/data/data/<package_id>/files/com.wzp.desktop`
/// - macOS: `~/Library/Application Support/com.wzp.desktop`
/// - Linux: `~/.local/share/com.wzp.desktop`
///
/// Falls back to `$HOME/.wzp` on the desktop side if the OnceLock hasn't been
/// initialised yet (shouldn't happen in normal startup, but keeps the fn
/// total).
fn identity_dir() -> PathBuf {
if let Some(dir) = APP_DATA_DIR.get() {
return dir.clone();
}
#[cfg(target_os = "android")]
{
// Last-resort default. The real path is set in setup() below.
std::path::PathBuf::from("/data/data/com.wzp.desktop/files")
}
#[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<wzp_crypto::Seed, String> {
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<String, String> {
let seed = load_or_create_seed()?;
Ok(seed.derive_identity().public_identity().fingerprint.to_string())
}
/// Build/identity info shown on the home screen so the user can prove which
/// build is installed and what their stable alias is.
#[derive(Clone, Serialize)]
struct AppInfo {
/// Short git commit hash captured at build time.
git_hash: &'static str,
/// Stable adjective+noun derived from the seed.
alias: String,
/// Full fingerprint, e.g. "abcd:ef01:..."
fingerprint: String,
/// App data dir actually in use — useful for debugging EACCES issues.
data_dir: String,
}
#[tauri::command]
fn get_app_info() -> Result<AppInfo, String> {
let seed = load_or_create_seed()?;
let pub_id = seed.derive_identity().public_identity();
Ok(AppInfo {
git_hash: GIT_HASH,
alias: derive_alias(&seed),
fingerprint: pub_id.fingerprint.to_string(),
data_dir: identity_dir().to_string_lossy().into_owned(),
})
}
#[tauri::command]
async fn connect(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
relay: String,
room: String,
alias: String,
os_aec: bool,
quality: String,
// Phase 3 hole-punching: peer's server-reflexive address
// cross-wired by the relay in CallSetup.peer_direct_addr.
peer_direct_addr: Option<String>,
// Phase 5.5: peer's LAN host candidates from CallSetup.
// Optional so the room-join path (which has no peer addrs)
// can omit it entirely — it's only populated on direct calls.
peer_local_addrs: Option<Vec<String>>,
) -> Result<String, String> {
emit_call_debug(&app, "connect:start", serde_json::json!({
"relay": relay,
"room": room,
"peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs,
}));
let mut engine_lock = state.engine.lock().await;
if engine_lock.is_some() {
emit_call_debug(&app, "connect:already_connected", serde_json::json!({}));
return Err("already connected".into());
}
// Phase 3.5: dual-path QUIC race.
//
// If the relay cross-wired a peer_direct_addr into the
// CallSetup, we read our own reflex addr from SignalState
// (populated earlier by place_call/answer_call's reflect query)
// and use determine_role() to decide whether we're the
// Acceptor (smaller addr, listens) or Dialer (larger addr,
// dials). Both roles also dial the relay in parallel as a
// fallback. Whichever transport completes first becomes the
// media transport we hand to CallEngine::start.
//
// If ANY of the inputs is missing (no peer_direct_addr, no
// own_reflex_addr, unparseable addrs, equal addrs), we skip
// the race entirely and fall back to the pure-relay path —
// identical to Phase 0 behavior.
let (own_reflex_addr, signal_endpoint_for_race) = {
let sig = state.signal.lock().await;
(sig.own_reflex_addr.clone(), sig.endpoint.clone())
};
let peer_addr_parsed: Option<std::net::SocketAddr> = peer_direct_addr
.as_deref()
.and_then(|s| s.parse().ok());
let relay_addr_parsed: Option<std::net::SocketAddr> = relay.parse().ok();
let role = wzp_client::reflect::determine_role(
own_reflex_addr.as_deref(),
peer_direct_addr.as_deref(),
);
// Phase 5.5: build the full peer candidate bundle (reflex +
// LAN hosts). The dial_order helper will fan them out in
// priority order for the D-role race.
let peer_local_addrs_vec = peer_local_addrs.unwrap_or_default();
let peer_local_parsed: Vec<std::net::SocketAddr> = peer_local_addrs_vec
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let pre_connected_transport: Option<Arc<wzp_transport::QuinnTransport>> =
match (role, relay_addr_parsed) {
(Some(r), Some(relay_sockaddr))
if peer_addr_parsed.is_some() || !peer_local_parsed.is_empty() =>
{
let candidates = wzp_client::dual_path::PeerCandidates {
reflexive: peer_addr_parsed,
local: peer_local_parsed.clone(),
};
tracing::info!(
role = ?r,
candidates = ?candidates.dial_order(),
%relay,
%room,
own = ?own_reflex_addr,
"connect: starting dual-path race"
);
emit_call_debug(&app, "connect:dual_path_race_start", serde_json::json!({
"role": format!("{:?}", r),
"peer_reflex": peer_addr_parsed.map(|a| a.to_string()),
"peer_local": peer_local_parsed.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
"relay_addr": relay_sockaddr.to_string(),
"own_reflex_addr": own_reflex_addr,
}));
let room_sni = room.clone();
let call_sni = format!("call-{room}");
// Phase 5: pass the signal endpoint so the race
// reuses ONE socket for listen + dial + relay.
match wzp_client::dual_path::race(
r,
candidates,
relay_sockaddr,
room_sni,
call_sni,
signal_endpoint_for_race.clone(),
)
.await
{
Ok((transport, path)) => {
tracing::info!(?path, "connect: dual-path race resolved");
emit_call_debug(&app, "connect:dual_path_race_won", serde_json::json!({
"path": format!("{:?}", path),
}));
Some(transport)
}
Err(e) => {
// Both paths failed — surface to the user.
// CallEngine::start below with None will try
// the relay once more using the old code path
// (which reuses the signal endpoint and has a
// longer timeout) so we don't unconditionally
// fail the call on a transient race blip.
tracing::warn!(error = %e, "connect: dual-path race failed, falling back to classic relay connect");
emit_call_debug(&app, "connect:dual_path_race_failed", serde_json::json!({
"error": e.to_string(),
}));
None
}
}
}
_ => {
tracing::info!(
has_peer_reflex = peer_direct_addr.is_some(),
has_peer_local = !peer_local_addrs_vec.is_empty(),
has_own = own_reflex_addr.is_some(),
?role,
%relay,
%room,
"connect: skipping dual-path race (missing inputs), relay-only"
);
emit_call_debug(&app, "connect:dual_path_skipped", serde_json::json!({
"has_peer_reflex": peer_direct_addr.is_some(),
"has_peer_local": !peer_local_addrs_vec.is_empty(),
"has_own": own_reflex_addr.is_some(),
"role": format!("{:?}", role),
}));
None
}
};
// If we previously opened a quinn::Endpoint for the signaling connection
// (direct-call path), reuse it so the media connection shares the same
// UDP socket. This side-steps the Android issue where a second
// quinn::Endpoint silently hangs in the QUIC handshake.
let reuse_endpoint = state.signal.lock().await.endpoint.clone();
if reuse_endpoint.is_some() && pre_connected_transport.is_none() {
tracing::info!("connect: reusing existing signal endpoint for media connection");
}
let app_clone = app.clone();
emit_call_debug(&app, "connect:call_engine_starting", serde_json::json!({}));
let app_for_engine = app.clone();
match CallEngine::start(relay, room, alias, os_aec, quality, reuse_endpoint, pre_connected_transport, app_for_engine, 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);
emit_call_debug(&app, "connect:call_engine_started", serde_json::json!({}));
Ok("connected".into())
}
Err(e) => {
emit_call_debug(&app, "connect:call_engine_failed", serde_json::json!({ "error": e.to_string() }));
Err(format!("{e}"))
}
}
}
#[tauri::command]
async fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<String, String> {
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())
}
}
#[tauri::command]
async fn toggle_mic(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
Ok(engine.toggle_mic())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn toggle_speaker(state: tauri::State<'_, Arc<AppState>>) -> Result<bool, String> {
let engine_lock = state.engine.lock().await;
if let Some(ref engine) = *engine_lock {
Ok(engine.toggle_speaker())
} else {
Err("not connected".into())
}
}
#[tauri::command]
async fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<CallStatus, String> {
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(),
})
}
}
// ─── Audio routing (Android-specific, no-op on desktop) ─────────────────────
/// Switch the call audio between earpiece (`on=false`) and loudspeaker
/// (`on=true`). On Android this calls AudioManager.setSpeakerphoneOn via
/// JNI AND then stops and restarts the Oboe streams so AAudio reconfigures
/// with the new routing — without the restart, changing the speakerphone
/// state mid-call silently tears down the running AAudio streams on some
/// OEMs and both capture + playout stop producing data.
///
/// The Rust send/recv tokio tasks keep running during the ~60ms restart
/// window; they just observe empty reads / writes against the
/// process-global ring buffers, which is fine because the ring state
/// is preserved across stop+start.
#[tauri::command]
#[allow(unused_variables)]
async fn set_speakerphone(on: bool) -> Result<(), String> {
#[cfg(target_os = "android")]
{
android_audio::set_speakerphone(on)?;
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
tracing::info!(on, "set_speakerphone: restarting Oboe for route change");
// Oboe's stop/start are sync C-FFI calls that block for ~400ms
// on Nothing-class devices (Pixel is faster). Calling them
// directly from an async Tauri command stalls the tokio
// executor — the send/recv engine tasks were observed to
// freeze for ~20 seconds across a few rapid speaker toggles,
// piling up buffered QUIC datagrams and then flooding them
// all at once when the runtime finally caught up.
//
// Fix: run the audio teardown + reopen on a dedicated
// blocking thread so the runtime keeps scheduling everything
// else. AAudio's requestStop returns only after the stream
// is actually in Stopped state, so no explicit inter-call
// sleep is needed.
tokio::task::spawn_blocking(|| {
wzp_native::audio_stop();
wzp_native::audio_start()
.map_err(|code| format!("audio_start after speakerphone toggle: code {code}"))
})
.await
.map_err(|e| format!("spawn_blocking join: {e}"))??;
tracing::info!("set_speakerphone: Oboe restarted");
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// Query whether the call is currently routed to the loudspeaker.
#[tauri::command]
async fn is_speakerphone_on() -> Result<bool, String> {
#[cfg(target_os = "android")]
{
android_audio::is_speakerphone_on()
}
#[cfg(not(target_os = "android"))]
{
Ok(false)
}
}
// ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command]
fn get_call_history() -> Vec<history::CallHistoryEntry> {
history::all()
}
#[tauri::command]
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
history::contacts()
}
#[tauri::command]
fn clear_call_history() -> Result<(), String> {
history::clear();
Ok(())
}
// ─── Signaling commands — platform independent ───────────────────────────────
struct SignalState {
transport: Option<Arc<wzp_transport::QuinnTransport>>,
/// The quinn::Endpoint backing the signal connection. Reused for the
/// media connection when a direct call is accepted — Android phones
/// silently drop packets from a second quinn::Endpoint to the same
/// relay, so every call after register_signal MUST share this socket.
endpoint: Option<wzp_transport::Endpoint>,
fingerprint: String,
signal_status: String,
incoming_call_id: Option<String>,
incoming_caller_fp: Option<String>,
incoming_caller_alias: Option<String>,
/// Pending `ReflectResponse` channel. When the `get_reflected_address`
/// Tauri command fires, it drops a `oneshot::Sender<SocketAddr>` here
/// before sending a `SignalMessage::Reflect`. The spawned recv loop
/// picks the response off the next bi-stream and fires the sender.
/// If another Reflect request comes in while one is pending, we
/// replace the sender — the old receiver sees a `Cancelled` error
/// and the caller retries.
pending_reflect: Option<tokio::sync::oneshot::Sender<std::net::SocketAddr>>,
/// Phase 3.5: this client's own server-reflexive address as last
/// observed by a Reflect query. Populated by
/// `try_reflect_own_addr` on success and read by the `connect`
/// Tauri command to compute the deterministic role for the
/// dual-path QUIC race against `peer_direct_addr`.
own_reflex_addr: Option<String>,
/// The relay address the user currently wants to be registered
/// against. `Some` means "keep me connected" — the supervisor
/// will auto-reconnect after unexpected drops. `None` means
/// "user explicitly deregistered" — do not retry.
///
/// Distinguishing these two cases is what lets relay
/// restarts + transient network blips be transparent to the
/// user: the recv loop dies, but because `desired_relay_addr`
/// is still set, a supervisor task retries the full
/// connect+register flow with exponential backoff until the
/// relay is reachable again.
desired_relay_addr: Option<String>,
/// Single-flight guard: `true` while the reconnect supervisor
/// task is actively trying to re-establish the signal
/// connection. Prevents duplicate supervisors from spawning
/// (recv loop exit races with a manual register_signal call).
reconnect_in_progress: bool,
}
#[tauri::command]
async fn register_signal(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
relay: String,
) -> Result<String, String> {
// Set the desired relay and handle the "already registered to
// a different relay" transition. This is the public entry
// point — settings-screen changes come through here.
let already_same = {
let sig = state.signal.lock().await;
sig.transport.is_some()
&& sig.desired_relay_addr.as_deref() == Some(relay.as_str())
};
if already_same {
// Idempotent: user hit "Register" twice on the same relay,
// or the JS side re-called after a settings save that
// didn't actually change the relay.
let sig = state.signal.lock().await;
return Ok(sig.fingerprint.clone());
}
// Tear down any existing registration (different relay → swap).
internal_deregister(&state.signal, /*keep_desired=*/ false).await;
// Announce the new desired state so the recv-loop exit path and
// any running supervisor can see it.
{
let mut sig = state.signal.lock().await;
sig.desired_relay_addr = Some(relay.clone());
}
do_register_signal(state.signal.clone(), app, relay).await
}
/// Close the current signal transport + clear derived state.
/// Used by `deregister` (with `keep_desired = false`, clearing
/// `desired_relay_addr`) and by the relay-swap path in
/// `register_signal` (also `keep_desired = false` — the caller
/// is about to set a new desired addr).
async fn internal_deregister(
signal_state: &Arc<tokio::sync::Mutex<SignalState>>,
keep_desired: bool,
) {
let mut sig = signal_state.lock().await;
if let Some(t) = sig.transport.take() {
// Dropping the transport Arc closes the quinn connection;
// calling close() explicitly is a no-op but neat.
let _ = t.close().await;
}
sig.endpoint = None;
sig.signal_status = "idle".into();
sig.incoming_call_id = None;
sig.incoming_caller_fp = None;
sig.incoming_caller_alias = None;
sig.pending_reflect = None;
sig.own_reflex_addr = None;
if !keep_desired {
sig.desired_relay_addr = None;
}
}
/// Core register flow, extracted so the Tauri command AND the
/// reconnect supervisor can both call it. Does the connect +
/// RegisterPresence + spawn-recv-loop dance.
///
/// Contract: `signal_state.desired_relay_addr` must already be
/// set to `Some(relay)` by the caller. On recv-loop exit, the
/// spawned task will check `desired_relay_addr` and (if still
/// Some) trigger the reconnect supervisor.
///
/// Explicit `+ Send` on the return type so the reconnect
/// supervisor (which lives inside a `tokio::spawn`) can await
/// this future without hitting auto-trait inference issues.
fn do_register_signal(
signal_state: Arc<tokio::sync::Mutex<SignalState>>,
app: tauri::AppHandle,
relay: String,
) -> impl std::future::Future<Output = Result<String, String>> + Send {
async move {
use wzp_proto::SignalMessage;
emit_call_debug(&app, "register_signal:start", serde_json::json!({ "relay": relay }));
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();
emit_call_debug(&app, "register_signal:identity_loaded", serde_json::json!({ "fingerprint": fp }));
// Phase 5: single-socket Nebula-style architecture. The signal
// endpoint is dual-purpose (client + server config). Every outbound
// flow — signal, reflect probes, relay media dials, direct-P2P
// dials — uses this same socket, so port-preserving NATs (MikroTik
// masquerade is the big one) give us a stable external port that
// peers can actually dial. The same socket also accepts incoming
// direct-P2P connections during the dual-path race.
//
// Was `None` before Phase 5 — that produced a client-only endpoint
// with a different internal port than later reflect / dual-path
// endpoints, which made MikroTik look symmetric and broke direct
// P2P because the advertised reflex port was not the listening
// port.
let bind: std::net::SocketAddr = "0.0.0.0:0".parse().unwrap();
let (server_cfg, _cert_der) = wzp_transport::server_config();
let endpoint = wzp_transport::create_endpoint(bind, Some(server_cfg))
.map_err(|e| format!("{e}"))?;
emit_call_debug(&app, "register_signal:endpoint_created", serde_json::json!({ "bind": bind.to_string() }));
let conn = wzp_transport::connect(&endpoint, addr, "_signal", wzp_transport::client_config())
.await
.map_err(|e| {
emit_call_debug(&app, "register_signal:connect_failed", serde_json::json!({ "error": e.to_string() }));
format!("{e}")
})?;
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
emit_call_debug(&app, "register_signal:quic_connected", serde_json::json!({ "relay": relay }));
transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub, signature: vec![], alias: None,
}).await.map_err(|e| format!("{e}"))?;
emit_call_debug(&app, "register_signal:register_presence_sent", serde_json::json!({}));
match transport.recv_signal().await.map_err(|e| format!("{e}"))? {
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
emit_call_debug(&app, "register_signal:ack_received", serde_json::json!({}));
}
_ => {
emit_call_debug(&app, "register_signal:ack_failed", serde_json::json!({}));
return Err("registration failed".into());
}
}
{
let mut sig = signal_state.lock().await;
sig.transport = Some(transport.clone());
sig.endpoint = Some(endpoint.clone());
sig.fingerprint = fp.clone();
sig.signal_status = "registered".into();
}
// Let the JS side know we've (re-)entered "registered" so any
// "reconnecting..." banner can clear.
let _ = app.emit(
"signal-event",
serde_json::json!({ "type": "registered", "fingerprint": fp }),
);
tracing::info!(%fp, "signal registered, spawning recv loop");
emit_call_debug(&app, "register_signal:recv_loop_spawning", serde_json::json!({ "fingerprint": fp }));
let signal_state_loop = signal_state.clone();
let app_clone = app.clone();
tokio::spawn(async move {
// Capture for the exit-path reconnect trigger below.
let signal_state = signal_state_loop.clone();
loop {
match transport.recv_signal().await {
Ok(Some(SignalMessage::CallRinging { call_id })) => {
tracing::info!(%call_id, "signal: CallRinging");
emit_call_debug(&app_clone, "recv:CallRinging", serde_json::json!({ "call_id": 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, caller_reflexive_addr, .. })) => {
tracing::info!(%call_id, caller = %caller_fingerprint, "signal: DirectCallOffer");
emit_call_debug(&app_clone, "recv:DirectCallOffer", serde_json::json!({
"call_id": call_id,
"caller_fp": caller_fingerprint,
"caller_alias": caller_alias,
"caller_reflexive_addr": caller_reflexive_addr,
}));
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();
// Log as a Missed entry up-front. If the user accepts
// the call, answer_call upgrades it to Received via
// history::mark_received_if_pending(call_id). If they
// reject or ignore, it stays Missed.
history::log(
call_id.clone(),
caller_fingerprint.clone(),
caller_alias.clone(),
history::CallDirection::Missed,
);
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
let _ = app_clone.emit("history-changed", ());
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, callee_reflexive_addr, .. })) => {
tracing::info!(%call_id, ?accept_mode, "signal: DirectCallAnswer (forwarded by relay)");
emit_call_debug(&app_clone, "recv:DirectCallAnswer", serde_json::json!({
"call_id": call_id,
"accept_mode": format!("{:?}", accept_mode),
"callee_reflexive_addr": callee_reflexive_addr,
}));
}
Ok(Some(SignalMessage::CallSetup { call_id, room, relay_addr, peer_direct_addr, peer_local_addrs })) => {
// Phase 3: peer_direct_addr carries the OTHER party's
// reflex addr. Phase 5.5: peer_local_addrs carries
// their LAN host candidates (usable for same-LAN
// direct dials that can't hairpin through the NAT).
tracing::info!(
%call_id,
%room,
%relay_addr,
peer_direct = ?peer_direct_addr,
peer_local = ?peer_local_addrs,
"signal: CallSetup — emitting setup event to JS"
);
emit_call_debug(&app_clone, "recv:CallSetup", serde_json::json!({
"call_id": call_id,
"room": room,
"relay_addr": relay_addr,
"peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs,
}));
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,
"peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs,
}),
);
}
Ok(Some(SignalMessage::Hangup { reason })) => {
tracing::info!(?reason, "signal: Hangup");
emit_call_debug(&app_clone, "recv:Hangup", serde_json::json!({ "reason": format!("{:?}", reason) }));
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(Some(SignalMessage::ReflectResponse { observed_addr })) => {
// "STUN for QUIC" response — the relay told us our
// own server-reflexive address. If a Tauri command
// is currently awaiting this, fire the oneshot;
// otherwise log and drop (unsolicited responses
// from a confused relay shouldn't crash the loop).
tracing::info!(%observed_addr, "signal: ReflectResponse");
match observed_addr.parse::<std::net::SocketAddr>() {
Ok(parsed) => {
let mut sig = signal_state.lock().await;
if let Some(tx) = sig.pending_reflect.take() {
// `send` returns Err(addr) only if the
// receiver was dropped (caller timed out
// or canceled). Either way, nothing to
// do — the value is gone.
let _ = tx.send(parsed);
} else {
tracing::debug!(%observed_addr, "reflect: unsolicited response (no pending sender)");
}
let _ = app_clone.emit(
"signal-event",
serde_json::json!({"type":"reflect","observed_addr":observed_addr}),
);
}
Err(e) => {
tracing::warn!(%observed_addr, error = %e, "reflect: relay returned unparseable addr");
// Treat unparseable response as a failed
// request so the caller doesn't hang.
let mut sig = signal_state.lock().await;
let _ = sig.pending_reflect.take();
}
}
}
Ok(Some(other)) => {
tracing::debug!(?other, "signal: unhandled message");
}
Ok(None) => {
tracing::warn!("signal recv returned None — peer closed");
break;
}
Err(wzp_proto::TransportError::Deserialize(e)) => {
// Forward-compat: the relay sent us a
// SignalMessage variant we don't know yet
// (older client against a newer relay).
// Log and keep the signal connection alive —
// otherwise direct-call registration would
// silently die on any protocol bump.
tracing::warn!(error = %e, "signal recv: unknown variant, continuing");
}
Err(e) => {
tracing::warn!(error = %e, "signal recv error — breaking loop");
break;
}
}
}
tracing::warn!("signal recv loop exited — signal_status=idle, transport dropped");
// Determine whether this was a user-requested close or an
// unexpected drop. `desired_relay_addr.is_some()` means the
// user still wants to be registered — spawn the reconnect
// supervisor with exponential backoff.
let (should_reconnect, desired_relay, already_reconnecting) = {
let mut sig = signal_state.lock().await;
sig.signal_status = "idle".into();
sig.transport = None;
(
sig.desired_relay_addr.is_some(),
sig.desired_relay_addr.clone(),
sig.reconnect_in_progress,
)
};
if should_reconnect && !already_reconnecting {
if let Some(relay) = desired_relay {
tracing::info!(%relay, "signal recv loop exited unexpectedly — spawning reconnect supervisor");
emit_call_debug(
&app_clone,
"signal:reconnect_supervisor_spawning",
serde_json::json!({ "relay": relay }),
);
let _ = app_clone.emit(
"signal-event",
serde_json::json!({ "type": "reconnecting", "relay": relay }),
);
let state_for_sup = signal_state.clone();
let app_for_sup = app_clone.clone();
tokio::spawn(async move {
signal_reconnect_supervisor(state_for_sup, app_for_sup, relay).await;
});
}
} else if should_reconnect && already_reconnecting {
tracing::debug!("signal recv loop exited; reconnect supervisor already running");
}
});
Ok(fp)
} // end async move
} // end fn do_register_signal
/// Supervisor task: loops with exponential backoff, calling
/// `do_register_signal` until the relay comes back online. Exits
/// as soon as one attempt succeeds (the newly-spawned recv loop
/// owns the connection from that point on) OR the user clears
/// `desired_relay_addr` via `deregister`.
///
/// Backoff schedule: 1s, 2s, 4s, 8s, 15s, 30s (capped). Reset on
/// success or exit.
async fn signal_reconnect_supervisor(
signal_state: Arc<tokio::sync::Mutex<SignalState>>,
app: tauri::AppHandle,
initial_relay: String,
) {
// Claim the single-flight slot so a second exit-path trigger
// or a manual register_signal doesn't spawn a duplicate.
{
let mut sig = signal_state.lock().await;
if sig.reconnect_in_progress {
tracing::debug!("reconnect supervisor: another already running, exiting");
return;
}
sig.reconnect_in_progress = true;
}
let backoff_schedule_ms: [u64; 6] = [1_000, 2_000, 4_000, 8_000, 15_000, 30_000];
let mut attempt: usize = 0;
let mut current_relay = initial_relay;
loop {
// Has the user cleared the desired relay? If so, exit.
let (desired, transport_is_some) = {
let sig = signal_state.lock().await;
(sig.desired_relay_addr.clone(), sig.transport.is_some())
};
let Some(desired) = desired else {
tracing::info!("reconnect supervisor: desired_relay_addr cleared, exiting");
break;
};
// Has something else already re-registered us (manual
// register_signal won the race)? If so, exit.
if transport_is_some {
tracing::info!("reconnect supervisor: transport already set by another path, exiting");
break;
}
// Has the desired relay changed under us? Switch to the new one.
if desired != current_relay {
tracing::info!(old = %current_relay, new = %desired, "reconnect supervisor: desired relay changed");
current_relay = desired.clone();
attempt = 0;
}
// Back off before the retry (skip on attempt 0 so the first
// reconnect kicks in fast).
if attempt > 0 {
let idx = (attempt - 1).min(backoff_schedule_ms.len() - 1);
let wait_ms = backoff_schedule_ms[idx];
tracing::info!(
attempt,
wait_ms,
relay = %current_relay,
"reconnect supervisor: backing off"
);
emit_call_debug(
&app,
"signal:reconnect_backoff",
serde_json::json!({ "attempt": attempt, "wait_ms": wait_ms, "relay": current_relay }),
);
tokio::time::sleep(std::time::Duration::from_millis(wait_ms)).await;
}
attempt += 1;
// One-shot attempt. do_register_signal will set the
// transport + spawn a fresh recv loop on success.
//
// CRITICAL: release our single-flight guard BEFORE
// do_register_signal spawns the new recv loop, because that
// recv loop's exit path also checks `reconnect_in_progress`
// to decide whether to spawn a supervisor of its own. If we
// held it here and later exited, the slot would be released
// too late for the next drop to trigger a fresh supervisor.
{
let mut sig = signal_state.lock().await;
sig.reconnect_in_progress = false;
}
emit_call_debug(
&app,
"signal:reconnect_attempt",
serde_json::json!({ "attempt": attempt, "relay": current_relay }),
);
match do_register_signal(signal_state.clone(), app.clone(), current_relay.clone()).await {
Ok(fp) => {
tracing::info!(%fp, relay = %current_relay, "reconnect supervisor: success");
emit_call_debug(
&app,
"signal:reconnect_ok",
serde_json::json!({ "fingerprint": fp, "relay": current_relay }),
);
return; // recv loop now owns the connection
}
Err(e) => {
tracing::warn!(error = %e, relay = %current_relay, "reconnect supervisor: attempt failed");
emit_call_debug(
&app,
"signal:reconnect_failed",
serde_json::json!({ "attempt": attempt, "error": e, "relay": current_relay }),
);
// Re-claim the single-flight slot for the next iteration.
let mut sig = signal_state.lock().await;
sig.reconnect_in_progress = true;
}
}
}
// Loop exited — clean up the slot if we still hold it.
let mut sig = signal_state.lock().await;
sig.reconnect_in_progress = false;
}
#[tauri::command]
async fn place_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
target_fp: String,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
emit_call_debug(&app, "place_call:start", serde_json::json!({ "target_fp": target_fp }));
// Phase 3 hole-punching: query our own reflex addr BEFORE the
// offer so we can advertise it. Best-effort — a failed reflect
// (old relay, transient error) falls back to `None` which
// means the callee's CallSetup will have peer_direct_addr=None
// and the whole call goes through the relay path unchanged.
//
// Critical: this call does its own state.signal.lock() usage and
// MUST NOT be wrapped in an outer lock, or the recv loop's
// ReflectResponse handler will deadlock on the same mutex.
emit_call_debug(&app, "place_call:reflect_query_start", serde_json::json!({}));
let state_inner: Arc<AppState> = (*state).clone();
let own_reflex = try_reflect_own_addr(&state_inner).await.ok().flatten();
if let Some(ref a) = own_reflex {
tracing::info!(%a, "place_call: learned own reflex addr for hole-punching advertisement");
emit_call_debug(&app, "place_call:reflect_query_ok", serde_json::json!({ "addr": a }));
} else {
tracing::info!("place_call: no reflex addr available, falling back to relay-only");
emit_call_debug(&app, "place_call:reflect_query_none", serde_json::json!({}));
}
// Phase 5.5: gather LAN host candidates using the signal
// endpoint's bound port so incoming dials land on the same
// socket that's already listening.
let caller_local_addrs: Vec<String> = {
let sig = state.signal.lock().await;
sig.endpoint
.as_ref()
.and_then(|ep| ep.local_addr().ok())
.map(|la| {
wzp_client::reflect::local_host_candidates(la.port())
.into_iter()
.map(|a| a.to_string())
.collect()
})
.unwrap_or_default()
};
emit_call_debug(&app, "place_call:host_candidates", serde_json::json!({
"local_addrs": caller_local_addrs,
}));
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()
);
tracing::info!(%call_id, %target_fp, reflex = ?own_reflex, "place_call: sending DirectCallOffer");
transport
.send_signal(&SignalMessage::DirectCallOffer {
caller_fingerprint: sig.fingerprint.clone(),
caller_alias: None,
target_fingerprint: target_fp.clone(),
call_id: call_id.clone(),
identity_pub: [0u8; 32],
ephemeral_pub: [0u8; 32],
signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
caller_reflexive_addr: own_reflex.clone(),
caller_local_addrs: caller_local_addrs.clone(),
})
.await
.map_err(|e| {
emit_call_debug(&app, "place_call:send_failed", serde_json::json!({ "error": e.to_string() }));
format!("{e}")
})?;
emit_call_debug(&app, "place_call:offer_sent", serde_json::json!({
"call_id": call_id,
"target_fp": target_fp,
"caller_reflexive_addr": own_reflex,
}));
history::log(call_id, target_fp, None, history::CallDirection::Placed);
let _ = app.emit("history-changed", ());
Ok(())
}
#[tauri::command]
async fn answer_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
call_id: String,
mode: i32,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
let accept_mode = match mode {
0 => wzp_proto::CallAcceptMode::Reject,
1 => wzp_proto::CallAcceptMode::AcceptTrusted,
_ => wzp_proto::CallAcceptMode::AcceptGeneric,
};
emit_call_debug(&app, "answer_call:start", serde_json::json!({
"call_id": call_id,
"accept_mode": format!("{:?}", accept_mode),
}));
// Phase 3 hole-punching: only AcceptTrusted reveals our reflex
// addr. Privacy-mode (AcceptGeneric) and Reject explicitly do
// NOT — leaking the callee's IP back to the caller in those
// modes would defeat the entire point of AcceptGeneric.
//
// Like place_call, we MUST NOT hold state.signal.lock() across
// the reflect await or the recv loop's ReflectResponse handler
// will deadlock on the same mutex.
let own_reflex = if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
emit_call_debug(&app, "answer_call:reflect_query_start", serde_json::json!({}));
let state_inner: Arc<AppState> = (*state).clone();
let r = try_reflect_own_addr(&state_inner).await.ok().flatten();
if let Some(ref a) = r {
tracing::info!(%call_id, %a, "answer_call: learned own reflex addr for AcceptTrusted");
emit_call_debug(&app, "answer_call:reflect_query_ok", serde_json::json!({ "addr": a }));
} else {
tracing::info!(%call_id, "answer_call: no reflex addr for AcceptTrusted, falling back to relay-only");
emit_call_debug(&app, "answer_call:reflect_query_none", serde_json::json!({}));
}
r
} else {
// Reject / AcceptGeneric: keep the IP private.
emit_call_debug(&app, "answer_call:privacy_mode_skip_reflect", serde_json::json!({}));
None
};
// Phase 5.5: gather LAN host candidates (AcceptTrusted only
// for symmetry with the reflex addr — privacy mode keeps
// LAN addrs hidden too).
let callee_local_addrs: Vec<String> =
if accept_mode == wzp_proto::CallAcceptMode::AcceptTrusted {
let sig = state.signal.lock().await;
sig.endpoint
.as_ref()
.and_then(|ep| ep.local_addr().ok())
.map(|la| {
wzp_client::reflect::local_host_candidates(la.port())
.into_iter()
.map(|a| a.to_string())
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
emit_call_debug(&app, "answer_call:host_candidates", serde_json::json!({
"local_addrs": callee_local_addrs,
}));
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or_else(|| {
tracing::warn!("answer_call: not registered (no transport)");
"not registered".to_string()
})?;
tracing::info!(%call_id, ?accept_mode, reflex = ?own_reflex, "answer_call: sending DirectCallAnswer");
transport
.send_signal(&SignalMessage::DirectCallAnswer {
call_id: call_id.clone(),
accept_mode,
identity_pub: None,
ephemeral_pub: None,
signature: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
callee_reflexive_addr: own_reflex.clone(),
callee_local_addrs: callee_local_addrs.clone(),
})
.await
.map_err(|e| {
tracing::error!(%call_id, error = %e, "answer_call: send_signal failed");
emit_call_debug(&app, "answer_call:send_failed", serde_json::json!({ "error": e.to_string() }));
format!("{e}")
})?;
tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
emit_call_debug(&app, "answer_call:answer_sent", serde_json::json!({
"call_id": call_id,
"accept_mode": format!("{:?}", accept_mode),
"callee_reflexive_addr": own_reflex,
}));
// Upgrade the pending "Missed" entry to "Received" if the user
// accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
if mode != 0 && history::mark_received_if_pending(&call_id) {
let _ = app.emit("history-changed", ());
}
Ok(())
}
/// Internal reflect helper shared by `get_reflected_address` and the
/// hole-punching path in `place_call` / `answer_call`.
///
/// Must be called WITHOUT holding `state.signal.lock()` — the recv
/// loop acquires the same lock to fire the oneshot, so holding it
/// across the await would deadlock.
///
/// Returns `Ok(Some(addr))` on success, `Ok(None)` if reflect is
/// unsupported / timed out / transport failed (caller should
/// gracefully continue with a relay-only path), or `Err` on
/// "not registered" which is a hard precondition failure.
async fn try_reflect_own_addr(
state: &Arc<AppState>,
) -> Result<Option<String>, String> {
use wzp_proto::SignalMessage;
let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
let transport = {
let mut sig = state.signal.lock().await;
sig.pending_reflect = Some(tx);
sig.transport
.as_ref()
.ok_or_else(|| "not registered".to_string())?
.clone()
};
if let Err(e) = transport.send_signal(&SignalMessage::Reflect).await {
let mut sig = state.signal.lock().await;
sig.pending_reflect = None;
tracing::warn!(error = %e, "try_reflect_own_addr: send_signal failed, continuing without reflex addr");
return Ok(None);
}
match tokio::time::timeout(std::time::Duration::from_millis(1000), rx).await {
Ok(Ok(addr)) => {
// Phase 3.5: cache the result on SignalState so the
// `connect` command can read it later for role
// determination without another reflect round-trip.
let s = addr.to_string();
{
let mut sig = state.signal.lock().await;
sig.own_reflex_addr = Some(s.clone());
}
Ok(Some(s))
}
Ok(Err(_canceled)) => {
tracing::warn!("try_reflect_own_addr: oneshot canceled");
Ok(None)
}
Err(_elapsed) => {
let mut sig = state.signal.lock().await;
sig.pending_reflect = None;
tracing::warn!("try_reflect_own_addr: 1s timeout (pre-Phase-1 relay?)");
Ok(None)
}
}
}
/// "STUN for QUIC" — ask the relay what our own public address looks
/// like from its side of the TLS-authenticated signal connection.
///
/// Wire flow:
/// 1. We install a `oneshot::Sender` in `SignalState.pending_reflect`
/// (replacing any stale one — last request wins).
/// 2. We release the state lock and send `SignalMessage::Reflect`
/// over the existing transport. The relay opens a fresh bi-stream
/// on its side to respond, which the spawned recv loop picks up.
/// 3. The recv loop's `ReflectResponse` match arm takes the sender
/// back out and fires it with the parsed `SocketAddr`.
/// 4. We await the receiver with a 1s timeout so a non-reflecting
/// relay (pre-Phase-1 build) doesn't hang the UI forever.
///
/// Returns the addr as a string so it can cross the Tauri IPC
/// boundary unchanged — JS-side can display it directly or parse it
/// with `new URL(...)` / a regex if needed.
#[tauri::command]
async fn get_reflected_address(
state: tauri::State<'_, Arc<AppState>>,
) -> Result<String, String> {
use wzp_proto::SignalMessage;
let (tx, rx) = tokio::sync::oneshot::channel::<std::net::SocketAddr>();
let transport = {
let mut sig = state.signal.lock().await;
// Drop any older pending sender — we don't support more than
// one in-flight Reflect per connection. A prior request whose
// receiver has timed out will be cleaned up here automatically.
sig.pending_reflect = Some(tx);
sig.transport
.as_ref()
.ok_or_else(|| "not registered".to_string())?
.clone()
};
if let Err(e) = transport.send_signal(&SignalMessage::Reflect).await {
// Clean up the pending sender so the next attempt doesn't see
// a stale channel. Re-acquire the lock inline since we already
// released it above to release `transport` back to the caller.
let mut sig = state.signal.lock().await;
sig.pending_reflect = None;
return Err(format!("send Reflect: {e}"));
}
// 1s is plenty for a same-datacenter relay (< 50ms RTT) and also
// the ceiling for "something's wrong, tell the user" — any older
// relay will never reply at all. 1100ms in the integration test.
match tokio::time::timeout(std::time::Duration::from_millis(1000), rx).await {
Ok(Ok(addr)) => Ok(addr.to_string()),
Ok(Err(_canceled)) => {
// The recv loop dropped the sender (relay returned
// unparseable addr, or loop exited mid-request).
Err("reflect channel canceled (signal loop exited or parse error)".into())
}
Err(_elapsed) => {
// Timeout — strip the pending sender so the next attempt
// starts clean. Old (pre-Phase-1) relays will land here.
let mut sig = state.signal.lock().await;
sig.pending_reflect = None;
Err("reflect timeout (relay may not support reflection)".into())
}
}
}
/// Phase 2 of the "STUN for QUIC" rollout — probe multiple relays
/// in parallel to classify this client's NAT type. See
/// `wzp_client::reflect` for the per-probe logic and the pure
/// classifier.
///
/// This does NOT touch the registered `SignalState` — each probe
/// opens a fresh throwaway QUIC endpoint so the OS gives it a
/// fresh ephemeral source port. Sharing one endpoint across probes
/// would make a symmetric NAT look like a cone NAT, which is
/// exactly the failure mode we're trying to detect.
///
/// Takes the relay list from JS because the GUI owns the relay
/// config (localStorage `wzp-settings.relays`). Frontend passes it
/// in; Rust side just does the network work.
#[tauri::command]
async fn detect_nat_type(
state: tauri::State<'_, Arc<AppState>>,
relays: Vec<RelayArg>,
) -> Result<serde_json::Value, String> {
// Parse relay args up front so a single malformed entry fails
// the whole call cleanly instead of surfacing as a probe error
// at the end.
let mut parsed = Vec::with_capacity(relays.len());
for r in relays {
let addr: std::net::SocketAddr = r
.address
.parse()
.map_err(|e| format!("bad relay address {:?}: {e}", r.address))?;
parsed.push((r.name, addr));
}
// Phase 5: share the signal endpoint across all probes so
// they emit from the same source port. Port-preserving NATs
// (MikroTik, most consumer routers) give a stable external
// port → classifier correctly sees cone instead of falsely
// labeling SymmetricPort. Falls back to None (per-probe fresh
// endpoint) when not registered.
let shared_endpoint = state.signal.lock().await.endpoint.clone();
// 1500ms per probe is generous: a same-host probe is < 10ms,
// a cross-continent probe is typically < 300ms, and we want
// to tolerate a one-off packet loss during connect.
let detection = wzp_client::reflect::detect_nat_type(parsed, 1500, shared_endpoint).await;
serde_json::to_value(&detection).map_err(|e| format!("serialize: {e}"))
}
/// Deserialization shim for the relay list coming from JS. The
/// `wzp-settings.relays` array in localStorage has more fields
/// (rtt, serverFingerprint, knownFingerprint) but we only need
/// name + address here.
#[derive(serde::Deserialize)]
struct RelayArg {
name: String,
address: String,
}
#[tauri::command]
async fn get_signal_status(state: tauri::State<'_, Arc<AppState>>) -> Result<serde_json::Value, String> {
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}))
}
/// Tear down the signal connection so the user goes back to idle. Called
/// when the user clicks "Deregister" on the direct-call screen. The
/// spawned recv loop will break out naturally when the transport closes,
/// AND — critically — clearing `desired_relay_addr` here tells that
/// exit path NOT to spawn a reconnect supervisor.
#[tauri::command]
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
internal_deregister(&state.signal, /*keep_desired=*/ false).await;
tracing::info!("deregister: user-requested, desired_relay_addr cleared");
Ok(())
}
/// End the current call, telling the peer via a signal-plane
/// `Hangup` message before tearing down the local media engine.
///
/// Prior to this command existing, the hangup button just called
/// `disconnect` which stopped the local engine but didn't notify
/// the peer — so the OTHER party stayed on the call screen with
/// nothing to hear. The relay DOES notice the media connection
/// closing but doesn't forward anything to the peer on its own,
/// so a real `SignalMessage::Hangup` is the only reliable signal.
///
/// Best-effort: if the signal transport is down (e.g. the relay
/// dropped us mid-call), we still tear down the engine locally
/// and return success. The peer's CallEngine will eventually
/// notice the media side dying and the signal-event hangup
/// handler will fire on receiving it from their signal loop if
/// the relay is still up on their side.
#[tauri::command]
async fn hangup_call(
state: tauri::State<'_, Arc<AppState>>,
app: tauri::AppHandle,
) -> Result<(), String> {
use wzp_proto::SignalMessage;
emit_call_debug(&app, "hangup_call:start", serde_json::json!({}));
// Step 1: send Hangup over the signal channel so the relay
// forwards it to the peer. Do this FIRST so the peer gets
// the notification even if the engine shutdown takes a beat.
{
let sig = state.signal.lock().await;
if let Some(ref transport) = sig.transport {
match transport
.send_signal(&SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
})
.await
{
Ok(()) => {
tracing::info!("hangup_call: Hangup signal sent to relay");
emit_call_debug(&app, "hangup_call:signal_sent", serde_json::json!({}));
}
Err(e) => {
tracing::warn!(error = %e, "hangup_call: failed to send Hangup signal");
emit_call_debug(
&app,
"hangup_call:signal_send_failed",
serde_json::json!({ "error": e.to_string() }),
);
}
}
} else {
tracing::debug!("hangup_call: no signal transport, skipping Hangup send");
emit_call_debug(&app, "hangup_call:no_signal_transport", serde_json::json!({}));
}
}
// Step 2: tear down the local media engine.
let mut engine_lock = state.engine.lock().await;
if let Some(engine) = engine_lock.take() {
engine.stop().await;
emit_call_debug(&app, "hangup_call:engine_stopped", serde_json::json!({}));
} else {
emit_call_debug(&app, "hangup_call:no_engine", serde_json::json!({}));
}
Ok(())
}
// ─── 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 {
engine: Mutex::new(None),
signal: Arc::new(Mutex::new(SignalState {
transport: None, endpoint: None, fingerprint: String::new(), signal_status: "idle".into(),
incoming_call_id: None, incoming_caller_fp: None, incoming_caller_alias: None,
pending_reflect: None,
own_reflex_addr: None,
desired_relay_addr: None,
reconnect_in_progress: false,
})),
});
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_notification::init())
.manage(state)
.setup(|app| {
// Resolve the platform-correct app data dir once at startup so
// every command can read/write the seed without juggling AppHandle.
let data_dir = app
.path()
.app_data_dir()
.map(|p| p.join(".wzp"))
.unwrap_or_else(|_| identity_dir());
// create_dir_all is a no-op if it already exists.
if let Err(e) = std::fs::create_dir_all(&data_dir) {
tracing::warn!("failed to create app data dir {data_dir:?}: {e}");
}
tracing::info!("app data dir: {data_dir:?}");
let _ = APP_DATA_DIR.set(data_dir);
// Load the standalone wzp-native cdylib (Oboe audio bridge) and
// cache its exported function pointers. The library handle is
// kept alive in a 'static OnceLock for the lifetime of the
// process, so CallEngine::start() can invoke its audio FFI
// from anywhere. See src/wzp_native.rs and the incident report
// in docs/incident-tauri-android-init-tcb.md.
#[cfg(target_os = "android")]
{
match wzp_native::init() {
Ok(()) => {
tracing::info!(
"wzp-native loaded: version={} msg=\"{}\"",
wzp_native::version(),
wzp_native::hello()
);
}
Err(e) => {
tracing::warn!("wzp-native init failed: {e}");
}
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
ping_relay, get_identity, get_app_info,
connect, disconnect, toggle_mic, toggle_speaker, get_status,
register_signal, place_call, answer_call, get_signal_status,
get_reflected_address, detect_nat_type,
hangup_call,
deregister,
set_speakerphone, is_speakerphone_on,
get_call_history, get_recent_contacts, clear_call_history,
set_dred_verbose_logs, get_dred_verbose_logs,
set_call_debug_logs, get_call_debug_logs,
])
.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();
}