aee41a638dbbebee3d92a0a04df359b27eb964c6
42 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0ccf4ed6b5 |
feat(call): media health watchdog — warn user when no audio arrives
When a P2P direct call establishes successfully but the underlying
network path dies (phone switched from WiFi to LTE mid-call, or
cross-relay media forwarding isn't working), the call stays up
silently with recv_fr frozen at 0. No feedback to the user.
New watchdog in the Android recv task: tracks consecutive
heartbeat ticks (2s each) where recv_fr hasn't advanced. After 3
ticks (6s) with no new packets, emits:
- call-event { kind: "media-degraded" } — user-facing warning
banner: "No audio — connection may be lost. Try hanging up and
reconnecting, or switch to a different relay."
- call-debug media:no_recv_timeout for the debug log
If packets resume (recv_fr advances), clears the banner via:
- call-event { kind: "media-recovered" }
JS listener creates/removes a red-tinted banner dynamically at
the top of the call screen. Banner is also cleaned up on
showConnectScreen (call end).
This covers:
- Direct P2P that established on WiFi but died when the phone
switched to LTE (stale NAT mapping, unreachable peer)
- Cross-relay calls where federation media isn't forwarding
(relay not upgraded, not federated, etc.)
- Any other "connected but silent" scenario
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
847699bf66 |
fix(ui): pre-flight ping + cancel button for register
Two UX issues when the selected relay is unreachable (e.g. user
switched from WiFi to LTE and the LAN relay is gone):
1. Pressing Register blocked the UI for ~30s while the QUIC
connect timed out against a dead host. No way to abort.
2. No feedback that the relay was unreachable — just a long
wait followed by a cryptic error.
Fix:
**Pre-flight ping**: before attempting the full register flow,
run `ping_relay` (existing Tauri command, 3s QUIC handshake
timeout). If it fails, immediately show "Server unavailable:
<error>" and re-enable the Register button. No blocking, no
wasted time. If it succeeds, proceed to register_signal.
**Cancel button**: during the register_signal await, the
Register button becomes "Cancel". Tapping it calls `deregister`
which closes the in-flight transport and makes the connect
fail immediately, breaking the await. The button goes back to
"Register on Relay" with a "Registration cancelled" message.
Flow:
[Register] → "Checking..." (disabled, 3s ping) →
ping fails → "Server unavailable" (re-enabled)
ping ok → "Cancel" (enabled, register in flight) →
user taps Cancel → "Registration cancelled" (re-enabled)
register succeeds → registered panel shown
register fails → error shown (re-enabled)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
50e6a50de4 |
feat(ui): phone-style layout for direct calls
The call screen now shows two different layouts depending on whether the call is a 1:1 direct call or a room/group call: **Direct call (directCallPeer set):** - Large centered identicon (96px circular with glow) - Peer name (22px bold) + fingerprint (11px mono) - Connection badge: "P2P Direct" (green), "Via Relay" (blue), or "Connecting..." (yellow) — auto-detected from the call-debug buffer's dual_path_race_won event - Room name header shows the peer's alias/fp instead of "general" - Group participant list is hidden **Room/group call (directCallPeer null):** - Existing group participant list layout — unchanged The badge updates live from pollStatus by scanning the debug buffer for the connect:dual_path_race_won event. If the path was "Direct" → green P2P badge; if "Relay" → blue relay badge. Before the race resolves, shows yellow "Connecting...". directCallView is cleared on showConnectScreen (call end). CSS in style.css: .direct-call-view, .dc-identicon, .dc-name, .dc-fp, .dc-badge with .relay and .connecting modifiers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
0cb8d34b21 |
fix(ui): show peer identity on direct P2P calls instead of "Waiting for participants"
On relay-mediated calls, the relay broadcasts RoomUpdate with the participant list and pollStatus renders it. On direct P2P calls neither peer joins the relay's media room, so RoomUpdate never fires and the UI showed "Waiting for participants..." even though audio was flowing bidirectionally. Fix: track the peer's identity (fingerprint + alias) from the signal plane in a `directCallPeer` variable: - Set on incoming call from the DirectCallOffer (caller_fp + caller_alias) - Set on outgoing call from the Call button click (target_fp) - Cleared on showConnectScreen (call ended) pollStatus now checks: if the engine's participant list is empty AND directCallPeer is set, inject a synthetic participant entry with relay_label = "P2P Direct". The participant row renders with identicon + fingerprint + alias as normal, but grouped under a "P2P Direct" header instead of "This Relay". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
16793be36f |
fix(p2p): Phase 5.6 — direct-path head start + hangup propagation + media debug events
Three fixes from a field-test log where same-LAN calls were
still losing the dual-path race to the relay path, peers were
getting stuck on an empty call screen when the other side
hung up, and 1-way audio was hard to diagnose because the
GUI debug log had no media-level events.
## 1. Direct-path 500ms head start (dual_path.rs)
The race was resolving in ~105ms with Relay winning even when
both phones were on the same MikroTik LAN with valid IPv6 host
candidates. Root cause: the relay dial is a plain outbound QUIC
connect that completes in whatever the client→relay RTT is
(~100ms), while the direct path needs the PEER to also process
its CallSetup, spin up its own race, and complete at least one
LAN dial back to us. That cross-client sequence reliably takes
longer than 100ms, so relay always won.
Fix: delay the relay_fut with `tokio::time::sleep(500ms)` before
starting its connect. Same-LAN direct dials complete in 30-50ms
typically, so the head start gives direct plenty of time to win
cleanly. Users on setups where direct genuinely can't work
(LTE-to-LTE cross-carrier) pay 500ms extra on the relay fallback,
which is invisible for a call setup.
## 2. Hangup propagation via a new hangup_call command (lib.rs + main.ts)
The hangup button was calling `disconnect` which stopped the
local media engine but never sent a SignalMessage::Hangup to
the relay. The peer never got notified and was stuck on the
call screen with silent audio. My earlier fix (commit
|
||
|
|
fa038df057 |
feat(p2p): Phase 5.5 — ICE LAN host candidates (IPv4 + IPv6)
Same-LAN P2P was failing because MikroTik masquerade (like most
consumer NATs) doesn't support NAT hairpinning — the advertised
WAN reflex addr is unreachable from a peer on the same LAN as
the advertiser. Phase 5 got us Cone NAT classification and fixed
the measurement artifact, but same-LAN direct dials still had
nowhere to land.
Phase 5.5 adds ICE-style host candidates: each client enumerates
its LAN-local network interface addresses, includes them in the
DirectCallOffer/Answer alongside the reflex addr, and the
dual-path race fans out to ALL peer candidates in parallel.
Same-LAN peers find each other via their RFC1918 IPv4 + ULA /
global-unicast IPv6 addresses without touching the NAT at all.
Dual-stack IPv6 is in scope from the start — on modern ISPs
(including Starlink) the v6 path often works even when v4
hairpinning doesn't, because there's no NAT on the v6 side.
## Changes
### `wzp_client::reflect::local_host_candidates(port)` (new)
Enumerates network interfaces via `if-addrs` and returns
SocketAddrs paired with the caller's port. Filters:
- IPv4: RFC1918 (10/8, 172.16/12, 192.168/16) + CGNAT (100.64/10)
- IPv6: global unicast (2000::/3) + ULA (fc00::/7)
- Skipped: loopback, link-local (169.254, fe80::), public v4
(already covered by reflex-addr), unspecified
Safe from any thread, one `getifaddrs(3)` syscall.
### Wire protocol (wzp-proto/packet.rs)
Three new `#[serde(default, skip_serializing_if = "Vec::is_empty")]`
fields, backward-compat with pre-5.5 clients/relays by
construction:
- `DirectCallOffer.caller_local_addrs: Vec<String>`
- `DirectCallAnswer.callee_local_addrs: Vec<String>`
- `CallSetup.peer_local_addrs: Vec<String>`
### Call registry (wzp-relay/call_registry.rs)
`DirectCall` gains `caller_local_addrs` + `callee_local_addrs`
Vec<String> fields. New `set_caller_local_addrs` /
`set_callee_local_addrs` setters. Follow the same pattern as
the reflex addr fields.
### Relay cross-wiring (wzp-relay/main.rs)
Both the local-call and cross-relay-federation paths now track
the local_addrs through the registry and inject them into the
CallSetup's peer_local_addrs. Cross-wiring is identical to the
existing peer_direct_addr logic — each party's CallSetup
carries the OTHER party's LAN candidates.
### Client side (desktop/src-tauri/lib.rs)
- `place_call`: gathers local host candidates via
`local_host_candidates(signal_endpoint.local_addr().port())`
and includes them in `DirectCallOffer.caller_local_addrs`.
The port match is critical — it's the Phase 5 shared signal
socket, so incoming dials to these addrs land on the same
endpoint that's already listening.
- `answer_call`: same, AcceptTrusted only (privacy mode keeps
LAN addrs hidden too, for consistency with the reflex addr).
- `connect` Tauri command: new `peer_local_addrs: Vec<String>`
arg. Builds a `PeerCandidates` bundle and passes it to the
dual-path race.
- Recv loop's CallSetup handler: destructures + forwards the
new field to JS via the signal-event payload.
### `dual_path::race` (wzp-client/dual_path.rs)
Signature change: takes `PeerCandidates` (reflex + local Vec)
instead of a single SocketAddr. The D-role branch now fans out
N parallel dials via `tokio::task::JoinSet` — one per candidate
— and the first successful dial wins (losers are aborted
immediately via `set.abort_all()`). Only when ALL candidates
have failed do we return Err; individual candidate failures are
just traced at debug level and the race waits for the others.
LAN host candidates are tried BEFORE the reflex addr in
`PeerCandidates::dial_order()` — they're faster when they work,
and the reflex addr is the fallback for the not-on-same-LAN
case.
### JS side (desktop/main.ts)
`connect` invoke now passes `peerLocalAddrs: data.peer_local_addrs ?? []`
alongside the existing `peerDirectAddr`.
### Tests
All existing test callsites updated for the new Vec<String>
fields (defaults to Vec::new() in tests — they don't exercise
the multi-candidate path). `dual_path.rs` integration tests
wrap the single `dead_peer` / `acceptor_listen_addr` in a
`PeerCandidates { reflexive: Some(_), local: Vec::new() }`.
Full workspace test: 423 passing (same as before 5.5).
## Expected behavior on the reporter's setup
Two phones behind MikroTik, both on the same LAN:
place_call:host_candidates {"local_addrs": ["192.168.88.21:XXX", "2001:...:YY:XXX"]}
recv:DirectCallAnswer {"callee_local_addrs": ["192.168.88.22:ZZZ", "2001:...:WW:ZZZ"]}
recv:CallSetup {"peer_direct_addr":"150.228.49.65:NN",
"peer_local_addrs":["192.168.88.22:ZZZ","2001:...:WW:ZZZ"]}
connect:dual_path_race_start {"peer_reflex":"...","peer_local":[...]}
dual_path: direct dial succeeded on candidate 0 ← LAN v4 wins
connect:dual_path_race_won {"path":"Direct"}
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
8990514417 |
fix(call): default Accept to AcceptTrusted + add log Copy/Share buttons
## Accept button regression — diagnosed from a user log
Field report: incoming call → callee taps Accept → debug log
shows the dual-path race being skipped with
`connect:dual_path_skipped {"has_own":false,"has_peer":true,
"role":"None"}` and the call falling to relay-only on the
callee side.
Root cause: the Accept button was calling `answer_call` with
`mode: 2` which falls through to `AcceptGeneric` (privacy
mode). By design, privacy mode SKIPS the reflex query on the
callee so the callee's IP stays hidden from the caller — but
the side effect is that `own_reflex_addr` never gets cached in
`SignalState`. When `connect` runs a moment later, it sees
`own_reflex_addr = None`, can't compute the deterministic role
for the dual-path race, and falls back to relay.
For a normal VoIP app where P2P is the desired default, the
right behavior is `AcceptTrusted` — which queries reflect,
advertises the callee's addr in the answer, and enables direct
P2P. Privacy mode can come back as a dedicated second button
if anyone actually needs it.
Changed `acceptCallBtn` click handler from `mode: 2` to
`mode: 1`. The next call from a Phase-5 APK should show
`connect:dual_path_race_start` + `connect:dual_path_race_won
{"path":"Direct"}` on a cone-NAT-to-cone-NAT pair.
## Debug log export — new Copy / Share buttons
Field-testing the GUI debug log required me to keep asking the
user to type out what they saw. Added two new buttons next to
Clear:
- **Copy log** — serialises the rolling buffer as plain text
(same HH:MM:SS.mmm format the on-screen panel uses) and
writes to `navigator.clipboard`. Falls back to the old
selection-based `execCommand("copy")` for WebViews that
refuse the new API without a permission prompt.
- **Share** — tries the Web Share API (`navigator.share(...)`)
first. On Android WebView this opens the system share sheet
so the user can send the text straight to a messaging app.
Falls back to clipboard copy on WebViews that don't expose
navigator.share (most desktop ones). Also falls back if the
user cancels the share sheet.
Flash status line below the buttons shows a 2.5s confirmation
("✓ Copied 47 entries") or an error hint. The log is plain
text so anyone can paste a log fragment into a message and
send it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
05ec926317 |
fix(ui): don't nuke the registered panel's children on status update
Regression from
|
||
|
|
b7a48bf13b |
feat(ui): incoming-call ring tone + system notification
Previously: incoming calls silently popped an "Accept/Reject" panel. Easy to miss — no audible cue, no system-level alert if the app was backgrounded. Now the incoming-call path triggers both a synthesized ring tone and a system notification banner. ## Ring tone (desktop/src/main.ts) New `Ringer` class using Web Audio API directly — no external asset files, no new npm dep. Synthesizes a classic NANP two-tone cadence (440Hz + 480Hz sine mix, 2s tone + 4s silence, looped) through an envelope-gated gain node that ramps on/off to avoid clicks. Audible on every Tauri-supported platform because WebView carries Web Audio. - `start()` — lazily creates AudioContext on first use (platforms that require a user gesture for AudioContext creation still work because the incoming-call event is user-adjacent from the webview's perspective), starts setInterval(6000) loop. - `stop()` — clears the timer AND disconnects any active oscillators so there's no tail audio. - Active-nodes array is swept every cycle so it doesn't grow unbounded across long rings. Hooked into signal-event handlers: - `"incoming"` → `ringer.start()` + notifyIncomingCall - `"answered"`, `"setup"`, `"hangup"` → `ringer.stop()` - Accept/Reject button click handlers → `ringer.stop()` as the first thing they do (before any await) ## System notification (desktop/src-tauri + main.ts) Added `tauri-plugin-notification = "2"` to the Tauri app and registered in the builder. Capabilities updated with the four notification permissions. Frontend calls the plugin commands via the generic `invoke` instead of adding `@tauri-apps/plugin-notification` as a JS dep — Tauri plugins expose `plugin:notification|notify` etc. directly. Flow: 1. `is_permission_granted` — check cached 2. If not granted → `request_permission` (Android prompts the user once, cached thereafter) 3. `notify` with title="Incoming call", body="From <alias>" All wrapped in try/catch with console.debug fallback — plugin missing or permission denied is non-fatal, the visible panel + ring tone still alert the user. ## Known gaps (deferred) - Android native system ringtone (RingtoneManager) + full- screen intent for lockscreen-visible ringer. Requires platform-specific Java/Kotlin glue in the Tauri Android shell — bigger lift. - Desktop window flash / taskbar attention-seek on incoming call when app is backgrounded. - Vibration pattern on Android. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
e75b045470 |
fix(ui): auto-dismiss call screen when peer hangs up
Previously: peer hangs up → Rust emits signal-event {type:hangup}
→ JS clears callStatusText + hides incoming panel, but the call
screen stays on with a dangling Hangup button the user has to
press to acknowledge a call that's already over. Dead UX.
Now: the hangup event handler tears down our side of the media
engine via `invoke("disconnect")` and transitions back to the
connect screen when we're currently in the call screen.
Incoming-call panel still hides as before.
`userDisconnected = true` is set so the existing call-event
"disconnected" auto-reconnect path (which fires on transport
drop) doesn't kick in — the peer-hangup signal is an intentional
end-of-call, not a transport blip worth retrying.
Also documented: "not connected" errors from the `disconnect`
command are silently swallowed because they happen when there's
no engine to tear down (e.g. incoming call that was never
answered — caller bailed), which is the correct outcome there.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
20375eceb9 |
feat(signal): transparent reconnect + auto-swap on relay change
Two related UX fixes, same state-machine surface: 1. Relay drops / goes offline / restarts: the client now auto- reconnects in the background instead of silently falling to "not registered" and requiring the user to tap Deregister + Register. 2. User switches relay in settings: client auto-swaps — close old transport, register against new, all transparent. ## Signal state additions (desktop/src-tauri/src/lib.rs) - `SignalState.desired_relay_addr: Option<String>` — what the user CURRENTLY wants. `Some(x)` means "keep me connected to x", `None` means "user explicitly asked for idle". This is the pivot that distinguishes "connection dropped, retry" from "user deregistered, stop". - `SignalState.reconnect_in_progress: bool` — single-flight guard so concurrent triggers (recv-loop exit + manual register_signal + another recv-loop exit after a brief success) don't spawn duplicate supervisors. ## Refactor The old `register_signal` Tauri command was doing the whole connect + Register + spawn-recv-loop flow inline. Split into: - `internal_deregister(signal_state, keep_desired)` — shared teardown helper that nulls out transport/endpoint/call state and optionally clears `desired_relay_addr`. - `do_register_signal(signal_state, app, relay)` — core connect + register + spawn-recv-loop flow, callable from both the Tauri command and the reconnect supervisor. Returns an explicit `impl Future<...> + Send` to avoid auto-trait inference bailing inside the tokio::spawn chain (rustc loses the Send trail through the recv-loop spawn inside the fn body). - `register_signal` Tauri command — now thin: if already registered to the same relay, no-op; otherwise internal_deregister(keep_desired=false), set desired_relay_addr = Some(new), call do_register_signal. The Rust side handles the "change of server" transition entirely on its own, no deregister+register dance from JS needed. - `deregister` Tauri command — internal_deregister(keep_desired = false) so the recv-loop exit path sees the cleared desired addr and does NOT spawn a supervisor. ## Reconnect supervisor New `signal_reconnect_supervisor(signal_state, app, relay)` task. Spawned from the recv-loop exit path when the loop exits unexpectedly AND `desired_relay_addr.is_some()` AND no supervisor is already running. - Exponential backoff: 1s, 2s, 4s, 8s, 15s, 30s (capped at 30s, never gives up). First attempt is immediate (attempt 0 skips the wait). - On each iteration checks whether `desired_relay_addr` was cleared (user deregistered mid-flight) or another path already re-registered; either short-circuits the supervisor. - Also detects if the user changed relays while the supervisor was sleeping — resets the backoff counter and retries against the new addr. - On success, exits so the newly-spawned recv loop owns the connection from that point. If THAT drops again, a fresh supervisor spawns. - Emits `call-debug-log` and `signal-event` events at every state transition so the GUI can display "reconnecting...", "registered" banners. ## UI wiring (desktop/src/main.ts) - signal-event handler gets two new cases: - `"reconnecting"` — amber "🔄 reconnecting to <relay>…" in the registered banner area - `"registered"` — green "✓ registered (<fp prefix>…)" to clear the reconnecting badge - Relay-selection click handler checks if a signal is currently registered and, if the user picked a different relay, fires `register_signal` with the new address. Rust side handles the swap transparently. Full workspace test: 423 passing (no regressions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
00deb97a5d |
fix(reflect): drop LAN/private reflex addrs from NAT classification
Real-world report: a user with one LAN relay + one internet relay got "Multiple IPs — treating as symmetric" because the LAN relay saw the client's LAN IP (172.16.81.172) while the internet relay saw the WAN IP (150.228.49.65). Two observations of "different public IPs" from the classifier's perspective, but semantically they describe two different network paths and shouldn't be compared. The LAN relay's reflection is always true, just not useful for public NAT classification: there's no NAT between the client and the LAN relay, so that path's reflex addr is always the LAN interface IP regardless of what the public-facing NAT beyond it looks like. Fix: new `is_private_or_loopback` helper filters the probe set before classification. Drops: - 127.0.0.0/8 loopback - 10/8, 172.16/12, 192.168/16 RFC1918 private - 169.254/16 link-local - 100.64/10 CGNAT shared-transition (same reasoning: a relay that sees the client with a CGNAT addr is on the same carrier network and can't describe public NAT state) - IPv6 loopback, unspecified, fe80::/10 link-local Failed probes still filtered out of classification (they were already) but now dimmed in the UI list instead of highlighted amber. Same rationale: a momentarily-offline probe target isn't a warning-worthy state, it's just a fact about the probe run. UI palette rebalance: only Cone gets green, everything else neutral text-dim. Wording changed from warning-tone "⚠ must use relay" to informational "ℹ P2P falls back to relay, calls still work" — symmetric NAT isn't broken state, it just means media takes the relay path. Tests added (4 new in wzp_client::reflect): - classify_drops_private_ip_probes — LAN + public → Unknown - classify_drops_loopback_probes — loopback + 2 public → Cone - classify_drops_cgnat_probes — CGNAT + 2 public same-IP- diff-port → SymmetricPort - classify_two_lan_probes_is_unknown_not_cone — all LAN → Unknown Existing multi_reflect integration test updated: two loopback relays now correctly classify as Unknown (because loopback reflex addrs are filtered) with the plumbing-works invariant preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
59ce52f8e8 |
feat(p2p): Phase 3.5 dual-path QUIC race + GUI call-flow debug logs
Two features in one commit because they ship and test together:
Phase 3.5 closes the hole-punching loop and the call-flow debug
logs give the user live visibility into every step of a call so
real-hardware testing of the new P2P path is debuggable.
## Phase 3.5 — dual-path QUIC connect race
Completes the hole-punching work Phase 3 scaffolded. On receiving
a CallSetup with peer_direct_addr, the client now actually races a
direct QUIC handshake against the relay dial and uses whichever
completes first. Symmetric role assignment avoids the two-conns-
per-call problem:
- Both peers compare `own_reflex_addr` vs `peer_reflex_addr`
lexicographically.
- Smaller addr → **Acceptor** (A-role): builds a server-capable
dual endpoint, awaits an incoming QUIC session. Does NOT dial.
- Larger addr → **Dialer** (D-role): builds a client-only
endpoint, dials the peer's addr with `call-<id>` SNI. Does NOT
listen.
- Both sides always dial the relay in parallel as fallback.
- `tokio::select!` with `biased` preference for direct, `tokio::pin!`
so each branch can await the losing opposite as fallback.
- Direct timeout 2s, relay fallback timeout 5s (so 7s worst case
from CallSetup to "no media path" error).
New crate module `wzp_client::dual_path::{race, WinningPath}`
(moved here from desktop/src-tauri so it's testable from a
workspace test). `determine_role` in `wzp_client::reflect` is
pure-function and unit-tested.
### CallEngine integration
- New `pre_connected_transport: Option<Arc<QuinnTransport>>` arg
on both android + desktop `CallEngine::start` branches. Skips
the internal wzp_transport::connect step when Some. Backward-
compat: None keeps Phase 0 relay-only behavior.
- `connect` Tauri command reads own_reflex_addr from SignalState,
computes role, runs the race, passes the winning transport
into CallEngine. If ANY input is missing (no peer addr, no own
addr, equal addrs), falls back to classic relay path —
identical to pre-Phase-3.5 behavior.
### Tests (9 new, all passing)
- 6 unit tests for `determine_role` truth table in
`wzp-client/src/reflect.rs` (smaller=Acceptor, larger=Dialer,
port-only diff, equal, missing-side, symmetry)
- 3 integration tests in `crates/wzp-client/tests/dual_path.rs`:
* `dual_path_direct_wins_on_loopback` — two-endpoint test
rig, Dialer wins direct path vs loopback mock relay
* `dual_path_relay_wins_when_direct_is_dead` — dead peer
port, 2s direct timeout, relay fallback wins
* `dual_path_errors_cleanly_when_both_paths_dead` — <10s
error, no hang
## GUI call-flow debug logs
Runtime-toggled structured events at every step of a call so the
user can see where a call progressed or stalled on real hardware.
Modeled on the existing DRED_VERBOSE_LOGS pattern.
### Rust side
- `static CALL_DEBUG_LOGS: AtomicBool` + `emit_call_debug(&app,
step, details)` helper. Always logs via `tracing::info!`
(logcat always has a copy); GUI Tauri `call-debug-log` event
only fires when the flag is on.
- Tauri commands `set_call_debug_logs` / `get_call_debug_logs`.
### Instrumented steps (24 emit_call_debug sites)
- `register_signal`: start, identity loaded, endpoint created,
connect failed/ok, RegisterPresence sent, ack received/failed,
recv loop spawning
- Recv loop: CallRinging, DirectCallOffer (w/ caller_reflexive_addr),
DirectCallAnswer (w/ callee_reflexive_addr), CallSetup (w/
peer_direct_addr), Hangup
- `place_call`: start, reflect query start/ok/none, offer sent,
send failed
- `answer_call`: start, reflect query start/ok/none or privacy
skip, answer sent, send failed
- `connect`: start, dual_path_race_start (w/ role), won (w/
path), failed, skipped (w/ reasons), call_engine_starting/
started/failed
### JS side
- New `callDebugLogs: boolean` field on Settings type.
- Boot-time hydrate of the Rust flag from localStorage so the
choice survives restarts (like `dredDebugLogs`).
- Settings panel: new "Call flow debug logs" checkbox alongside
the DRED toggle.
- New "Call Debug Log" section that ONLY shows when the flag is
on. Rolling in-memory buffer of the last 200 events, rendered
as monospace `HH:MM:SS.mmm step {details}` lines with auto-
scroll and a Clear button.
- `listen("call-debug-log", ...)` subscribed at app startup,
appends to the buffer, re-renders on every event.
Full workspace test goes from 404 → 413 passing. Clippy clean
on touched crates.
PRD: .taskmaster/docs/prd_phase35_dual_path_race.txt
Tasks: 61-69 all completed
Next: APK + desktop build carrying everything — Phase 2 NAT
detect, Phase 3 advertising, Phase 3.5 dual-path + call debug
logs, plus the earlier Android first-join diagnostics — so the
user can validate the P2P path on real hardware with live
per-step visibility into where any failures happen.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
39277bf3a0 |
feat(hole-punching): advertise peer reflexive addrs in DirectCall flow — Phase 3
Completes the signal-plane plumbing for P2P direct calling: both
peers now learn their own server-reflexive address (Phase 1
Reflect), include it in DirectCallOffer / DirectCallAnswer, and
the relay cross-wires them into each side's CallSetup so the
client knows the OTHER party's direct addr. Dual-path QUIC race
is scaffolded but deferred to Phase 3.5 — this commit ships the
full advertising layer so real-hardware testing can confirm the
addrs flow end-to-end before adding the concurrent-connect logic.
Wire protocol (wzp-proto/src/packet.rs):
- DirectCallOffer gains optional `caller_reflexive_addr`
- DirectCallAnswer gains optional `callee_reflexive_addr`
- CallSetup gains optional `peer_direct_addr`
- All #[serde(default, skip_serializing_if = "Option::is_none")] so
pre-Phase-3 peers and relays stay backward compatible by
construction — the new fields are elided from the JSON on the
wire when None, and older clients parse the JSON ignoring any
fields they don't know.
- 2 new roundtrip tests (Some + None cases, old-JSON parse-back).
Call registry (wzp-relay/src/call_registry.rs):
- DirectCall gains caller_reflexive_addr + callee_reflexive_addr.
- set_caller_reflexive_addr / set_callee_reflexive_addr setters.
- 2 new unit tests: stores and returns addrs, clearing works.
Relay cross-wiring (wzp-relay/src/main.rs):
- On DirectCallOffer: stash the caller's addr in the registry.
- On DirectCallAnswer: stash the callee's addr (only set by
AcceptTrusted answers — privacy-mode leaves it None).
- Send two different CallSetup messages: one to the caller with
peer_direct_addr=callee_addr, and one to the callee with
peer_direct_addr=caller_addr. The cross-wiring means each side
gets the OTHER party's direct addr, not its own.
- Logs `p2p_viable=true` when both sides advertised.
Client advertising (desktop/src-tauri/src/lib.rs):
- New `try_reflect_own_addr` helper that reuses the Phase 1
oneshot pattern WITHOUT holding state.signal.lock() across the
await (critical: the recv loop reacquires the same mutex to
fire the oneshot, so holding it would deadlock).
- `place_call` queries reflect first and includes the returned
addr in DirectCallOffer. Falls back to None on any failure —
call still proceeds via the relay path.
- `answer_call` queries reflect ONLY on AcceptTrusted so
AcceptGeneric keeps the callee's IP private by design. Reject
and AcceptGeneric both pass None.
- recv loop's CallSetup handler destructures and forwards
peer_direct_addr to the JS layer in the signal-event payload.
Client scaffolding for dual-path (desktop/src-tauri/src/lib.rs +
desktop/src/main.ts):
- `connect` Tauri command gets a new optional `peer_direct_addr`
argument. Currently LOGS the addr but still uses the relay
path for the media connection — Phase 3.5 will swap in a
tokio::select! race between direct dial + relay dial. Scaffolding
lands here so the JS wire is stable, real-hardware testing can
confirm advertising works end-to-end, and Phase 3.5 is a pure
Rust change with no JS touches.
- JS setup handler forwards `data.peer_direct_addr` to invoke.
Back-compat with the CLI client (crates/wzp-client/src/cli.rs):
- CLI test harness updated for the new fields — always passes
None for both reflex addrs (no hole-punching). Also destructures
peer_direct_addr: _ in its CallSetup handler.
Tests (8 new, all passing):
- wzp-proto: hole_punching_optional_fields_roundtrip,
hole_punching_backward_compat_old_json_parses
- wzp-relay call_registry: call_registry_stores_reflexive_addrs,
call_registry_clearing_reflex_addr_works
- wzp-relay integration: crates/wzp-relay/tests/hole_punching.rs
* both_peers_advertise_reflex_addrs_cross_wire_in_setup
* privacy_mode_answer_omits_callee_addr_from_setup
* pre_phase3_caller_leaves_both_setups_relay_only
* neither_peer_advertises_both_setups_are_relay_only
Full workspace test goes from 396 → 404 passing.
PRD: .taskmaster/docs/prd_hole_punching.txt
Tasks: 53-60 all completed (58 = scaffolding-only; 3.5 follow-up)
Next up: **Phase 3.5 — dual-path QUIC connect race**. With the
advertising layer live, this becomes a focused change: on
CallSetup-with-peer_direct_addr, start a server-capable dual
endpoint, and tokio::select! across (direct dial, relay dial,
inbound accept). Whichever QUIC handshake completes first wins,
the losers drop, 2s direct timeout falls back to relay.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
8d903f16c6 |
feat(reflect): multi-relay NAT type detection — Phase 2
Builds on Phase 1's SignalMessage::Reflect to probe N relays in
parallel through transient QUIC connections and classify the
client's NAT type for the future P2P hole-punching path. No wire
protocol changes — Phase 1's Reflect/ReflectResponse pair is
reused unchanged.
New client-side module (crates/wzp-client/src/reflect.rs):
- probe_reflect_addr(relay, timeout_ms): opens a throwaway
quinn::Endpoint (fresh ephemeral source port per probe,
essential for NAT-type detection — sharing one endpoint would
make a symmetric NAT look like a cone NAT), connects to _signal,
sends RegisterPresence with zero identity, consumes the Ack,
sends Reflect, awaits ReflectResponse, cleanly closes.
- detect_nat_type(relays, timeout_ms): parallel probes via
tokio::task::JoinSet (bounded by slowest probe not sum) and
returns a NatDetection with per-probe results + aggregate
classification.
- classify_nat(probes): pure-function classifier split out for
network-free unit tests. Rules:
* 0-1 successful probes → Unknown
* 2+ successes, same ip same port → Cone (P2P viable)
* 2+ successes, same ip diff ports → SymmetricPort (relay)
* 2+ successes, different ips → Multiple (treat as
symmetric)
Tauri command (desktop/src-tauri/src/lib.rs):
- detect_nat_type({ relays: [{ name, address }] }) -> NatDetection
as JSON. Takes the relay list from JS because localStorage
owns the config. Parse-up-front so a malformed entry fails
clean instead of as a probe error. 1500ms per-probe timeout.
UI (desktop/index.html + src/main.ts):
- New "NAT type" row + "Detect NAT" button in the Network
settings section. Renders per-probe status (name, address,
observed addr, latency, or error) plus the colored verdict:
* green Cone — shows consensus addr
* amber SymmetricPort / Multiple — must relay
* gray Unknown — not enough data
Tests:
- 7 unit tests in wzp-client/src/reflect.rs covering every
classifier branch (empty, 1 success, 2 identical, 2 diff ports,
2 diff ips, success+failure mix, pure-failure).
- 3 integration tests in crates/wzp-relay/tests/multi_reflect.rs:
* probe_reflect_addr_happy_path — single mock relay end-to-end
* detect_nat_type_two_loopback_relays_is_cone — two concurrent
relays, asserts both see 127.0.0.1 and classifier returns
Cone or SymmetricPort (accepted because the test harness
uses fresh ephemeral ports per probe which look like
SymmetricPort on single-host loopback)
* detect_nat_type_dead_relay_is_unknown — alive + dead port
mix, asserts the dead probe surfaces an error string and
the aggregator returns Unknown (only 1 success)
Full workspace test goes from 386 → 396 passing.
PRD: .taskmaster/docs/prd_multi_relay_reflect.txt
Tasks: 47-52 all completed
Next up: hole-punching (Phase 3) — use the reflected address in
DirectCallOffer/Answer and CallSetup so peers attempt a direct
QUIC handshake to each other, with relay fallback on timeout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
921856eba9 |
feat(reflect): QUIC-native NAT reflection ("STUN for QUIC") — Phase 1
Lets a client ask its registered relay "what IP:port do you see for
me?" over the existing TLS-authenticated signal channel, returning
the client's server-reflexive address as a SocketAddr. Replaces the
need for a classic STUN deployment and becomes the bootstrap step
for future P2P hole-punching: once both peers know their own reflex
addrs, they can advertise them in DirectCallOffer and attempt a
direct QUIC handshake to each other.
Wire protocol (wzp-proto):
- SignalMessage::Reflect — unit variant, client -> relay
- SignalMessage::ReflectResponse { observed_addr: String } — relay -> client
- JSON-serde, appended at end of enum: zero ordinal concerns,
backward compat with pre-Phase-1 relays by construction (older
relays log "unexpected message" and drop; newer clients time out
cleanly within 1s).
Relay handler (wzp-relay/src/main.rs, signal loop):
- New match arm next to Ping reuses the already-bound `addr` from
connection.remote_address() and replies with observed_addr as a
string. debug!-level log on success, warn!-level on send failure.
Client side (desktop/src-tauri/src/lib.rs):
- SignalState gains pending_reflect: Option<oneshot::Sender<SocketAddr>>.
- get_reflected_address Tauri command installs the oneshot before
sending Reflect and awaits it with a 1s timeout; cleans up on
every exit path (send failure, timeout, parse error).
- recv loop's new ReflectResponse arm fires the pending sender or
emits a debug log for unsolicited responses — never crashes the
loop on malformed input.
- Integrated into invoke_handler! alongside the other signal
commands.
UI (desktop/index.html + src/main.ts):
- New "Network" section in settings panel with a "Detect" button
that displays the reflected address or a categorized warning
("register first" / "relay does not support reflection" / error).
Tests (crates/wzp-relay/tests/reflect.rs — 3 new, all passing):
- reflect_happy_path: client on loopback gets back 127.0.0.1:<its own port>
- reflect_two_clients_distinct_ports: two concurrent clients see
their own distinct ports, proving per-connection remote_address
- reflect_old_relay_times_out: mock relay that ignores Reflect —
client times out between 1000-1200ms and does not hang
Also pre-existing test bit-rot unrelated to this PR — fixed so the
full workspace `cargo test` goes green:
- handshake_integration tests in wzp-client, wzp-relay and
featherchat_compat in wzp-crypto all missed the `alias` field
addition to CallOffer and the 3-arg form of perform_handshake
plus 4-tuple return of accept_handshake. Updated to the current
API surface.
Results:
cargo test --workspace --exclude wzp-android: 386 passed
cargo check --workspace: clean
cargo clippy: no new warnings in touched files
Verification excludes wzp-android because it's dead code on this
branch (Tauri mobile uses wzp-native instead) and can't link -llog
on macOS host — unchanged status quo.
PRD: .taskmaster/docs/prd_reflect_over_quic.txt
Tasks: 39-46 all completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
578ff8cff4 |
feat(debug): GUI toggle for DRED verbose logs + macOS mic permission
DRED verbose logs (off by default — keeps logcat clean in normal use): - wzp-codec: DRED_VERBOSE_LOGS atomic flag with dred_verbose_logs() / set_dred_verbose_logs() helpers - opus_enc: gate "DRED enabled" + libopus version logs behind the flag - desktop/src-tauri/engine.rs: gate DredRecvState parse log, reconstruction log, classical PLC log, and DRED-counter fields in the Android recv heartbeat (non-verbose path still logs basic recv stats) - Tauri commands set_dred_verbose_logs / get_dred_verbose_logs - Settings panel gets a "DRED debug logs (verbose, dev only)" checkbox; preference persists in wzp-settings localStorage and is pushed to Rust on save and on app boot macOS mic permission: - Add desktop/src-tauri/Info.plist with NSMicrophoneUsageDescription. Without it, modern macOS silently denies CoreAudio capture for ad-hoc-signed Tauri builds — capture starts but every callback hands you zeros. Symptom: phones could not hear desktop client, desktop could still hear phones (playout has no TCC gate). The Tauri 2 bundler auto-merges this file into WarzonePhone.app's Contents/Info.plist on the next build, so first launch will pop the standard mic prompt. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
5cd7a20152 |
fix(ui): disable WebView pinch-zoom and desktop right-click menu
Two small WebView hardening tweaks that apply to both Android (Tauri mobile) and desktop (Tauri) since the frontend is shared: - index.html viewport meta now sets maximum-scale=1.0, minimum-scale=1.0, and user-scalable=no. This stops users on Android from pinch-zooming out of the fixed-layout UI. Desktop is unaffected because the Tauri WebView ignores pinch gestures anyway. - main.ts installs global listeners that preventDefault on contextmenu (kills the browser-style right-click menu that exposed Inspect / Reload / Back / Forward entries on desktop), keydown Ctrl+-/+/0 (stops keyboard zoom of the fixed layout), and gesture* + ctrl-wheel events (trackpad pinch on WebKit + Chromium respectively). Dev tools remain accessible via F12 / Cmd-Opt-I keyboard shortcuts — only the right-click entry point is suppressed. Android has no right-click so that part is a no-op there. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
d774f5f8c5 |
feat(history): dedupe by call_id + explicit Incoming/Outgoing/Missed labels
User reported that outgoing direct calls from macOS show up in the history list as "missed" even when the call completes successfully. Adds two changes to fix / diagnose: 1. history::log now dedupes by call_id. If an entry for this call_id already exists in the store, it updates the existing row's direction + timestamp in place instead of appending a duplicate. Protects against double-emit (caller side adding Missed on top of Placed, or any future signal loop that fires twice). One row per call_id, which matches what the user intuitively expects. 2. history::log now logs every write with tracing::info — call_id, peer_fp, direction, alias. Plus an extra line when we replace an existing entry: "history::log replacing existing entry from=Placed to=Missed" etc. Makes it easy to see in the desktop stderr which side is writing what, so we can find the outgoing => missed regression immediately if it recurs. 3. main.ts now renders an explicit text label next to the direction arrow: "Outgoing", "Incoming", or "Missed" instead of just the ↗ ↙ ✗ icons. Removes any ambiguity about what the icon means so future users can't misread a Placed entry as Missed based on icon shape alone. Side fix for scripts/build-windows-cloud.sh: - die() and the do_full ERR trap now respect WZP_KEEP_VM=1 so a failed build doesn't auto-destroy the debug VM (previously the trap fired before the KEEP_VM check and tore down the VM on any error). - Bump default server type cx23 → cx33. 4GB RAM is not enough for a cold tauri + rustls + quinn + wzp-client cross-compile — the cx23 run got "Read from remote host ... Connection reset by peer" partway through rustc, which is the classic signature of an OOM kill on the SSH session. cx33 has 8GB RAM and 8 vCPU which should comfortably fit the build. |
||
|
|
510eae2089 |
feat(direct-call): call history, recent contacts, deregister button
Persistent JSON-backed call history for the direct-call screen so users
can see what they've placed / received / missed and dial back with one
click. Also fixes two small latent UX issues reported alongside.
Backend (Rust)
- new crate/module desktop/src-tauri/src/history.rs: thread-safe in-
process store (OnceLock<RwLock<Vec<CallHistoryEntry>>>) backed by
<APP_DATA_DIR>/call_history.json. Atomic writes via temp+rename. Max
200 entries, FIFO pruning. CallDirection { Placed, Received, Missed }.
- Log hooks in the signal loop + commands:
* place_call → Placed entry (with target fingerprint)
* DirectCallOffer → Missed entry up front; upgraded to Received
inside answer_call when accept_mode != Reject
via history::mark_received_if_pending(call_id).
If user rejects or never answers, it stays Missed.
- New Tauri commands:
* get_call_history() → all entries, newest first
* get_recent_contacts() → unique peers by fp, newest interaction first
* clear_call_history() → wipes JSON + in-memory
* deregister() → tears down signal transport + endpoint
Backend emits `history-changed` events so the UI can live-refresh
without polling.
Frontend (main.ts + index.html + style.css)
- Direct-call panel now has:
* Recent contacts chip row (top 6 unique peers). Click a chip → dial.
* Call history list (up to 50 rows). Direction icon (↗ placed, ↙
received, ✗ missed), peer alias/fp, relative timestamp, callback
button. Both click handlers populate target-fp and fire place_call.
* Deregister button in the "registered" header — calls the new
deregister command, tears down the signal transport, returns the
UI to the pre-register state.
* Clear-history link in the history header.
- Subscribes to `history-changed` events so the list updates the moment
the backend logs a new entry. Also refreshed on register + after a
clear.
- Nothing is rendered until there is data — empty sections stay hidden.
Tasks #20 + #21 (small UX items bundled in)
- Default room "general" for new installations: the html input value
attribute is now "general" and loadSettings() defaults match. Existing
users' localStorage still wins.
- Random alias on desktop: already latent but confirmed working — the
startup IIFE at main.ts:374 calls get_app_info() and prefills the
alias input from derive_alias(seed) when the input is empty. No code
change needed, just verified it flows through the same path as the
Android client.
Known follow-ups (deferred to step 6 polish)
- Call duration tracking (currently all entries have no duration field)
- Hangup signal from an unanswered incoming should emit history-changed
so the missed state is visible even when the user never tapped accept
- Android UI layout fit-check on the smaller Nothing screen
|
||
|
|
4c6aac654a |
fix(android-audio): restart Oboe on speakerphone toggle + unbreak button UI
Build
|
||
|
|
0178cbd91d |
android(audio): Speaker button toggles earpiece↔speaker via JNI (WIP, untested)
Build
|
||
|
|
35642d1c54 |
feat(desktop): bake local Laptop relay into default relay list for testing
Adds 172.16.81.125:4433 (the laptop's LAN IP) as the first default relay so the Android rewrite can be tested against a relay whose logs are on the same host as the builds and screenshots. On fresh installs the Laptop relay is pre-selected as index 0. On upgrades from an older cached settings blob, a one-shot migration unshifts it to the front if missing, so we don't have to tap through Manage Relays after every reinstall. Marked "remove once Android rewrite is stable" — the address is a hardcoded LAN IP that won't be valid in other environments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
7639aaf08d |
feat(desktop): deterministic alias from seed + git hash on home screen + fix EACCES on Android
Three home-screen issues from the first Tauri Android APK: 1. Alias was empty (no seed-derived name). Port the adjective+noun word lists from the old Kotlin SettingsRepository into a `derive_alias()` helper that maps the first 4 bytes of the seed to indices in those lists. Same seed → same alias forever, different seeds → effectively random aliases — so reinstalls keep the user's identity AND the friendly name they're used to. 2. Build identity was invisible — couldn't tell which APK was actually installed (this caused us a lot of grief on the Kotlin app). build.rs now captures `git rev-parse --short HEAD` and emits it as `WZP_GIT_HASH`, exposed via a new `get_app_info` command. The frontend stamps `build <hash> • <alias>` under the fingerprint on the home screen. 3. Register on relay failed with `Permission denied (os error 13)`. Root cause: I hardcoded `/data/data/com.wzp.phone/files/.wzp` as the identity dir, but the Tauri Android package id is `com.wzp.desktop` — so the app was trying to write into another app's data directory and getting EACCES at the filesystem layer. Fix: resolve the data dir from Tauri's `path().app_data_dir()` API in the `setup()` callback and stash it in a `OnceLock<PathBuf>`. Works on Android, macOS, Linux, Windows without any cfg gymnastics. Also: `get_app_info` returns the resolved `data_dir` so we can debug storage issues from the UI (it's set as the build-hash element's title). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
2288c1ae07 |
feat: direct calling UI for desktop Tauri app + merge android branch
Tauri backend: - register_signal: persistent _signal connection, presence registration - place_call: send DirectCallOffer by fingerprint - answer_call: accept/reject incoming calls - get_signal_status: poll signal state Frontend: - Mode toggle: "Room" vs "Direct Call" - Register button → registers on relay signal channel - Incoming call panel with Accept/Reject - Fingerprint input + Call button - Auto-connect to media room on CallSetup event Also merges feat/android-voip-client into desktop branch: - Federation fixes, time-based dedup, FEC stale blocks - Direct calling protocol types - ACL + SAS verification Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
395a0c557e |
feat: TX/RX codec badges on desktop call screen
Desktop now shows codec badges like Android: - Green TX badge: e.g. "Opus64k" - Blue RX badge: e.g. "Opus24k" Displayed in the stats line below the call controls. Engine tracks tx_codec (set on encoder init) and rx_codec (updated from incoming packet headers). Passed through EngineStatus → CallStatus → frontend. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
da593f9510 |
feat: relay-grouped participant rendering + relay_label in protocol
RoomParticipant now has optional relay_label field. Desktop client groups participants by relay: "This Relay" (green dot) for local, peer label (blue dot) for federated. Shows all relays in the chain including intermediate ones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
a8c2011445 |
feat: add Opus 32k/48k/64k studio quality tiers
Adds three new codec IDs (Opus32k=6, Opus48k=7, Opus64k=8) and corresponding STUDIO_32K, STUDIO_48K, STUDIO_64K quality profiles. All use 20ms frames with minimal FEC (10%) for maximum quality on good networks. Updated across: wire protocol (codec_id.rs), encoder/decoder (opus_enc/dec.rs), adaptive codec switch (call.rs), CLI (--profile studio-64k), desktop engine + UI slider (8 quality levels from Studio 64k green to Codec2 1.2k red). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
ded49bdb7b |
feat: replace browser confirm with proper key-change warning dialog
When the relay's server key changes (e.g. after restart), show a styled in-app warning dialog instead of the ugly browser confirm(). The dialog shows old vs new fingerprints and lets the user accept the new key or cancel. Accepting updates the saved fingerprint and refreshes the relay button state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
44f04b55e8 |
feat: quality slider in settings with color gradient
Replace the quality dropdown with a range slider in the settings panel. The slider goes from Auto (green) through Opus 24k, Opus 6k (yellow), Codec2 3.2k (orange) to Codec2 1.2k (dark red). The track uses a green-to-red gradient and the label color updates to match the selected level. Removed the quality dropdown from the connect screen — quality is now settings-only. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
85c2146760 |
feat: quality profile selection in desktop settings
Adds a Quality dropdown (Auto / Opus 24k / Opus 6k / Codec2 3.2k / Codec2 1.2k) to both the connect screen and settings panel. The selected profile is passed through to the engine which configures the encoder and decoder accordingly. The desktop engine recv path now auto-switches the decoder codec when incoming packets use a different codec than expected, enabling cross-codec interop between clients on different quality settings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
4df08eadbd |
fix: don't block connect on offline ping — always allow connection attempt
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> |
||
|
|
7806d4ec04 |
feat: identicons, server fingerprints, lock status (TOFU)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m35s
Identicon generator: - Deterministic 5x5 symmetric pattern from fingerprint hash - HSL-derived colors, rendered as inline SVG - Click any identicon to copy its fingerprint to clipboard - Used for participants, user identity, and relay servers Server identity (TOFU — Trust On First Use): - Ping returns server fingerprint (QUIC peer certificate hash) - First contact: auto-saved as known fingerprint - Subsequent pings: compared against known fingerprint - Lock icons: locked (verified), unlocked (new), warning (changed), red (offline) - Fingerprint mismatch shows confirmation dialog before connecting UI updates: - Participants show identicons instead of letter avatars - User identity shows identicon + fingerprint on connect screen - Manage Relays shows identicon per server with lock status - Relay button shows lock icon instead of colored dot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
d31b81a21d |
fix: replace relay dropdown with direct dialog on click
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m53s
- Click relay button opens Manage Relays dialog directly (no dropdown) - Click a relay in the dialog to select it (highlighted with accent border) - × button to delete, Add Relay button to add new - Removed all dropdown menu code and CSS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
c268ce419a |
fix: relay dialog overflow — stack inputs, full-width Add button
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- Dialog fits within 360px window (was overflowing at 420px) - Add inputs stacked: name + host:port in a row, "Add Relay" button below - Text overflow with ellipsis on relay names and addresses - Proper min-width: 0 on flex children to prevent overflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
61b6e67610 |
feat: relay server dropdown with status indicators and manage dialog
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s
- Relay selector as dropdown with green/yellow/red status dots (green < 200ms, yellow > 200ms, red = offline, gray = unknown) - All relays pinged on startup, RTT shown next to each - "Manage Relays..." dialog: add/remove servers, see live status - Clicking a relay in dropdown selects it, fills connect form - Recent room chips auto-select matching relay - Migrates old single-relay settings format automatically - Prevents connecting to offline relays Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
dddf5d2e2d |
feat: relay ping with RTT display, fix dead_code warning
Some checks failed
Build Release Binaries / build-amd64 (push) Has been cancelled
- New ping_relay Tauri command: QUIC connect with 3s timeout, returns RTT ms - Relay status shown next to input field: "42ms" (green) or "offline" (red) - Auto-pings on app startup and debounced on relay input change - Fix SyncWrapper dead_code warning with #[allow(dead_code)] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
ed272d29f8 |
feat: fingerprint at startup, relay+room pairs, auto-reconnect, cleanup
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m34s
#7 Fingerprint shown before connecting — new get_identity command reads ~/.wzp/identity at startup (generates if missing). Click to copy. #8 Recent rooms store (relay, room) pairs — clicking a chip fills both fields. Settings panel shows relay alongside room name. Migrates old string[] format automatically. #9 Auto-reconnect on unexpected disconnect — exponential backoff (1s, 2s, 4s... max 10s), up to 5 attempts. Yellow blinking dot shows reconnecting state. Stops if user clicks hangup. #10 Audio handle cleanup — CPAL handles stored in SyncWrapper (no more mem::forget), dropped properly on CallEngine::stop(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
9b733010ab |
fix: blocking_lock panic in status(), fingerprint copy-to-clipboard
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m13s
- Change status() from blocking_lock to async lock().await — fixes "Cannot block the current thread from within a runtime" panic that froze the call timer and broke audio - Click fingerprint to copy to clipboard (both connect and settings screens) - Show "Copied!" feedback on click Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
4a195a923a |
feat: settings panel with Cmd+, shortcut (macOS standard)
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m48s
- Full settings page as modal overlay (blur backdrop) - Opens via gear icon on connect/call screens or Cmd+, (Ctrl+, on Win/Linux) - Escape or click outside to close - Settings: relay, room, alias, OS AEC toggle, AGC toggle - Identity section showing fingerprint and identity file path - Recent rooms management (remove individual, clear all) - Save syncs back to connect form - Gear icon on both connect and in-call screens Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
f726f8cfa4 |
feat: desktop GUI enhancements — audio level, call timer, VPIO, settings
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m47s
- Audio level meter with log-scale RMS visualization - Call duration timer - VPIO (OS AEC) wired through to engine with fallback to CPAL - "You" badge on own participant entry - Recent rooms list (click to reuse) - Enter key to connect from form fields - Improved dark theme with pulse animation on status dot - Settings persistence via localStorage (relay, room, alias, AEC, recent rooms) - Fingerprint display on connect screen - Keyboard shortcuts skip input fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
e468454464 |
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> |