5 Commits

Author SHA1 Message Date
Siavash Sameni
073756ed4b fix: auto-switch decoder codec to match incoming packets
The CallDecoder now inspects each incoming packet's codec_id and
automatically switches the audio decoder if it differs from the
current profile. This enables cross-codec interop where one client
sends Opus and the other sends Codec2 — previously the receiver
would try to decode with the wrong codec, producing garbled audio.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:25:24 +04:00
Siavash Sameni
2fcc2d77cf feat: add --profile/--codec flag to CLI for forcing codec selection
Enables debugging Codec2 by allowing forced codec selection from CLI.
Supports: good, degraded, catastrophic, codec2-3200, codec2-1200.
Frame size, timing, and jitter buffer are all adjusted dynamically
based on the selected profile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:23:36 +04:00
Siavash Sameni
f7ccb67b02 fix: desktop ping closes endpoint properly, prevents resource leaks
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:00:32 +04:00
Siavash Sameni
4df08eadbd fix: don't block connect on offline ping — always allow connection attempt
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m44s
Server may be reachable even if ping failed (transient timeout).
User should always be able to try connecting. Fingerprint change
still shows confirm dialog (accept/reject).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:20:38 +04:00
Siavash Sameni
6d776097c8 feat: relay ping handling, identity persistence, linux build script (backport)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 40s
Build Release Binaries / build-amd64 (push) Failing after 3m46s
Backported from feat/android-voip-client:
- Relay: SNI "ping" connections handled gracefully (no timeout errors)
- Relay: identity persisted in ~/.wzp/relay-identity (stable fingerprint)
- Linux fire-and-forget build script (Hetzner VM)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:45:27 +04:00
10 changed files with 343 additions and 42 deletions

1
Cargo.lock generated
View File

@@ -7238,6 +7238,7 @@ dependencies = [
"async-trait", "async-trait",
"axum 0.7.9", "axum 0.7.9",
"bytes", "bytes",
"dirs",
"futures-util", "futures-util",
"prometheus", "prometheus",
"quinn", "quinn",

View File

@@ -500,6 +500,49 @@ impl CallDecoder {
} }
} }
/// Switch the decoder to match an incoming packet's codec if it differs
/// from the current profile. This enables cross-codec interop (e.g. one
/// client sends Opus, the other sends Codec2).
fn switch_decoder_if_needed(&mut self, incoming_codec: CodecId) {
if incoming_codec == self.profile.codec || incoming_codec == CodecId::ComfortNoise {
return;
}
let new_profile = Self::profile_for_codec(incoming_codec);
info!(
from = ?self.profile.codec,
to = ?incoming_codec,
"decoder switching codec to match incoming packet"
);
if let Err(e) = self.audio_dec.set_profile(new_profile) {
warn!("failed to switch decoder profile: {e}");
return;
}
self.fec_dec = wzp_fec::create_decoder(&new_profile);
self.profile = new_profile;
}
/// Map a `CodecId` to a reasonable `QualityProfile` for decoding.
fn profile_for_codec(codec: CodecId) -> QualityProfile {
match codec {
CodecId::Opus24k => QualityProfile::GOOD,
CodecId::Opus16k => QualityProfile {
codec: CodecId::Opus16k,
fec_ratio: 0.3,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Opus6k => QualityProfile::DEGRADED,
CodecId::Codec2_3200 => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
CodecId::Codec2_1200 => QualityProfile::CATASTROPHIC,
CodecId::ComfortNoise => QualityProfile::GOOD,
}
}
/// Decode the next audio frame from the jitter buffer. /// Decode the next audio frame from the jitter buffer.
/// ///
/// Returns PCM samples (48kHz mono) or None if not ready. /// Returns PCM samples (48kHz mono) or None if not ready.
@@ -514,6 +557,9 @@ impl CallDecoder {
return Some(pcm.len()); return Some(pcm.len());
} }
// Auto-switch decoder if incoming codec differs from current.
self.switch_decoder_if_needed(pkt.header.codec_id);
self.last_was_cn = false; self.last_was_cn = false;
let result = match self.audio_dec.decode(&pkt.payload, pcm) { let result = match self.audio_dec.decode(&pkt.payload, pcm) {
Ok(n) => Some(n), Ok(n) => Some(n),

View File

@@ -19,12 +19,18 @@ use tracing::{error, info, warn};
use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder};
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz const FRAME_SAMPLES_20MS: usize = 960; // 20ms @ 48kHz
const FRAME_SAMPLES_40MS: usize = 1920; // 40ms @ 48kHz
/// Compute frame samples at 48kHz for a given profile.
fn frame_samples_for(profile: &wzp_proto::QualityProfile) -> usize {
(profile.frame_duration_ms as usize) * 48 // 48000 / 1000
}
/// Generate a sine wave tone. /// Generate a sine wave tone.
fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64) -> Vec<i16> { fn generate_sine_frame(freq_hz: f32, sample_rate: u32, frame_offset: u64, frame_samples: usize) -> Vec<i16> {
let start_sample = frame_offset * FRAME_SAMPLES as u64; let start_sample = frame_offset * frame_samples as u64;
(0..FRAME_SAMPLES) (0..frame_samples)
.map(|i| { .map(|i| {
let t = (start_sample + i as u64) as f32 / sample_rate as f32; let t = (start_sample + i as u64) as f32 / sample_rate as f32;
(f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16 (f32::sin(2.0 * std::f32::consts::PI * freq_hz * t) * 16000.0) as i16
@@ -57,6 +63,8 @@ struct CliArgs {
os_aec: bool, os_aec: bool,
token: Option<String>, token: Option<String>,
_metrics_file: Option<String>, _metrics_file: Option<String>,
/// Force a quality profile: "good", "degraded", "catastrophic", "codec2-3200"
profile_override: Option<String>,
} }
/// Default identity file path: ~/.wzp/identity /// Default identity file path: ~/.wzp/identity
@@ -112,6 +120,27 @@ impl CliArgs {
} }
} }
/// Resolve a profile name to a QualityProfile.
fn resolve_profile(name: &str) -> wzp_proto::QualityProfile {
use wzp_proto::{CodecId, QualityProfile};
match name.to_lowercase().as_str() {
"good" | "opus" | "opus24k" => QualityProfile::GOOD,
"degraded" | "opus6k" => QualityProfile::DEGRADED,
"catastrophic" | "codec2-1200" | "c2-1200" | "1200" => QualityProfile::CATASTROPHIC,
"codec2-3200" | "c2-3200" | "3200" => QualityProfile {
codec: CodecId::Codec2_3200,
fec_ratio: 0.5,
frame_duration_ms: 20,
frames_per_block: 5,
},
other => {
eprintln!("unknown profile: {other}");
eprintln!("valid: good, degraded, catastrophic, codec2-3200, codec2-1200");
std::process::exit(1);
}
}
}
fn parse_args() -> CliArgs { fn parse_args() -> CliArgs {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let mut live = false; let mut live = false;
@@ -136,6 +165,7 @@ fn parse_args() -> CliArgs {
let mut os_aec = false; let mut os_aec = false;
let mut token = None; let mut token = None;
let mut metrics_file = None; let mut metrics_file = None;
let mut profile_override = None;
let mut relay_str = None; let mut relay_str = None;
let mut i = 1; let mut i = 1;
@@ -237,6 +267,14 @@ fn parse_args() -> CliArgs {
.expect("--drift-test value must be a number"), .expect("--drift-test value must be a number"),
); );
} }
"--profile" | "--codec" => {
i += 1;
profile_override = Some(
args.get(i)
.expect("--profile requires a value (good, degraded, catastrophic, codec2-3200)")
.to_string(),
);
}
"--sweep" => sweep = true, "--sweep" => sweep = true,
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]"); eprintln!("Usage: wzp-client [options] [relay-addr]");
@@ -248,6 +286,8 @@ fn parse_args() -> CliArgs {
eprintln!(" --record <file.raw> Record received audio to raw PCM file"); eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test"); eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement"); eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --profile <name> Force quality profile: good, degraded, catastrophic, codec2-3200");
eprintln!(" --codec <name> Alias for --profile");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)"); eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)");
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"); eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)"); eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
@@ -312,6 +352,7 @@ fn parse_args() -> CliArgs {
os_aec, os_aec,
token, token,
_metrics_file: metrics_file, _metrics_file: metrics_file,
profile_override,
} }
} }
@@ -332,12 +373,19 @@ async fn main() -> anyhow::Result<()> {
let seed = cli.resolve_seed(); let seed = cli.resolve_seed();
// Resolve profile override
let profile = cli.profile_override.as_deref().map(resolve_profile);
if let Some(ref p) = profile {
info!(codec = ?p.codec, frame_ms = p.frame_duration_ms, fec = p.fec_ratio, "forced profile");
}
info!( info!(
relay = %cli.relay_addr, relay = %cli.relay_addr,
live = cli.live, live = cli.live,
send_tone = ?cli.send_tone_secs, send_tone = ?cli.send_tone_secs,
record = ?cli.record_file, record = ?cli.record_file,
room = ?cli.room, room = ?cli.room,
profile = ?cli.profile_override,
"WarzonePhone client" "WarzonePhone client"
); );
@@ -400,6 +448,7 @@ async fn main() -> anyhow::Result<()> {
direct_playout: cli.direct_playout, direct_playout: cli.direct_playout,
aec_delay_ms: cli.aec_delay_ms, aec_delay_ms: cli.aec_delay_ms,
os_aec: cli.os_aec, os_aec: cli.os_aec,
profile_override: profile,
}; };
return run_live(transport, audio_opts).await; return run_live(transport, audio_opts).await;
} }
@@ -422,19 +471,23 @@ async fn main() -> anyhow::Result<()> {
transport.close().await?; transport.close().await?;
Ok(()) Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() { } else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file, profile).await
} else { } else {
run_silence(transport).await run_silence(transport, profile).await
} }
} }
/// Send silence frames (connectivity test). /// Send silence frames (connectivity test).
async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::Result<()> { async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>, profile: Option<wzp_proto::QualityProfile>) -> anyhow::Result<()> {
let config = CallConfig::default(); let config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let frame_samples = frame_samples_for(&config.profile);
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let frame_duration = tokio::time::Duration::from_millis(20); let frame_duration = tokio::time::Duration::from_millis(config.profile.frame_duration_ms as u64);
let pcm = vec![0i16; FRAME_SAMPLES]; let pcm = vec![0i16; frame_samples];
let mut total_source = 0u64; let mut total_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -480,13 +533,20 @@ async fn run_file_mode(
send_tone_secs: Option<u32>, send_tone_secs: Option<u32>,
send_file: Option<String>, send_file: Option<String>,
record_file: Option<String>, record_file: Option<String>,
profile: Option<wzp_proto::QualityProfile>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let config = CallConfig::default(); let config = match profile {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let frame_samples = frame_samples_for(&config.profile);
let frame_duration_ms = config.profile.frame_duration_ms as u64;
// --- Send task: generate tone or play file --- // --- Send task: generate tone or play file ---
let send_transport = transport.clone(); let send_transport = transport.clone();
let send_handle = tokio::spawn(async move { let send_handle = tokio::spawn(async move {
// Load PCM frames from file or generate tone // Load PCM frames from file or generate tone
let frames_per_sec = 1000 / frame_duration_ms;
let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file { let pcm_frames: Vec<Vec<i16>> = if let Some(ref path) = send_file {
// Read raw PCM file (48kHz mono s16le) // Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) { let bytes = match std::fs::read(path) {
@@ -498,14 +558,14 @@ async fn run_file_mode(
.collect(); .collect();
let duration = samples.len() as f64 / 48_000.0; let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file"); info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
samples.chunks(FRAME_SAMPLES) samples.chunks(frame_samples)
.filter(|c| c.len() == FRAME_SAMPLES) .filter(|c| c.len() == frame_samples)
.map(|c| c.to_vec()) .map(|c| c.to_vec())
.collect() .collect()
} else if let Some(secs) = send_tone_secs { } else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50; let total = (secs as u64) * frames_per_sec;
info!(seconds = secs, frames = total, "sending 440Hz tone"); info!(seconds = secs, frames = total, frame_samples, frame_ms = frame_duration_ms, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect() (0..total).map(|i| generate_sine_frame(440.0, 48_000, i, frame_samples)).collect()
} else { } else {
// No sending, just wait // No sending, just wait
tokio::signal::ctrl_c().await.ok(); tokio::signal::ctrl_c().await.ok();
@@ -514,7 +574,7 @@ async fn run_file_mode(
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
let _total_frames = pcm_frames.len() as u64; let _total_frames = pcm_frames.len() as u64;
let frame_duration = tokio::time::Duration::from_millis(20); let frame_duration = tokio::time::Duration::from_millis(frame_duration_ms);
let mut total_source = 0u64; let mut total_source = 0u64;
let mut total_repair = 0u64; let mut total_repair = 0u64;
@@ -564,8 +624,13 @@ async fn run_file_mode(
} }
}; };
let mut decoder = CallDecoder::new(&CallConfig::default()); let recv_config = match profile {
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let recv_frame_samples = frame_samples_for(&recv_config.profile);
let mut decoder = CallDecoder::new(&recv_config);
let mut pcm_buf = vec![0i16; recv_frame_samples.max(FRAME_SAMPLES_40MS)];
let mut all_pcm: Vec<i16> = Vec::new(); let mut all_pcm: Vec<i16> = Vec::new();
let mut frames_received = 0u64; let mut frames_received = 0u64;
@@ -704,6 +769,7 @@ struct AudioOpts {
direct_playout: bool, direct_playout: bool,
aec_delay_ms: Option<u32>, aec_delay_ms: Option<u32>,
os_aec: bool, os_aec: bool,
profile_override: Option<wzp_proto::QualityProfile>,
} }
#[cfg(feature = "audio")] #[cfg(feature = "audio")]
@@ -788,12 +854,18 @@ async fn run_live(
std::process::exit(1); std::process::exit(1);
}); });
let base_config = match opts.profile_override {
Some(p) => CallConfig::from_profile(p),
None => CallConfig::default(),
};
let config = CallConfig { let config = CallConfig {
noise_suppression: !opts.no_denoise, noise_suppression: !opts.no_denoise,
suppression_enabled: !opts.no_silence, suppression_enabled: !opts.no_silence,
aec_delay_ms: opts.aec_delay_ms.unwrap_or(40), aec_delay_ms: opts.aec_delay_ms.unwrap_or(40),
..CallConfig::default() ..base_config
}; };
let frame_samples = frame_samples_for(&config.profile);
info!(codec = ?config.profile.codec, frame_samples, frame_ms = config.profile.frame_duration_ms, "call config");
{ {
let mut flags = Vec::new(); let mut flags = Vec::new();
if opts.no_denoise { flags.push("denoise"); } if opts.no_denoise { flags.push("denoise"); }
@@ -819,8 +891,8 @@ async fn run_live(
let mut encoder = CallEncoder::new(&config); let mut encoder = CallEncoder::new(&config);
if no_aec { encoder.set_aec_enabled(false); } if no_aec { encoder.set_aec_enabled(false); }
if no_agc { encoder.set_agc_enabled(false); } if no_agc { encoder.set_agc_enabled(false); }
let mut capture_buf = vec![0i16; FRAME_SAMPLES]; let mut capture_buf = vec![0i16; frame_samples];
let mut farend_buf = vec![0i16; FRAME_SAMPLES]; let mut farend_buf = vec![0i16; frame_samples];
let mut frames_sent: u64 = 0; let mut frames_sent: u64 = 0;
let mut frames_dropped: u64 = 0; let mut frames_dropped: u64 = 0;
let mut send_errors: u64 = 0; let mut send_errors: u64 = 0;
@@ -834,19 +906,19 @@ async fn run_live(
} }
let avail = capture_ring.available(); let avail = capture_ring.available();
if avail < FRAME_SAMPLES { if avail < frame_samples {
tokio::time::sleep(std::time::Duration::from_millis(5)).await; tokio::time::sleep(std::time::Duration::from_millis(5)).await;
polls += 1; polls += 1;
// Diagnostic every 2 seconds // Diagnostic every 2 seconds
if last_diag.elapsed().as_secs() >= 2 { if last_diag.elapsed().as_secs() >= 2 {
info!(avail, polls, frames_sent, "send: ring starved (avail < {FRAME_SAMPLES})"); info!(avail, polls, frames_sent, frame_samples, "send: ring starved");
last_diag = std::time::Instant::now(); last_diag = std::time::Instant::now();
} }
continue; continue;
} }
let read = capture_ring.read(&mut capture_buf); let read = capture_ring.read(&mut capture_buf);
if read < FRAME_SAMPLES { if read < frame_samples {
continue; continue;
} }
@@ -858,7 +930,7 @@ async fn run_live(
// Feed AEC far-end reference: what was played through the speaker. // Feed AEC far-end reference: what was played through the speaker.
// Must be called BEFORE encode_frame processes the mic signal. // Must be called BEFORE encode_frame processes the mic signal.
if !no_aec { if !no_aec {
while send_farend.available() >= FRAME_SAMPLES { while send_farend.available() >= frame_samples {
send_farend.read(&mut farend_buf); send_farend.read(&mut farend_buf);
encoder.feed_aec_farend(&farend_buf); encoder.feed_aec_farend(&farend_buf);
} }
@@ -903,6 +975,8 @@ async fn run_live(
let recv_running = running.clone(); let recv_running = running.clone();
let recv_spk_muted = spk_muted.clone(); let recv_spk_muted = spk_muted.clone();
let direct_playout = opts.direct_playout; let direct_playout = opts.direct_playout;
let recv_profile = opts.profile_override;
let playout_profile = recv_profile; // Copy for playout_task
// Direct playout: decode on recv, write straight to playout ring (like Android). // Direct playout: decode on recv, write straight to playout ring (like Android).
// Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick. // Jitter buffer mode: ingest into jitter buffer, decode on 20ms tick.
@@ -917,14 +991,15 @@ async fn run_live(
let mut packets_received: u64 = 0; let mut packets_received: u64 = 0;
let mut recv_errors: u64 = 0; let mut recv_errors: u64 = 0;
let mut timeouts: u64 = 0; let mut timeouts: u64 = 0;
// For direct playout: raw Opus decoder + AGC // For direct playout: raw codec decoder + AGC
let direct_profile = recv_profile.unwrap_or(wzp_proto::QualityProfile::GOOD);
let mut opus_dec = if direct_playout { let mut opus_dec = if direct_playout {
Some(wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD)) Some(wzp_codec::create_decoder(direct_profile))
} else { } else {
None None
}; };
let mut playout_agc = wzp_codec::AutoGainControl::new(); let mut playout_agc = wzp_codec::AutoGainControl::new();
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; let mut pcm_buf = vec![0i16; frame_samples.max(FRAME_SAMPLES_40MS)];
loop { loop {
if !recv_running.load(Ordering::Relaxed) { if !recv_running.load(Ordering::Relaxed) {
@@ -1019,10 +1094,15 @@ async fn run_live(
return; return;
} }
let config = CallConfig::default(); let playout_config = match playout_profile {
let mut decoder = CallDecoder::new(&config); Some(p) => CallConfig::from_profile(p),
let mut pcm_buf = vec![0i16; FRAME_SAMPLES]; None => CallConfig::default(),
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20)); };
let playout_frame_ms = playout_config.profile.frame_duration_ms as u64;
let playout_frame_samples = frame_samples_for(&playout_config.profile);
let mut decoder = CallDecoder::new(&playout_config);
let mut pcm_buf = vec![0i16; playout_frame_samples.max(FRAME_SAMPLES_40MS)];
let mut interval = tokio::time::interval(std::time::Duration::from_millis(playout_frame_ms));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut telemetry = JitterTelemetry::new(5); let mut telemetry = JitterTelemetry::new(5);
loop { loop {

View File

@@ -28,6 +28,7 @@ prometheus = "0.13"
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] } axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "ws"] }
tower-http = { version = "0.6", features = ["fs"] } tower-http = { version = "0.6", features = ["fs"] }
futures-util = "0.3" futures-util = "0.3"
dirs = "6"
[[bin]] [[bin]]
name = "wzp-relay" name = "wzp-relay"

View File

@@ -13,7 +13,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{error, info}; use tracing::{error, info, warn};
use wzp_proto::MediaTransport; use wzp_proto::MediaTransport;
use wzp_relay::config::RelayConfig; use wzp_relay::config::RelayConfig;
@@ -207,8 +207,39 @@ async fn main() -> anyhow::Result<()> {
tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr)); tokio::spawn(wzp_relay::metrics::serve_metrics(port, m, p, rr));
} }
// Generate ephemeral relay identity for crypto handshake // Load or generate relay identity — persisted in ~/.wzp/relay-identity
let relay_seed = wzp_crypto::Seed::generate(); let relay_seed = {
let config_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".wzp");
let identity_path = config_dir.join("relay-identity");
if identity_path.exists() {
if let Ok(hex) = std::fs::read_to_string(&identity_path) {
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
info!("loaded relay identity from {}", identity_path.display());
s
} else {
warn!("corrupt relay identity file, generating new");
let s = wzp_crypto::Seed::generate();
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&identity_path, &hex);
s
}
} else {
let s = wzp_crypto::Seed::generate();
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&identity_path, &hex);
s
}
} else {
let s = wzp_crypto::Seed::generate();
let _ = std::fs::create_dir_all(&config_dir);
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&identity_path, &hex);
info!("generated relay identity at {}", identity_path.display());
s
}
};
let relay_fp = relay_seed.derive_identity().public_identity().fingerprint; let relay_fp = relay_seed.derive_identity().public_identity().fingerprint;
info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting"); info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting");
@@ -299,6 +330,13 @@ async fn main() -> anyhow::Result<()> {
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
// Ping connections: client just measures QUIC connect RTT.
// No handshake, no streams — client closes immediately after connecting.
if room_name == "ping" {
info!(%addr, "ping connection (RTT probe)");
return;
}
// Probe connections use SNI "_probe" to identify themselves. // Probe connections use SNI "_probe" to identify themselves.
// They skip auth + handshake and just do Ping->Pong + presence gossip. // They skip auth + handshake and just do Ping->Pong + presence gossip.
if room_name == "_probe" { if room_name == "_probe" {

View File

@@ -0,0 +1,8 @@
{
"hash": "9046c0bf",
"configHash": "ef0fc96f",
"lockfileHash": "d66891b1",
"browserHash": "8171ed59",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -55,16 +55,19 @@ async fn ping_relay(relay: String) -> Result<PingResult, String> {
let client_cfg = wzp_transport::client_config(); let client_cfg = wzp_transport::client_config();
let start = std::time::Instant::now(); let start = std::time::Instant::now();
match tokio::time::timeout( let conn_result = tokio::time::timeout(
std::time::Duration::from_secs(3), std::time::Duration::from_secs(3),
wzp_transport::connect(&endpoint, addr, "ping", client_cfg), wzp_transport::connect(&endpoint, addr, "ping", client_cfg),
) )
.await .await;
{
// Always close endpoint to prevent resource leaks
endpoint.close(0u32.into(), b"done");
match conn_result {
Ok(Ok(conn)) => { Ok(Ok(conn)) => {
let rtt_ms = start.elapsed().as_millis() as u32; let rtt_ms = start.elapsed().as_millis() as u32;
// Extract server fingerprint from peer certificate
let server_fingerprint = conn let server_fingerprint = conn
.peer_identity() .peer_identity()
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok()) .and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
@@ -76,7 +79,6 @@ async fn ping_relay(relay: String) -> Result<PingResult, String> {
format!("{h:016x}") format!("{h:016x}")
})) }))
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Fallback: hash the remote address as identifier
format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64) format!("{:x}", addr.ip().to_string().len() as u64 * 0x9e3779b97f4a7c15 + addr.port() as u64)
}); });

View File

@@ -368,7 +368,7 @@ async function doConnect() {
saveSettingsObj(s); saveSettingsObj(s);
} }
if (ls === "offline") { connectError.textContent = "Relay is offline"; return; } // Don't block connect on offline — ping may have failed transiently
connectError.textContent = ""; connectError.textContent = "";
connectBtn.disabled = true; connectBtn.disabled = true;

122
scripts/build-linux-notify.sh Executable file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env bash
set -euo pipefail
# Build WarzonePhone Linux x86_64 binaries via Hetzner Cloud VPS.
# Fire and forget — notifies via ntfy.sh/wzp with rustypaste URL.
#
# Usage:
# ./scripts/build-linux-notify.sh Full: create VM → build → upload → notify → destroy
# ./scripts/build-linux-notify.sh --keep Keep VM after build
# ./scripts/build-linux-notify.sh --pull Git pull (for existing VM)
SSH_KEY_NAME="wz"
SSH_KEY_PATH="/Users/manwe/CascadeProjects/wzp"
SERVER_TYPE="cx33"
IMAGE="debian-12"
SERVER_NAME="wzp-linux-builder"
NTFY_TOPIC="https://ntfy.sh/wzp"
LOCAL_OUTPUT="target/linux-x86_64"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15 -o ServerAliveInterval=15 -o LogLevel=ERROR"
KEEP_VM=0
DO_PULL=0
for arg in "$@"; do
case "$arg" in
--keep) KEEP_VM=1 ;;
--pull) DO_PULL=1 ;;
esac
done
log() { echo -e "\033[1;36m>>> $*\033[0m"; }
err() { echo -e "\033[1;31mERROR: $*\033[0m" >&2; }
get_vm_ip() {
hcloud server list -o columns=name,ipv4 -o noheader 2>/dev/null | grep "$SERVER_NAME" | awk '{print $2}' | tr -d ' '
}
ssh_cmd() {
local ip=$(get_vm_ip)
[ -n "$ip" ] || { err "No VM found"; exit 1; }
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip" "$@"
}
notify() { curl -s -d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true; }
# --- Create VM if needed ---
existing=$(hcloud server list -o columns=name -o noheader 2>/dev/null | grep "$SERVER_NAME" | tr -d ' ' || true)
if [ -z "$existing" ]; then
log "Creating Hetzner VM ($SERVER_TYPE, $IMAGE)..."
hcloud server create --name "$SERVER_NAME" --type "$SERVER_TYPE" --image "$IMAGE" --ssh-key "$SSH_KEY_NAME" --location fsn1 --quiet
log "Waiting for SSH..."
ip=$(get_vm_ip)
for i in $(seq 1 30); do
ssh $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip" "echo ok" &>/dev/null && break
sleep 2
done
log "Installing deps..."
ssh_cmd "apt-get update -qq && apt-get install -y -qq build-essential cmake pkg-config libasound2-dev libssl-dev curl git > /dev/null 2>&1"
ssh_cmd "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable > /dev/null 2>&1"
fi
# --- Upload source ---
log "Uploading source..."
ip=$(get_vm_ip)
rsync -az --delete \
--exclude='target' --exclude='.git' --exclude='.claude' \
--exclude='node_modules' --exclude='dist' --exclude='android/app/build' \
-e "ssh $SSH_OPTS -i $SSH_KEY_PATH" \
"$PROJECT_DIR/" "root@$ip:/root/wzp-build/"
# --- Build ---
log "Building all binaries..."
notify "WZP Linux build started..."
ssh_cmd "source ~/.cargo/env && cd /root/wzp-build && \
cargo build --release --bin wzp-relay --bin wzp-client --bin wzp-web --bin wzp-bench 2>&1 | tail -5 && \
echo '--- audio client ---' && \
cargo build --release --bin wzp-client --features audio 2>&1 | tail -3 && \
cp target/release/wzp-client target/release/wzp-client-audio && \
cargo build --release --bin wzp-client 2>&1 | tail -3 && \
echo 'BUILD_DONE' && \
ls -lh target/release/wzp-relay target/release/wzp-client target/release/wzp-client-audio target/release/wzp-web target/release/wzp-bench"
# --- Package + upload to rustypaste ---
log "Packaging and uploading..."
UPLOAD_URL=$(ssh_cmd "cd /root/wzp-build && \
tar czf /tmp/wzp-linux-x86_64.tar.gz \
-C target/release wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench \
-C /root/wzp-build/crates/wzp-web/static index.html audio-processor.js 2>/dev/null && \
curl -s -F 'file=@/tmp/wzp-linux-x86_64.tar.gz' \
-H 'Authorization: DAxAAGghkn1WKv1+RpPKkg==' \
https://paste.dk.manko.yoga")
if [ -n "$UPLOAD_URL" ]; then
notify "WZP Linux binaries ready! $UPLOAD_URL"
log "Uploaded: $UPLOAD_URL"
else
notify "WZP Linux build FAILED"
err "Upload failed"
fi
# --- Transfer locally ---
log "Downloading binaries..."
mkdir -p "$LOCAL_OUTPUT"
for bin in wzp-relay wzp-client wzp-client-audio wzp-web wzp-bench; do
scp $SSH_OPTS -i "$SSH_KEY_PATH" "root@$ip:/root/wzp-build/target/release/$bin" "$LOCAL_OUTPUT/$bin" 2>/dev/null
done
ls -lh "$LOCAL_OUTPUT"/wzp-*
# --- Cleanup ---
if [ "$KEEP_VM" = "1" ]; then
log "VM kept alive. Destroy: hcloud server delete $SERVER_NAME"
else
log "Destroying VM..."
hcloud server delete "$SERVER_NAME"
fi
log "Done!"
echo " Deploy: scp $LOCAL_OUTPUT/wzp-relay user@server:~/wzp/"