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>
188 lines
7.6 KiB
Rust
188 lines
7.6 KiB
Rust
//! featherChat signaling bridge.
|
|
//!
|
|
//! Sends WZP call signaling (Offer/Answer/Hangup) through featherChat's
|
|
//! E2E encrypted WebSocket channel as `WireMessage::CallSignal`.
|
|
//!
|
|
//! Flow:
|
|
//! 1. Client connects to featherChat WS with bearer token
|
|
//! 2. Sends CallOffer as CallSignal(signal_type=Offer, payload=JSON SignalMessage)
|
|
//! 3. Receives CallAnswer as CallSignal(signal_type=Answer, payload=JSON SignalMessage)
|
|
//! 4. Extracts relay address from the answer
|
|
//! 5. Connects QUIC to relay for media
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use wzp_proto::packet::SignalMessage;
|
|
|
|
/// featherChat CallSignal types (mirrors warzone-protocol::message::CallSignalType).
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub enum CallSignalType {
|
|
Offer,
|
|
Answer,
|
|
IceCandidate,
|
|
Hangup,
|
|
Reject,
|
|
Ringing,
|
|
Busy,
|
|
Hold,
|
|
Unhold,
|
|
Mute,
|
|
Unmute,
|
|
Transfer,
|
|
}
|
|
|
|
/// A CallSignal as sent through featherChat's WireMessage.
|
|
/// This is what goes in the `payload` field of `WireMessage::CallSignal`.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct WzpCallPayload {
|
|
/// The WZP SignalMessage (CallOffer, CallAnswer, etc.) serialized as JSON.
|
|
pub signal: SignalMessage,
|
|
/// The relay address to connect to for media (host:port).
|
|
pub relay_addr: Option<String>,
|
|
/// Room name on the relay.
|
|
pub room: Option<String>,
|
|
}
|
|
|
|
/// Parameters for initiating a call through featherChat.
|
|
pub struct CallInitParams {
|
|
/// featherChat server URL (e.g., "wss://chat.example.com/ws").
|
|
pub server_url: String,
|
|
/// Bearer token for authentication.
|
|
pub token: String,
|
|
/// Target peer fingerprint (who to call).
|
|
pub target_fingerprint: String,
|
|
/// Relay address for media transport.
|
|
pub relay_addr: String,
|
|
/// Room name on the relay.
|
|
pub room: String,
|
|
/// Our identity seed for crypto.
|
|
pub seed: [u8; 32],
|
|
}
|
|
|
|
/// Result of a successful call setup.
|
|
pub struct CallSetupResult {
|
|
/// Relay address to connect to.
|
|
pub relay_addr: String,
|
|
/// Room name.
|
|
pub room: String,
|
|
/// The peer's CallAnswer signal (contains ephemeral key, etc.)
|
|
pub answer: SignalMessage,
|
|
}
|
|
|
|
/// Serialize a WZP SignalMessage into a featherChat CallSignal payload string.
|
|
pub fn encode_call_payload(
|
|
signal: &SignalMessage,
|
|
relay_addr: Option<&str>,
|
|
room: Option<&str>,
|
|
) -> String {
|
|
let payload = WzpCallPayload {
|
|
signal: signal.clone(),
|
|
relay_addr: relay_addr.map(|s| s.to_string()),
|
|
room: room.map(|s| s.to_string()),
|
|
};
|
|
serde_json::to_string(&payload).unwrap_or_default()
|
|
}
|
|
|
|
/// Deserialize a featherChat CallSignal payload back to WZP types.
|
|
pub fn decode_call_payload(payload: &str) -> Result<WzpCallPayload, String> {
|
|
serde_json::from_str(payload).map_err(|e| format!("invalid call payload: {e}"))
|
|
}
|
|
|
|
/// Map WZP SignalMessage type to featherChat CallSignalType.
|
|
pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
|
match signal {
|
|
SignalMessage::CallOffer { .. } => CallSignalType::Offer,
|
|
SignalMessage::CallAnswer { .. } => CallSignalType::Answer,
|
|
SignalMessage::IceCandidate { .. } => CallSignalType::IceCandidate,
|
|
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
|
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::LossRecoveryUpdate { .. } => CallSignalType::Offer, // reuse (telemetry)
|
|
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
|
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
|
SignalMessage::Hold => CallSignalType::Hold,
|
|
SignalMessage::Unhold => CallSignalType::Unhold,
|
|
SignalMessage::Mute => CallSignalType::Mute,
|
|
SignalMessage::Unmute => CallSignalType::Unmute,
|
|
SignalMessage::Transfer { .. } => CallSignalType::Transfer,
|
|
SignalMessage::TransferAck => CallSignalType::Offer, // reuse
|
|
SignalMessage::PresenceUpdate { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::RouteQuery { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::RouteResponse { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse
|
|
SignalMessage::FederationHello { .. }
|
|
| SignalMessage::GlobalRoomActive { .. }
|
|
| SignalMessage::GlobalRoomInactive { .. } => CallSignalType::Offer, // relay-only
|
|
SignalMessage::DirectCallOffer { .. } => CallSignalType::Offer,
|
|
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
|
|
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
|
|
SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
|
|
SignalMessage::RegisterPresence { .. }
|
|
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only
|
|
// NAT reflection is a client↔relay control exchange that
|
|
// never crosses the featherChat bridge — if it ever reaches
|
|
// this mapper something is wrong, but we still have to give
|
|
// an answer. "Offer" is the generic catch-all.
|
|
SignalMessage::Reflect
|
|
| SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
|
|
// Phase 4 cross-relay forwarding envelope — strictly a
|
|
// relay-to-relay message, never rides the featherChat
|
|
// bridge. Catch-all mapping for completeness.
|
|
SignalMessage::FederatedSignalForward { .. } => CallSignalType::Offer,
|
|
SignalMessage::MediaPathReport { .. } => CallSignalType::Offer, // control-plane
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use wzp_proto::QualityProfile;
|
|
|
|
#[test]
|
|
fn payload_roundtrip() {
|
|
let signal = SignalMessage::CallOffer {
|
|
identity_pub: [1u8; 32],
|
|
ephemeral_pub: [2u8; 32],
|
|
signature: vec![3u8; 64],
|
|
supported_profiles: vec![QualityProfile::GOOD],
|
|
alias: None,
|
|
};
|
|
|
|
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
|
let decoded = decode_call_payload(&encoded).unwrap();
|
|
|
|
assert_eq!(decoded.relay_addr.unwrap(), "relay.example.com:4433");
|
|
assert_eq!(decoded.room.unwrap(), "myroom");
|
|
assert!(matches!(decoded.signal, SignalMessage::CallOffer { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn signal_type_mapping() {
|
|
let offer = SignalMessage::CallOffer {
|
|
identity_pub: [0; 32],
|
|
ephemeral_pub: [0; 32],
|
|
signature: vec![],
|
|
supported_profiles: vec![],
|
|
alias: None,
|
|
};
|
|
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
|
|
|
let hangup = SignalMessage::Hangup {
|
|
reason: wzp_proto::HangupReason::Normal,
|
|
};
|
|
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
|
|
|
|
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold));
|
|
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold));
|
|
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute));
|
|
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute));
|
|
|
|
let transfer = SignalMessage::Transfer {
|
|
target_fingerprint: "abc".to_string(),
|
|
relay_addr: None,
|
|
};
|
|
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer));
|
|
}
|
|
}
|