feat: Tauri desktop GUI app with call engine
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m27s
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m27s
- New desktop/ directory with Tauri v2 + Vite + TypeScript - Rust backend: CallEngine wrapping wzp-client audio + transport - Web frontend: connect screen, in-call screen with participants, mic/speaker mute, keyboard shortcuts (m/s/q) - Dark theme UI, settings persistence via localStorage - Platform-aware --os-aec: warns on Windows/Linux (not yet implemented) - Workspace updated to include desktop/src-tauri Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3103
Cargo.lock
generated
3103
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ members = [
|
||||
"crates/wzp-client",
|
||||
"crates/wzp-web",
|
||||
"crates/wzp-android",
|
||||
"desktop/src-tauri",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -720,18 +720,44 @@ async fn run_live(
|
||||
use wzp_client::call::JitterTelemetry;
|
||||
|
||||
// Audio I/O: either VPIO (OS-level AEC) or separate CPAL streams.
|
||||
#[cfg(feature = "vpio")]
|
||||
#[cfg(all(target_os = "macos", feature = "vpio"))]
|
||||
let vpio;
|
||||
let (capture_ring, playout_ring) = if opts.os_aec {
|
||||
#[cfg(feature = "vpio")]
|
||||
#[cfg(all(target_os = "macos", feature = "vpio"))]
|
||||
{
|
||||
vpio = wzp_client::audio_vpio::VpioAudio::start()?;
|
||||
(vpio.capture_ring().clone(), vpio.playout_ring().clone())
|
||||
}
|
||||
#[cfg(not(feature = "vpio"))]
|
||||
#[cfg(all(target_os = "macos", not(feature = "vpio")))]
|
||||
{
|
||||
anyhow::bail!("--os-aec requires the 'vpio' feature (build with: cargo build --features audio,vpio)");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
warn!("--os-aec on Windows is experimental and not yet tested");
|
||||
warn!("Windows Voice Capture DSP (MFT) AEC is not yet implemented");
|
||||
warn!("falling back to CPAL without AEC — please report issues");
|
||||
let capture = AudioCapture::start()?;
|
||||
let playback = AudioPlayback::start()?;
|
||||
let cr = capture.ring().clone();
|
||||
let pr = playback.ring().clone();
|
||||
std::mem::forget(capture);
|
||||
std::mem::forget(playback);
|
||||
(cr, pr)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
warn!("--os-aec on Linux is experimental and not yet tested");
|
||||
warn!("PipeWire/PulseAudio echo-cancel module AEC is not yet implemented");
|
||||
warn!("falling back to CPAL without AEC — please report issues");
|
||||
let capture = AudioCapture::start()?;
|
||||
let playback = AudioPlayback::start()?;
|
||||
let cr = capture.ring().clone();
|
||||
let pr = playback.ring().clone();
|
||||
std::mem::forget(capture);
|
||||
std::mem::forget(playback);
|
||||
(cr, pr)
|
||||
}
|
||||
} else {
|
||||
let capture = AudioCapture::start()?;
|
||||
let playback = AudioPlayback::start()?;
|
||||
|
||||
2
desktop/.gitignore
vendored
Normal file
2
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
62
desktop/index.html
Normal file
62
desktop/index.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WarzonePhone</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Connect screen -->
|
||||
<div id="connect-screen">
|
||||
<h1>WarzonePhone</h1>
|
||||
<div class="form">
|
||||
<label>Relay
|
||||
<input id="relay" type="text" value="193.180.213.68:4433" />
|
||||
</label>
|
||||
<label>Room
|
||||
<input id="room" type="text" value="android" />
|
||||
</label>
|
||||
<label>Alias
|
||||
<input id="alias" type="text" placeholder="your name" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="os-aec" type="checkbox" checked />
|
||||
OS Echo Cancellation
|
||||
</label>
|
||||
<button id="connect-btn" class="primary">Connect</button>
|
||||
<p id="connect-error" class="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- In-call screen -->
|
||||
<div id="call-screen" class="hidden">
|
||||
<div class="call-header">
|
||||
<div id="room-name" class="room-name"></div>
|
||||
<div id="call-status" class="status">Connected</div>
|
||||
</div>
|
||||
|
||||
<div id="participants" class="participants"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
||||
<span class="icon">🎙</span>
|
||||
<span class="label">Mic</span>
|
||||
</button>
|
||||
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
|
||||
<span class="icon">🔊</span>
|
||||
<span class="label">Speaker</span>
|
||||
</button>
|
||||
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
|
||||
<span class="icon">📞</span>
|
||||
<span class="label">End</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1350
desktop/package-lock.json
generated
Normal file
1350
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
desktop/package.json
Normal file
19
desktop/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "wzp-desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vite": "^6",
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
36
desktop/src-tauri/Cargo.toml
Normal file
36
desktop/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "wzp-desktop"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "WarzonePhone Desktop — encrypted VoIP client"
|
||||
default-run = "wzp-desktop"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
anyhow = "1"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
|
||||
# WarzonePhone crates
|
||||
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 = { path = "../../crates/wzp-client", features = ["audio"] }
|
||||
|
||||
# Platform-specific
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
coreaudio-rs = "0.11"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
desktop/src-tauri/build.rs
Normal file
3
desktop/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
1
desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
desktop/src-tauri/gen/schemas/capabilities.json
Normal file
1
desktop/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
2564
desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
2564
desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2564
desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
2564
desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
desktop/src-tauri/icons/icon.png
Normal file
BIN
desktop/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 B |
288
desktop/src-tauri/src/engine.rs
Normal file
288
desktop/src-tauri/src/engine.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! Call engine for the desktop app — wraps wzp-client audio + transport
|
||||
//! into a clean async interface for Tauri commands.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info};
|
||||
|
||||
use wzp_client::audio_io::{AudioCapture, AudioPlayback};
|
||||
use wzp_client::call::{CallConfig, CallEncoder};
|
||||
use wzp_proto::MediaTransport;
|
||||
|
||||
const FRAME_SAMPLES: usize = 960;
|
||||
|
||||
pub struct ParticipantInfo {
|
||||
pub fingerprint: String,
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
pub struct EngineStatus {
|
||||
pub mic_muted: bool,
|
||||
pub spk_muted: bool,
|
||||
pub participants: Vec<ParticipantInfo>,
|
||||
pub frames_sent: u64,
|
||||
pub frames_received: u64,
|
||||
}
|
||||
|
||||
pub struct CallEngine {
|
||||
running: Arc<AtomicBool>,
|
||||
mic_muted: Arc<AtomicBool>,
|
||||
spk_muted: Arc<AtomicBool>,
|
||||
participants: Arc<Mutex<Vec<ParticipantInfo>>>,
|
||||
frames_sent: Arc<std::sync::atomic::AtomicU64>,
|
||||
frames_received: Arc<std::sync::atomic::AtomicU64>,
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
}
|
||||
|
||||
impl CallEngine {
|
||||
pub async fn start<F>(
|
||||
relay: String,
|
||||
room: String,
|
||||
alias: String,
|
||||
_os_aec: bool,
|
||||
event_cb: F,
|
||||
) -> Result<Self, anyhow::Error>
|
||||
where
|
||||
F: Fn(&str, &str) + Send + Sync + 'static,
|
||||
{
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let relay_addr: SocketAddr = relay.parse()?;
|
||||
|
||||
// Load or generate identity
|
||||
let seed = {
|
||||
let path = {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
|
||||
std::path::PathBuf::from(home).join(".wzp").join("identity")
|
||||
};
|
||||
if path.exists() {
|
||||
if let Ok(hex) = std::fs::read_to_string(&path) {
|
||||
if let Ok(s) = wzp_crypto::Seed::from_hex(hex.trim()) {
|
||||
s
|
||||
} else {
|
||||
wzp_crypto::Seed::generate()
|
||||
}
|
||||
} else {
|
||||
wzp_crypto::Seed::generate()
|
||||
}
|
||||
} else {
|
||||
let s = wzp_crypto::Seed::generate();
|
||||
if let Some(p) = path.parent() {
|
||||
std::fs::create_dir_all(p).ok();
|
||||
}
|
||||
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
|
||||
std::fs::write(&path, hex).ok();
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
let fp = seed.derive_identity().public_identity().fingerprint;
|
||||
info!(%fp, "identity loaded");
|
||||
|
||||
// Connect
|
||||
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
|
||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||
let client_config = wzp_transport::client_config();
|
||||
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config).await?;
|
||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
|
||||
|
||||
// Handshake
|
||||
let _session = wzp_client::handshake::perform_handshake(
|
||||
&*transport,
|
||||
&seed.0,
|
||||
Some(&alias),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!("connected to relay, handshake complete");
|
||||
event_cb("connected", &format!("joined room {room}"));
|
||||
|
||||
// Audio I/O
|
||||
// TODO: support VPIO on macOS when os_aec is true
|
||||
let capture = AudioCapture::start()?;
|
||||
let playback = AudioPlayback::start()?;
|
||||
let capture_ring = capture.ring().clone();
|
||||
let playout_ring = playback.ring().clone();
|
||||
std::mem::forget(capture);
|
||||
std::mem::forget(playback);
|
||||
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let mic_muted = Arc::new(AtomicBool::new(false));
|
||||
let spk_muted = Arc::new(AtomicBool::new(false));
|
||||
let participants: Arc<Mutex<Vec<ParticipantInfo>>> = Arc::new(Mutex::new(vec![]));
|
||||
let frames_sent = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let frames_received = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
|
||||
// Send task
|
||||
let send_t = transport.clone();
|
||||
let send_r = running.clone();
|
||||
let send_mic = mic_muted.clone();
|
||||
let send_fs = frames_sent.clone();
|
||||
tokio::spawn(async move {
|
||||
let config = CallConfig {
|
||||
noise_suppression: false,
|
||||
suppression_enabled: false,
|
||||
..CallConfig::default()
|
||||
};
|
||||
let mut encoder = CallEncoder::new(&config);
|
||||
encoder.set_aec_enabled(false); // OS AEC or none
|
||||
let mut buf = vec![0i16; FRAME_SAMPLES];
|
||||
|
||||
loop {
|
||||
if !send_r.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if capture_ring.available() < FRAME_SAMPLES {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
continue;
|
||||
}
|
||||
capture_ring.read(&mut buf);
|
||||
if send_mic.load(Ordering::Relaxed) {
|
||||
buf.fill(0);
|
||||
}
|
||||
match encoder.encode_frame(&buf) {
|
||||
Ok(pkts) => {
|
||||
for pkt in &pkts {
|
||||
if let Err(e) = send_t.send_media(pkt).await {
|
||||
error!("send: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
send_fs.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => error!("encode: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Recv task (direct playout)
|
||||
let recv_t = transport.clone();
|
||||
let recv_r = running.clone();
|
||||
let recv_spk = spk_muted.clone();
|
||||
let recv_fr = frames_received.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut opus_dec = wzp_codec::create_decoder(wzp_proto::QualityProfile::GOOD);
|
||||
let mut agc = wzp_codec::AutoGainControl::new();
|
||||
let mut pcm = vec![0i16; FRAME_SAMPLES];
|
||||
|
||||
loop {
|
||||
if !recv_r.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(100),
|
||||
recv_t.recv_media(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(pkt))) => {
|
||||
if !pkt.header.is_repair {
|
||||
if let Ok(n) = opus_dec.decode(&pkt.payload, &mut pcm) {
|
||||
agc.process_frame(&mut pcm[..n]);
|
||||
if !recv_spk.load(Ordering::Relaxed) {
|
||||
playout_ring.write(&pcm[..n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
recv_fr.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(e)) => {
|
||||
error!("recv: {e}");
|
||||
break;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Signal task (presence)
|
||||
let sig_t = transport.clone();
|
||||
let sig_r = running.clone();
|
||||
let sig_p = participants.clone();
|
||||
let event_cb = Arc::new(event_cb);
|
||||
let sig_cb = event_cb.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if !sig_r.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
sig_t.recv_signal(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(wzp_proto::SignalMessage::RoomUpdate {
|
||||
participants: parts,
|
||||
..
|
||||
}))) => {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let unique: Vec<ParticipantInfo> = parts
|
||||
.into_iter()
|
||||
.filter(|p| seen.insert((p.fingerprint.clone(), p.alias.clone())))
|
||||
.map(|p| ParticipantInfo {
|
||||
fingerprint: p.fingerprint,
|
||||
alias: p.alias,
|
||||
})
|
||||
.collect();
|
||||
let count = unique.len();
|
||||
*sig_p.lock().await = unique;
|
||||
sig_cb("room-update", &format!("{count} participants"));
|
||||
}
|
||||
Ok(Ok(Some(_))) => {}
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
running,
|
||||
mic_muted,
|
||||
spk_muted,
|
||||
participants,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
transport,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn toggle_mic(&self) -> bool {
|
||||
let was = self.mic_muted.load(Ordering::Relaxed);
|
||||
self.mic_muted.store(!was, Ordering::Relaxed);
|
||||
!was
|
||||
}
|
||||
|
||||
pub fn toggle_speaker(&self) -> bool {
|
||||
let was = self.spk_muted.load(Ordering::Relaxed);
|
||||
self.spk_muted.store(!was, Ordering::Relaxed);
|
||||
!was
|
||||
}
|
||||
|
||||
pub fn status(&self) -> EngineStatus {
|
||||
let parts = self.participants.blocking_lock();
|
||||
EngineStatus {
|
||||
mic_muted: self.mic_muted.load(Ordering::Relaxed),
|
||||
spk_muted: self.spk_muted.load(Ordering::Relaxed),
|
||||
participants: parts
|
||||
.iter()
|
||||
.map(|p| ParticipantInfo {
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
alias: p.alias.clone(),
|
||||
})
|
||||
.collect(),
|
||||
frames_sent: self.frames_sent.load(Ordering::Relaxed),
|
||||
frames_received: self.frames_received.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(self) {
|
||||
self.running.store(false, Ordering::SeqCst);
|
||||
self.transport.close().await.ok();
|
||||
}
|
||||
}
|
||||
155
desktop/src-tauri/src/main.rs
Normal file
155
desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod engine;
|
||||
|
||||
use engine::CallEngine;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct CallEvent {
|
||||
kind: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct Participant {
|
||||
fingerprint: String,
|
||||
alias: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
struct CallStatus {
|
||||
active: bool,
|
||||
mic_muted: bool,
|
||||
spk_muted: bool,
|
||||
participants: Vec<Participant>,
|
||||
encode_fps: u64,
|
||||
recv_fps: u64,
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
engine: Mutex<Option<CallEngine>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect(
|
||||
state: tauri::State<'_, Arc<AppState>>,
|
||||
app: tauri::AppHandle,
|
||||
relay: String,
|
||||
room: String,
|
||||
alias: String,
|
||||
os_aec: bool,
|
||||
) -> Result<String, String> {
|
||||
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, 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
let muted = engine.toggle_mic();
|
||||
Ok(muted)
|
||||
} 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 {
|
||||
let muted = engine.toggle_speaker();
|
||||
Ok(muted)
|
||||
} 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();
|
||||
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,
|
||||
})
|
||||
.collect(),
|
||||
encode_fps: status.frames_sent,
|
||||
recv_fps: status.frames_received,
|
||||
})
|
||||
} else {
|
||||
Ok(CallStatus {
|
||||
active: false,
|
||||
mic_muted: false,
|
||||
spk_muted: false,
|
||||
participants: vec![],
|
||||
encode_fps: 0,
|
||||
recv_fps: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt().init();
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
engine: Mutex::new(None),
|
||||
});
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
connect,
|
||||
disconnect,
|
||||
toggle_mic,
|
||||
toggle_speaker,
|
||||
get_status,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running WarzonePhone Desktop");
|
||||
}
|
||||
33
desktop/src-tauri/tauri.conf.json
Normal file
33
desktop/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"productName": "WarzonePhone",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.wzp.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "WarzonePhone",
|
||||
"width": 400,
|
||||
"height": 640,
|
||||
"resizable": true,
|
||||
"minWidth": 360,
|
||||
"minHeight": 500
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/icon.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
177
desktop/src/main.ts
Normal file
177
desktop/src/main.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
// Elements
|
||||
const connectScreen = document.getElementById("connect-screen")!;
|
||||
const callScreen = document.getElementById("call-screen")!;
|
||||
const relayInput = document.getElementById("relay") as HTMLInputElement;
|
||||
const roomInput = document.getElementById("room") as HTMLInputElement;
|
||||
const aliasInput = document.getElementById("alias") as HTMLInputElement;
|
||||
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
|
||||
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
|
||||
const connectError = document.getElementById("connect-error")!;
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
const participantsDiv = document.getElementById("participants")!;
|
||||
const micBtn = document.getElementById("mic-btn")!;
|
||||
const spkBtn = document.getElementById("spk-btn")!;
|
||||
const hangupBtn = document.getElementById("hangup-btn")!;
|
||||
const statsDiv = document.getElementById("stats")!;
|
||||
|
||||
let statusInterval: number | null = null;
|
||||
|
||||
// Load saved settings
|
||||
const saved = localStorage.getItem("wzp-settings");
|
||||
if (saved) {
|
||||
try {
|
||||
const s = JSON.parse(saved);
|
||||
if (s.relay) relayInput.value = s.relay;
|
||||
if (s.room) roomInput.value = s.room;
|
||||
if (s.alias) aliasInput.value = s.alias;
|
||||
if (s.osAec !== undefined) osAecCheckbox.checked = s.osAec;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
localStorage.setItem(
|
||||
"wzp-settings",
|
||||
JSON.stringify({
|
||||
relay: relayInput.value,
|
||||
room: roomInput.value,
|
||||
alias: aliasInput.value,
|
||||
osAec: osAecCheckbox.checked,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Connect
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
connectError.textContent = "";
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = "Connecting...";
|
||||
saveSettings();
|
||||
|
||||
try {
|
||||
await invoke("connect", {
|
||||
relay: relayInput.value,
|
||||
room: roomInput.value,
|
||||
alias: aliasInput.value,
|
||||
osAec: osAecCheckbox.checked,
|
||||
});
|
||||
showCallScreen();
|
||||
} catch (e: any) {
|
||||
connectError.textContent = String(e);
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = "Connect";
|
||||
}
|
||||
});
|
||||
|
||||
function showCallScreen() {
|
||||
connectScreen.classList.add("hidden");
|
||||
callScreen.classList.remove("hidden");
|
||||
roomName.textContent = roomInput.value;
|
||||
|
||||
// Poll status
|
||||
statusInterval = window.setInterval(pollStatus, 500);
|
||||
}
|
||||
|
||||
function showConnectScreen() {
|
||||
callScreen.classList.add("hidden");
|
||||
connectScreen.classList.remove("hidden");
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = "Connect";
|
||||
if (statusInterval) {
|
||||
clearInterval(statusInterval);
|
||||
statusInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mute buttons
|
||||
micBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const muted: boolean = await invoke("toggle_mic");
|
||||
micBtn.classList.toggle("muted", muted);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
spkBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const muted: boolean = await invoke("toggle_speaker");
|
||||
spkBtn.classList.toggle("muted", muted);
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Hangup
|
||||
hangupBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await invoke("disconnect");
|
||||
} catch {}
|
||||
showConnectScreen();
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (callScreen.classList.contains("hidden")) return;
|
||||
if (e.key === "m") micBtn.click();
|
||||
if (e.key === "s") spkBtn.click();
|
||||
if (e.key === "q") hangupBtn.click();
|
||||
});
|
||||
|
||||
// Status polling
|
||||
interface CallStatus {
|
||||
active: boolean;
|
||||
mic_muted: boolean;
|
||||
spk_muted: boolean;
|
||||
participants: { fingerprint: string; alias: string | null }[];
|
||||
encode_fps: number;
|
||||
recv_fps: number;
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const status: CallStatus = await invoke("get_status");
|
||||
if (!status.active) {
|
||||
showConnectScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update mute state
|
||||
micBtn.classList.toggle("muted", status.mic_muted);
|
||||
spkBtn.classList.toggle("muted", status.spk_muted);
|
||||
|
||||
// Update participants
|
||||
participantsDiv.innerHTML = status.participants
|
||||
.map((p) => {
|
||||
const name = p.alias || "Anonymous";
|
||||
const initial = name.charAt(0).toUpperCase();
|
||||
const fp = p.fingerprint
|
||||
? p.fingerprint.substring(0, 8) + "..."
|
||||
: "";
|
||||
return `
|
||||
<div class="participant">
|
||||
<div class="avatar">${initial}</div>
|
||||
<div class="info">
|
||||
<div class="name">${escapeHtml(name)}</div>
|
||||
<div class="fp">${escapeHtml(fp)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Stats
|
||||
statsDiv.textContent = `TX: ${status.encode_fps} frames | RX: ${status.recv_fps} frames`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Listen for events from backend
|
||||
listen("call-event", (event: any) => {
|
||||
const { kind, message } = event.payload;
|
||||
if (kind === "room-update") {
|
||||
pollStatus();
|
||||
}
|
||||
});
|
||||
258
desktop/src/style.css
Normal file
258
desktop/src/style.css
Normal file
@@ -0,0 +1,258 @@
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--primary: #0f3460;
|
||||
--accent: #e94560;
|
||||
--text: #eee;
|
||||
--text-dim: #888;
|
||||
--green: #4ade80;
|
||||
--red: #ef4444;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Connect screen */
|
||||
#connect-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
#connect-screen h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form input[type="text"] {
|
||||
background: var(--surface);
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form input[type="text"]:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
font-size: 13px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
/* Call screen */
|
||||
#call-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.call-header {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 13px;
|
||||
color: var(--green);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Participants */
|
||||
.participants {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.participant {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #ffffff10;
|
||||
}
|
||||
|
||||
.participant:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.participant .avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.participant .info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.participant .name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.participant .fp {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--surface);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-btn .icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.control-btn .label {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.control-btn.muted {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.control-btn.muted .label {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.control-btn.hangup {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.control-btn.hangup:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.stats {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
padding: 8px;
|
||||
}
|
||||
15
desktop/tsconfig.json
Normal file
15
desktop/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
desktop/vite.config.ts
Normal file
15
desktop/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
build: {
|
||||
target: "esnext",
|
||||
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_DEBUG,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user