feat: Tauri desktop GUI app with call engine
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:
Siavash Sameni
2026-04-06 11:25:54 +04:00
parent d1c96cd71f
commit e468454464
21 changed files with 10598 additions and 81 deletions

3103
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ members = [
"crates/wzp-client", "crates/wzp-client",
"crates/wzp-web", "crates/wzp-web",
"crates/wzp-android", "crates/wzp-android",
"desktop/src-tauri",
] ]
[workspace.package] [workspace.package]

View File

@@ -720,18 +720,44 @@ async fn run_live(
use wzp_client::call::JitterTelemetry; use wzp_client::call::JitterTelemetry;
// Audio I/O: either VPIO (OS-level AEC) or separate CPAL streams. // 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 vpio;
let (capture_ring, playout_ring) = if opts.os_aec { 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 = wzp_client::audio_vpio::VpioAudio::start()?;
(vpio.capture_ring().clone(), vpio.playout_ring().clone()) (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)"); 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 { } else {
let capture = AudioCapture::start()?; let capture = AudioCapture::start()?;
let playback = AudioPlayback::start()?; let playback = AudioPlayback::start()?;

2
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
dist/

62
desktop/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

19
desktop/package.json Normal file
View 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"
}
}

View 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"]

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 B

View 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();
}
}

View 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");
}

View 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
View 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
View 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
View 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
View 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,
},
});