Three fixes for Bluetooth audio not working:
1. is_bluetooth_available() now checks for TYPE_BLUETOOTH_A2DP (8) in
addition to TYPE_BLUETOOTH_SCO (7) — many headsets only register as
A2DP until SCO is explicitly started.
2. set_bluetooth_sco(on=true) polls isBluetoothScoOn() for up to 3s
before restarting Oboe. startBluetoothSco() is async — the SCO link
takes 500ms-2s to establish. Without waiting, Oboe opens against
earpiece and audio goes nowhere.
3. Frontend skips redundant set_speakerphone(false) when transitioning
to BT — start_bluetooth_sco() handles speaker-off internally,
avoiding a double Oboe restart.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: Hangup had no call_id field. The relay forwarded hangups to
ALL active calls for a user. When user A hung up call 1 and user B
immediately placed call 2, the relay's processing of A's hangup would
also kill call 2 (race window ~1-2s).
Fix: add optional call_id to Hangup (backwards-compatible via serde
skip_serializing_if). When present, the relay only ends the named call.
Old clients send call_id=None and get the legacy broadcast behavior.
Also: clear pending_path_report in Hangup recv handler and
internal_deregister to prevent stale oneshot channels from blocking
subsequent call setups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Release builds from cargo-tauri are unsigned. After Gradle produces the
APK, zipalign + apksigner now sign it with the release keystore
(android/keystore/wzp-release.jks). Falls back to debug keystore if
release is missing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bluetooth: wire existing AudioRouteManager SCO support through both app
variants. Replace binary speaker toggle with 3-way route cycling
(Earpiece → Speaker → Bluetooth). Tauri side adds JNI bridge functions
(start/stop/query SCO, device availability) and Oboe stream restart.
Network awareness: integrate Android ConnectivityManager to detect
WiFi/cellular transitions and feed them to AdaptiveQualityController
via lock-free AtomicU8 signaling. Enables proactive quality downgrade
and FEC boost on network handoffs.
Build: add --arch flag to build-tauri-android.sh supporting arm64,
armv7, or all (separate per-arch APKs for smaller tester binaries).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PRD 4: Disable IPv6 direct dial/accept temporarily. IPv6 QUIC
handshakes succeed but connections die immediately on datagram
send ("connection lost"). IPv4 candidates work reliably. IPv6
candidates still gathered but filtered at dial time.
PRD 1: Close losing transport after Phase 6 negotiation. The
non-selected transport now gets an explicit QUIC close frame
instead of silently dropping after 30s idle timeout. Prevents
phantom connections from polluting future accept() calls.
PRD 2: Harden accept loop with max 3 stale retries. Stale
connections are explicitly closed (conn.close) and counted.
After 3 stale connections, the accept loop aborts instead of
spinning until the race timeout.
PRD 3: Resource cleanup — close old IPv6 endpoint before
creating a new one in place_call/answer_call. Add Drop impl
to CallEngine so tasks are signalled to stop on ungraceful
shutdown.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The originating relay (where the caller is) never set peer_relay_fp
because the call was created locally. When the callee's answer
arrived via federation, the cross-relay dispatcher handled it but
didn't mark the call as cross-relay. This meant the caller's
MediaPathReport was delivered via local hub.send_to() to a peer
fingerprint that isn't connected locally — silently dropped.
Fix: in the cross-relay answer dispatcher, call
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp)) so the
originating relay knows to forward MediaPathReport via federation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add relay_build field to RegisterPresenceAck so the client logs
which relay version it connected to. Shows in the debug log as
register_signal:ack_received {"relay_build":"f843a93"}.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
MediaPathReport was only delivered via local signal_hub, so calls
between peers on different relays always hit peer_report_timeout
and fell back to relay — even when direct P2P worked perfectly.
Fix: check peer_relay_fp in call_registry (same pattern as
DirectCallAnswer). If the peer is on a remote relay, wrap in
FederatedSignalForward and send via federation link. Also fix
the cross-relay dispatcher to deliver to BOTH caller and callee
(not just caller), since the report can come from either side.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When peers are on different relays, MediaPathReport can't be
forwarded — causing a 3s timeout and false relay fallback even
though direct P2P works perfectly.
Fix: on timeout, if local_direct_ok is true AND the direct
transport's connection is still alive (no close_reason), trust
the direct path instead of falling back to relay. The timeout
indicates a relay forwarding issue, not a direct path failure.
Also fix ALT build paste URL (paste.tbs.manko.yoga not amn.gg).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Acceptor's accept() on the shared signal endpoint can dequeue
a stale QUIC connection from a previous call that the Dialer has
already dropped. This results in "connection lost" errors when
media datagrams are sent — 100% drops on both sides.
Fix: after accepting a connection, check close_reason(). If the
connection is already closed, log a warning and re-accept. Also
verify max_datagram_size() is available before returning.
Additionally: emit transport details (remote addr, max_datagram,
close_reason) in the call_engine_starting debug event so stale
connection issues are visible in the user-facing debug log.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When direct P2P calls show 100% datagram drops, we need to know
WHY send_media() fails. This commit adds:
- Remote address + stable_id logging on A-role accept and D-role
dial success (dual_path.rs) — tells us which candidate won
- Remote address + max_datagram_size on engine transport init —
verifies datagrams are negotiated
- last_send_err in send heartbeat — captures the actual error
from send_datagram() failures
- QuinnTransport::remote_address() helper
Also fixes UI badge: was looking for wrong event name
("dual_path_race_won" → "path_negotiated").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The UI looked for event "connect:dual_path_race_won" which doesn't
exist — the actual event is "connect:path_negotiated" with a
use_direct boolean. Badge always showed "Via Relay" even when the
call was direct P2P.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CLI binary was missing the new caller_build_version and
callee_build_version fields, causing E0063 compile errors on
Linux relay/client builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The peer's MediaPathReport can arrive while our dual_path::race is
still running. Previously, the oneshot was created AFTER the race
completed, so the recv loop had nowhere to deliver the report —
it was silently dropped, causing a 3s timeout and false relay
fallback on ~50% of calls.
Fix: create the oneshot and install it in SignalState BEFORE
starting the race. The oneshot::Receiver buffers the value so the
connect command can read it immediately after the race finishes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add caller_build_version / callee_build_version (git short hash)
to DirectCallOffer and DirectCallAnswer so peers can identify each
other's build in debug logs. Also log own build at register time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The CallSetup enum gained peer_direct_addr and peer_local_addrs
in Phase 5.5 but the wzp-android signal recv match arm was never
updated, breaking cargo ndk builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a dedicated IPv6 QUIC endpoint (IPV6_V6ONLY=1 via socket2)
alongside the existing IPv4 signal endpoint for proper dual-stack
P2P connectivity. Previous [::]:0 dual-stack attempt broke IPv4
on Android; this uses separate sockets per address family like
WebRTC/libwebrtc.
- create_ipv6_endpoint(): socket2-based IPv6-only UDP socket,
tries same port as IPv4 signal EP, falls back to ephemeral
- local_host_candidates(v4_port, v6_port): now gathers IPv6
global-unicast (2000::/3) and unique-local (fc00::/7) addrs
- dual_path::race(): A-role accepts on both v4+v6 via select!,
D-role routes each candidate to matching-AF endpoint
- Graceful fallback: if IPv6 unavailable, .ok() → None → pure
IPv4 behavior identical to pre-Phase-7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes:
## Revert [::]:0 dual-stack sockets → back to 0.0.0.0:0
Android's IPV6_V6ONLY=1 default on some kernels (confirmed on
Nothing Phone) makes [::]:0 IPv6-only, silently killing ALL
IPv4 traffic. This broke P2P direct calls: IPv4 LAN candidates
(172.16.81.x) couldn't complete QUIC handshakes through the
IPv6-only socket, causing local_direct_ok=false and relay
fallback on every call after the first.
Reverted all bind sites to 0.0.0.0:0 (reliable IPv4). IPv6 host
candidates are disabled in local_host_candidates() until a
proper dual-socket approach (one IPv4 + one IPv6 endpoint,
Phase 7) is implemented.
## Fix A (task #35): Oboe playout callback stall auto-restart
The Nothing Phone's Oboe playout callback fires once (cb#0) and
then stops draining the ring on ~50% of cold-launch calls. Fix
D+C (stop+prime from previous commit) didn't help because
audio_stop is a no-op on cold launch.
New approach: self-healing watchdog in audio_write_playout.
Tracks the playout ring's read_idx across writes. If read_idx
hasn't advanced in 50 consecutive writes (~1 second), the Oboe
playout callback has stopped:
1. Log "playout STALL detected"
2. Call wzp_oboe_stop() to tear down the stuck streams
3. Clear both ring buffers (prevent stale data reads)
4. Call wzp_oboe_start() to rebuild fresh streams
5. Log success/failure
6. Return 0 (caller retries on next frame)
This is the same teardown+rebuild that "rejoin" does — but
triggered automatically from the first stalled call instead of
requiring the user to hang up and redial. The watchdog runs
on every write so it fires within 1s of the stall starting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Every QUIC endpoint was bound to 0.0.0.0:0 (IPv4-only). This
silently killed ALL IPv6 host candidates: the Dialer couldn't
send packets to [2a0d:...] addresses (wrong address family on
the socket), and the Acceptor couldn't receive incoming IPv6
QUIC handshakes. The IPv6 candidates were gathered and advertised
in DirectCallOffer/Answer but were completely non-functional.
On same-LAN with dual-stack (which both test phones have), this
meant:
- JoinSet fanned out 3+ candidates (2× IPv6 + 1× IPv4)
- IPv6 dials failed silently or timed out
- IPv4 dial worked but competed with failed IPv6 for JoinSet
attention
- Sometimes the JoinSet returned an IPv6 failure before the
IPv4 success, causing unnecessary fallback to relay
Fix: bind to [::]:0 (IPv6 any) instead of 0.0.0.0:0. On
dual-stack systems (Linux/Android default), [::]:0 creates a
socket that handles BOTH:
- IPv6 natively (global unicast, ULA)
- IPv4 via v4-mapped addresses (::ffff:172.16.81.x)
One socket, both protocols. All 7 bind sites updated:
- register_signal (signal endpoint)
- do_register_signal
- ping_relay
- probe_reflect_addr (fresh endpoint fallback)
- dual_path::race (A-role fresh, D-role fresh, relay fresh)
With this fix, same-LAN P2P should prefer the IPv6 path (no
NAT, direct routing, lower latency) and fall through to IPv4
if IPv6 fails — relay is the last resort after ALL candidates
are exhausted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses the first-join no-audio regression (tasks #35-37) where
the Oboe playout callback fires once (cb#0) and then stops
draining the ring on the Nothing Phone, causing written_samples
to freeze at 7679 (ring capacity minus one burst). Second call
(rejoin) always works because audio_stop tears down the streams
and audio_start rebuilds them fresh.
Two combined fixes:
**Fix D (task #37)**: always call audio_stop() before audio_start()
at the top of CallEngine::start. On a cold launch this is a no-op
(streams not yet started). On subsequent calls it guarantees a
clean teardown before rebuild — the same thing rejoin does. Added
a 50ms pause between stop and start to let the Android HAL release
the audio session.
**Fix C (task #36)**: after audio_start(), immediately write 960
samples (20ms) of silence into the playout ring. This ensures the
Oboe playout callback has data to drain on its first invocation.
On devices where an empty-ring first callback causes the stream
to self-pause (Nothing Phone's Qualcomm HAL), the priming data
keeps the callback loop alive until real decoded audio arrives
from the recv task.
Together these cover the two most likely root causes:
1. Stale Oboe state from a previous audio_start that didn't
clean up properly → Fix D forces a clean rebuild
2. Playout callback self-pausing on an empty ring → Fix C
ensures the ring is non-empty at callback time
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Critical Phase 6 bug: when the negotiation agreed on relay path
but delivered the relay transport via pre_connected_transport,
CallEngine saw is_some() = true → is_direct_p2p = true → skipped
perform_handshake. The relay couldn't authenticate the participant
→ room join silently failed → recv_fr: 0, both sides sending
into the void.
Fix: add explicit is_direct_p2p: bool parameter to CallEngine::
start (both android and desktop branches). The connect command
sets it from the Phase 6 negotiation result (use_direct), not
from whether pre_connected_transport is Some.
Now relay-negotiated calls correctly run perform_handshake,
and direct P2P calls correctly skip it.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The commit de007ec added a heuristic that forced relay-only when
peers had different public IPs. That was a stopgap for the race
condition where one side picked Direct and the other picked Relay.
Phase 6 (f5542ef) solved this properly via MediaPathReport
negotiation, but the heuristic wasn't cleaned up and was still
running BEFORE the Phase 6 code — suppressing the race entirely
for cross-network calls.
Removed. Phase 6 negotiation now handles ALL cases: both sides
race, exchange reports, and agree on the same path before
committing media. Cross-network calls that can't go P2P will
have both sides report direct_ok=false and agree on relay.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Before Phase 6, each side's dual-path race ran independently and
committed to whichever transport completed first. When one side
picked Direct and the other picked Relay, they sent media to
different places — TX > 0 RX: 0 on both, completely silent call.
Phase 6 adds a negotiation step: after the local race completes,
each side sends a MediaPathReport { call_id, direct_ok, winner }
to the peer through the relay. Both wait for the other's report
before committing a transport to the CallEngine. The decision
rule is simple: if BOTH report direct_ok = true, use direct; if
EITHER reports false, BOTH use relay.
## Wire protocol
New `SignalMessage::MediaPathReport { call_id, direct_ok,
race_winner }`. The relay forwards it to the call peer via the
same signal_hub routing used for DirectCallOffer/Answer. The
cross-relay dispatcher also forwards it.
## dual_path::race restructured
Returns `RaceResult` instead of `(Arc<QuinnTransport>, WinningPath)`:
- `direct_transport: Option<Arc<QuinnTransport>>`
- `relay_transport: Option<Arc<QuinnTransport>>`
- `local_winner: WinningPath`
Both paths are run as spawned tasks. After the first completes,
a 1s grace period lets the loser also finish. The connect
command gets BOTH transports (when available) and picks the
right one based on the negotiation outcome. The unused transport
is dropped.
## connect command flow (revised)
1. Run race() → RaceResult with both transports
2. Send MediaPathReport to relay with our direct_ok
3. Install oneshot; wait for peer's report (3s timeout)
4. Decision: both direct_ok → use direct; else → use relay
5. Start CallEngine with the agreed transport
If the peer never responds (old build, timeout), falls back to
relay — backward compatible.
## Relay forwarding
MediaPathReport is forwarded like DirectCallOffer/Answer: via
signal_hub.send_to(peer_fp) for same-relay calls, and via
cross-relay dispatcher for federated calls.
## Debug log events
- `connect:dual_path_race_done` — local race result
- `connect:path_report_sent` — our report to the peer
- `connect:peer_report_received` — peer's report
- `connect:peer_report_timeout` — peer didn't respond (3s)
- `connect:path_negotiated` — final agreed path with reasons
Full workspace test: 423 passing (no regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Race condition: when two phones are on different networks (WiFi
vs LTE, home vs office, etc.), each side's dual-path race runs
independently. One side may pick Direct while the other picks
Relay, causing both to send media to different places — TX > 0,
RX: 0 on both sides, completely silent call.
Root cause: the dual-path race doesn't have a negotiation step.
Each side picks the first transport that completes a QUIC
handshake, which may be a different path than the other side
picked. On same-LAN this doesn't matter because direct always
wins on both (the 500ms relay delay guarantees it). On cross-
network, the asymmetry bites.
Heuristic fix: compare own_reflex_addr IP to peer_reflex_addr
IP. If they're different → different networks → force relay-only
(set role = None, which skips the dual-path race entirely).
Same public IP means same LAN / same NAT:
→ LAN host candidates work, direct always wins on both sides
→ Safe for P2P
Different public IPs means cross-network:
→ Direct may work on one side but not the other
→ Relay is the safe choice for both
This preserves the proven same-LAN P2P and eliminates the broken
cross-network case. The full fix is ICE-style path negotiation
(Phase 6) where both sides exchange connectivity check results
through the signal plane and agree on a winner before committing
media — but that's a 500+ line protocol change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Added warn-level log in handle_datagram when a federation
datagram arrives but no matching local room is found. Prints:
- room_hash (8-byte tag from the datagram)
- active_rooms (all rooms the relay currently has)
- seq + peer label
This diagnoses the cross-relay recv_fr=0 issue: if media IS
arriving from the peer relay but the room hash doesn't match any
active room, the log tells us exactly what hash is expected vs
what rooms exist locally. If no datagram log fires at all, the
issue is upstream (peer relay not forwarding, federation link
down, etc.).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
All rooms with names starting with 'call-' are now treated as
global rooms by the federation pipeline. This enables relay-
mediated media fallback for cross-relay direct calls: when Alice
on Relay A and Bob on Relay B both join the same call-<id> room,
the federation media forwarding pipeline (GlobalRoomActive
announcements + datagram forwarding + presence replication)
kicks in automatically without any runtime registration step.
Previously, cross-relay direct calls that couldn't go P2P
(symmetric NAT on either side) failed with "no media path"
because the call-<id> room wasn't in the configured global_rooms
set and media datagrams weren't forwarded across the federation
link.
The relay's existing ACL for call-* rooms (only the two
authorized fingerprints from the call registry can join)
prevents random clients from creating or eavesdropping on
call rooms.
## Changes
### `is_global_room` (federation.rs)
Added `room.starts_with("call-")` check before the static
global_rooms set lookup. Returns true immediately for any
call-prefixed room.
### `resolve_global_room` (federation.rs)
Return type changed from `Option<&str>` to `Option<String>`
(owned) because call-* room names aren't stored on `self` —
they come from the caller and resolve to themselves as the
canonical name. The 13 callers continue to work via String/&str
auto-deref; 4 HashMap lookups needed explicit `.as_str()` or
`&` borrows.
Full workspace test: 423 passing (no regressions).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Two regressions from Phase 5.5/5.6:
1. Room connect broken: the connect Tauri command required
peerLocalAddrs as a Vec<String>, but the room-join JS path
doesn't pass it (only the direct-call setup handler does).
Error: "invalid args 'peerLocalAddrs' for command 'connect':
command connect missing required key peerLocalAddrs".
Fix: change to Option<Vec<String>>, unwrap_or_default() at
usage sites. Room connect works again with zero peer addrs.
2. Direct P2P call connects but then CallEngine fails with
"expected CallAnswer, got Discriminant(0)". Root cause: after
the dual-path race picked a direct P2P transport, CallEngine
still ran perform_handshake() on it. That handshake is a
relay-specific protocol — sends a CallOffer signal and waits
for CallAnswer back. On a direct QUIC connection to a phone,
there's nobody running accept_handshake, so the handshake
reads garbage from the peer's first media packet and errors.
Fix: track is_direct_p2p = pre_connected_transport.is_some()
and skip perform_handshake when true. The direct connection
is already TLS-encrypted by QUIC, and both peers' identities
were verified through the signal channel (DirectCallOffer/
Answer carry identity_pub + ephemeral_pub + signature). Both
android and desktop branches updated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 e75b045)
only handled the RECEIVE side — auto-dismiss call screen on
recv:Hangup — but the SEND side was still missing.
New Tauri command `hangup_call`:
1. Acquire state.signal.lock(), send SignalMessage::Hangup
over the signal transport (best-effort; log + continue if
signal is down)
2. Acquire state.engine.lock(), stop the CallEngine
JS hangupBtn click handler now calls hangup_call with a fallback
to raw disconnect if the command is missing (older builds).
## 3. Media debug events (engine.rs + lib.rs)
Threaded tauri::AppHandle into CallEngine::start so the send/
recv tasks can emit call-debug events when the user has debug
logs enabled. Added on the Android branch (desktop branch
accepts the arg for API symmetry but doesn't emit yet):
- media:first_send — emitted when the first encoded frame is
handed to the transport. Useful for 1-way audio diagnosis:
if this fires on side A but side B never sees media:first_recv,
A's outbound is broken.
- media:first_recv — emitted when the first packet from the
peer arrives. Mirror of first_send.
- media:send_heartbeat — every 2s with frames_sent, last_rms,
last_pkt_bytes, short_reads, drops. A stalled last_rms
(== 0) tells you the mic isn't producing samples; a frozen
frames_sent tells you the encode pipeline hung.
- media:recv_heartbeat — every 2s with recv_fr, decoded_frames,
last_written, written_samples, decode_errs, codec. Mirror
invariants for the inbound direction.
All four are gated by `call_debug_logs_enabled()` via
`emit_call_debug`, so they only show up in the GUI log when the
user has the Call Flow Debug Logs checkbox on. Tracing::info!
still runs unconditionally so logcat (adb) keeps its copy
regardless.
The `emit_call_debug` fn in lib.rs is now `pub(crate)` so
engine.rs can call it via `crate::emit_call_debug`.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
## 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>
Before Phase 5 WarzonePhone used THREE separate UDP sockets per
client:
1. Signal endpoint (register_signal, client-only)
2. Reflect probe endpoints (one fresh socket per relay probe)
3. Dual-path race endpoint (fresh per call setup)
This broke two things in production on port-preserving NATs
(MikroTik masquerade, most consumer routers):
a. Phase 2 NAT detection was WRONG. Each probe used a fresh
internal port, so MikroTik mapped each one to a different
external port, and the classifier saw "different port per
relay" and labeled it SymmetricPort. The real NAT was
cone-like but measurement via fresh sockets hid that.
b. Phase 3.5 dual-path P2P race was BROKEN. The reflex addr
we advertised in DirectCallOffer was observed by the signal
endpoint's socket. The actual dual-path race listened on a
DIFFERENT fresh socket, on a different internal (and
therefore external) port. Peers dialed the advertised addr
and hit MikroTik's mapping for the signal socket, which
forwarded to the signal endpoint — a client-only endpoint
that doesn't accept incoming connections. Direct path
silently failed, relay always won the race.
Nebula-style fix: one socket for everything. The signal endpoint
is now dual-purpose (client + server_config), and both the
reflect probes and the dual-path race reuse it instead of
creating fresh ones. MikroTik's port-preservation then gives us
a stable external port across all flows → classifier correctly
sees Cone NAT → advertised reflex addr is the actual listening
port → direct dials from peers land on the right socket →
`endpoint.accept()` in the A-role branch of the dual-path race
picks up the incoming connection.
## Changes
### `register_signal` (desktop/src-tauri/src/lib.rs)
- Endpoint now created with `Some(server_config())` instead of
`None`. The socket can now accept incoming QUIC connections as
well as dial outbound.
- Every code path that previously read `sig.endpoint` for the
relay-dial reuse benefits automatically — same socket is now
ALSO listening for peer dials.
### `probe_reflect_addr` (wzp-client/src/reflect.rs)
- New `existing_endpoint: Option<Endpoint>` arg. `Some` reuses
the caller's socket (production: pass the signal endpoint).
`None` creates a fresh one (tests + pre-registration).
- Removed the `drop(endpoint)` at the end — was correct for
fresh endpoints (explicit early socket close) but incorrect
for shared ones. End-of-scope drop does the right thing in
both cases via Arc semantics.
### `detect_nat_type` (wzp-client/src/reflect.rs)
- New `shared_endpoint: Option<Endpoint>` arg, forwarded to
every probe in the JoinSet fan-out. One shared socket means
the classifier sees the true NAT type.
### `detect_nat_type` Tauri command (desktop/src-tauri/src/lib.rs)
- Reads `state.signal.endpoint` and passes it as the shared
endpoint. Falls back to None when not registered. NAT detection
now produces accurate classifications against MikroTik / most
consumer NATs.
### `dual_path::race` (wzp-client/src/dual_path.rs)
- New `shared_endpoint: Option<Endpoint>` arg.
- A-role: when `Some`, reuses it for `accept()`. This is the
critical change — the reflex addr advertised to peers is now
the address listening for incoming direct dials.
- D-role: when `Some`, reuses it for the outbound direct dial.
MikroTik keeps the same external port for the dial as for
the signal flow → direct dial through a cone-mapped NAT.
- Relay path: also reuses the shared endpoint so MikroTik has
a single consistent mapping across the whole call (saves one
extra external port and makes firewall traces cleaner).
- When `None`, falls back to fresh per-role endpoints as before.
### `connect` Tauri command (desktop/src-tauri/src/lib.rs)
- Reads `state.signal.endpoint` once when acquiring own reflex
addr and passes it through to `dual_path::race`.
### Tests
- `wzp-client/tests/dual_path.rs` and
`wzp-relay/tests/multi_reflect.rs` updated to pass `None` for
the new endpoint arg — tests use fresh sockets and that's
fine because the loopback harness doesn't care about
port-preserving NAT behavior.
Full workspace test: 423 passing (no regressions).
## Expected behavior after this commit on real hardware
Behind MikroTik + Starlink-bypass (the reporter's setup):
- Phase 2 NAT detect → **Cone NAT** (was SymmetricPort — false
positive from the measurement artifact)
- Phase 3.5 direct-P2P dial → succeeds for both cone-cone and
cone-CGNAT cases where the remote side was previously blocked
by our own socket mismatch
- LTE ↔ LTE cross-carrier → still likely relay fallback; that's
genuinely strict symmetric and needs Phase 5.5 port prediction.
## Phase 5.5 (next, separate PRD)
Multi-candidate port prediction + ICE-style candidate aggregation
for truly strict symmetric NATs. Not needed for the 95% case —
Phase 5 alone fixes most consumer-router setups.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression from 20375ec: the `signal-event reconnecting` and
`signal-event registered` handlers were assigning to
`directRegistered.textContent`, which is the PARENT element that
holds the entire registered UI — the "Registered — waiting"
header, incoming-call panel, recent-contacts section, call
history, the fingerprint-input bar, and the Call button. Setting
textContent on that parent wiped every child with a single text
node, so after registration the user saw "✅ Registered" with
NOTHING below it — no call input, no history, no call button.
App unusable post-registration.
Fix:
- Add a dedicated `#registered-status` <p> inside the header of
`#direct-registered` (this element already existed as a plain
paragraph without an id; just giving it an id).
- Rewrite both handlers to target that element by id instead of
the parent, so `textContent =` only touches the status line
and leaves the rest of the panel intact.
- The `registered` handler now also explicitly
`registerBtn.classList.add("hidden")` and
`directRegistered.classList.remove("hidden")` so the first
register event correctly reveals the UI. Belt-and-braces for
the transparent-reconnect case too — if the supervisor
re-registers after a drop, the UI stays in the registered
state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
Both sides of the signal channel previously broke their recv loop
on any deserialize error, which meant adding a new variant in one
build silently killed signal connections from peers running an
older build. This bit us during Phase 1 testing: a new client
sending SignalMessage::Reflect to a pre-Phase-1 relay caused the
relay to drop the whole signal connection, which looked like
"Error: not registered" on the next place_call.
Fix:
- New TransportError::Deserialize(String) variant in wzp-proto
carries serde errors as a distinct category.
- wzp-transport/reliable.rs::recv_signal returns Deserialize on
serde_json::from_slice failures (was wrapped in Internal).
- wzp-relay/main.rs signal loop matches on Deserialize → warn +
continue (instead of break).
- desktop/src-tauri/lib.rs recv loop does the same.
Other TransportError variants (ConnectionLost, Io, Internal) still
break the loop — only pure parse failures are recoverable.
This means future SignalMessage variant additions are backward-
compat by construction: older peers will see "unknown variant,
continuing" in their logs while newer peers can keep evolving the
protocol.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Teaches the relay pair to route direct-call signaling across an
existing federation link. Alice on Relay A can now place a direct
call to Bob on Relay B if A and B are federation peers — the
wire protocol, call registry, and signal dispatch all learn to
track and route the cross-relay flow.
Phase 3.5's dual-path QUIC race then carries the media directly
peer-to-peer using the advertised reflex addrs, with zero
changes needed on the client side.
## Wire protocol (wzp-proto)
New `SignalMessage::FederatedSignalForward { inner, origin_relay_fp }`
envelope variant, appended at end of enum — JSON serde is
name-tagged so pre-Phase-4 relays just log "unknown variant" and
drop it. 2 new roundtrip tests (any-inner nesting + single
DirectCallOffer case).
## Call registry (wzp-relay)
`DirectCall.peer_relay_fp: Option<String>` — federation TLS fp
of the peer relay that forwarded the offer/answer for this call.
`None` on local calls, `Some` on cross-relay. Used by the answer
path to route the reply back through the same federation link
instead of trying (and failing) to deliver via local signal_hub.
New `set_peer_relay_fp` setter + 1 new unit test.
## FederationManager (wzp-relay)
Three new methods:
- `local_tls_fp()` — exposes the relay's own federation TLS fp
so main.rs can build `origin_relay_fp` fields.
- `broadcast_signal(msg) -> usize` — fan out any signal message
(in practice `FederatedSignalForward`) to every active peer
link, returning the reach count. Used when Relay A doesn't
know which peer has the target fingerprint.
- `send_signal_to_peer(fp, msg)` — targeted send for the reply
path where the registry already knows which peer relay to
hit.
Plus a new `cross_relay_signal_tx: Mutex<Option<Sender<...>>>`
field that `set_cross_relay_tx()` wires at startup so the
federation `handle_signal` can push unwrapped inner messages
into the main signal dispatcher.
## Federation handle_signal (wzp-relay)
New match arm for `FederatedSignalForward`:
- Loop prevention: drops forwards whose `origin_relay_fp` equals
this relay's own fp (prevents A→B→A echo loops without needing
TTL yet).
- Otherwise pulls the inner message out and pushes it through
`cross_relay_signal_tx` so the main loop's dispatcher task
handles it as if it had arrived locally.
## Main signal loop (wzp-relay)
### DirectCallOffer when target not local
Before falling through to Hangup, try the federation path:
- Wrap the offer in `FederatedSignalForward` with
`origin_relay_fp = this relay's tls_fp`
- `fm.broadcast_signal(forward)` — returns peer count
- If any peers reached, stash the call in local registry with
`caller_reflexive_addr` set, `peer_relay_fp` still None
(broadcast — the answer-side will identify itself when it
replies)
- Send `CallRinging` to caller immediately for UX feedback
- Only if no federation or no peers → legacy Hangup path
### DirectCallAnswer when peer is remote
- Registry lookup now reads both `peer_fingerprint` and
`peer_relay_fp` in one acquisition
- If `peer_relay_fp.is_some()`:
* Reject → forward a `Hangup` over federation via
`send_signal_to_peer` instead of local signal_hub
* Accept → wrap the raw answer in `FederatedSignalForward`,
route to the specific origin peer, then emit the LOCAL
CallSetup to our callee with `peer_direct_addr =
caller_reflexive_addr` (caller is remote; this side only
has the callee)
- If `peer_relay_fp.is_none()` → existing Phase 3 same-relay
path with both CallSetups (caller + callee)
### Cross-relay signal dispatcher task
New long-running task reading `(inner, origin_relay_fp)` from
`cross_relay_rx`. In Phase 4 MVP handles:
- `DirectCallOffer` — if target is local, create the call in
the registry with `peer_relay_fp = origin_relay_fp`, stash
caller addr, deliver offer to local callee. If target isn't
local, drop (no multi-hop in Phase 4 MVP).
- `DirectCallAnswer` — look up local caller by call_id, stash
callee addr, forward raw answer to local caller via
signal_hub, emit local CallSetup with `peer_direct_addr =
callee_reflexive_addr` (peer is local now; this side only
has the caller).
- `CallRinging` — best-effort forward to local caller for UX.
- `Hangup` — logged for now; Phase 4.1 will target by call_id.
## Integration tests
`crates/wzp-relay/tests/cross_relay_direct_call.rs` — 3 tests
that reproduce the main.rs cross-relay dispatcher logic inline
and assert the invariants without spinning up real binaries:
1. `cross_relay_offer_forwards_and_stashes_peer_relay_fp` —
Relay A gets Alice's offer, broadcasts. Relay B's dispatcher
creates the call with `peer_relay_fp = relay_a_tls_fp`.
2. `cross_relay_answer_crosswires_peer_direct_addrs` — full
round trip; both CallSetups (one on each relay) carry the
OTHER party's reflex addr.
3. `cross_relay_loop_prevention_drops_self_sourced_forward` —
explicit loop-prevention check.
Full workspace test goes from 413 → 419 passing. Clippy clean
on touched files.
## Non-goals (deferred to Phase 4.1+)
- Relay-mediated media fallback across federation — if P2P
direct fails (symmetric NAT on either side), the call errors
out with "no media path". Making the existing federation
media pipeline carry ephemeral call-<id> rooms is the Phase
4.1 lift.
- Multi-hop federation (A → B → C). Phase 4 MVP supports a
direct federation link between A and B only.
- Fingerprint → peer-relay routing gossip.
PRD: .taskmaster/docs/prd_phase4_cross_relay_p2p.txt
Tasks: 70-78 all completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>