feat(ui): direct-only mode setting (no relay fallback)
Some checks failed
Mirror to GitHub / mirror (push) Failing after 24s
Build Release Binaries / build-amd64 (push) Failing after 3m38s

New toggle in Settings → "Direct-only mode (no relay fallback)":
- Default: OFF (normal behavior, relay fallback on P2P failure)
- When ON: connect returns error if P2P fails, with full
  candidate_diags in the debug log showing why each candidate
  failed. Call never falls back to relay.

Useful for testing NAT traversal — you see the exact failure
reason instead of the call silently working through relay.

Wired end-to-end:
- Settings.directOnly persisted in localStorage
- Passed as directOnly param to Rust connect command
- connect:path_negotiated shows direct_only flag
- connect:direct_only_failed emits on failure with diags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-14 16:04:45 +04:00
parent 1de280fe04
commit 6c49d7436f
3 changed files with 31 additions and 0 deletions

View File

@@ -187,6 +187,10 @@
<input id="s-call-debug" type="checkbox" /> <input id="s-call-debug" type="checkbox" />
Call flow debug logs (trace every step of a call) Call flow debug logs (trace every step of a call)
</label> </label>
<label class="checkbox">
<input id="s-direct-only" type="checkbox" />
Direct-only mode (no relay fallback — fails if P2P can't connect)
</label>
</div> </div>
<div class="settings-section" id="s-call-debug-section" style="display:none"> <div class="settings-section" id="s-call-debug-section" style="display:none">
<h3>Call Debug Log</h3> <h3>Call Debug Log</h3>

View File

@@ -333,13 +333,19 @@ async fn connect(
// Phase 8 (Tailscale-inspired): peer's port-mapped external // Phase 8 (Tailscale-inspired): peer's port-mapped external
// address from NAT-PMP/PCP/UPnP, carried in CallSetup. // address from NAT-PMP/PCP/UPnP, carried in CallSetup.
peer_mapped_addr: Option<String>, peer_mapped_addr: Option<String>,
// Debug: when true, skip relay fallback entirely — the call
// fails if direct P2P doesn't connect. Useful for testing NAT
// traversal without the relay masking failures.
direct_only: Option<bool>,
) -> Result<String, String> { ) -> Result<String, String> {
let force_direct = direct_only.unwrap_or(false);
emit_call_debug(&app, "connect:start", serde_json::json!({ emit_call_debug(&app, "connect:start", serde_json::json!({
"relay": relay, "relay": relay,
"room": room, "room": room,
"peer_direct_addr": peer_direct_addr, "peer_direct_addr": peer_direct_addr,
"peer_local_addrs": peer_local_addrs, "peer_local_addrs": peer_local_addrs,
"peer_mapped_addr": peer_mapped_addr, "peer_mapped_addr": peer_mapped_addr,
"direct_only": force_direct,
})); }));
let mut engine_lock = state.engine.lock().await; let mut engine_lock = state.engine.lock().await;
if engine_lock.is_some() { if engine_lock.is_some() {
@@ -572,7 +578,20 @@ async fn connect(
"local_direct_ok": local_direct_ok, "local_direct_ok": local_direct_ok,
"peer_direct_ok": peer_direct_ok, "peer_direct_ok": peer_direct_ok,
"chosen_path": format!("{:?}", chosen_path), "chosen_path": format!("{:?}", chosen_path),
"direct_only": force_direct,
})); }));
// direct_only mode: refuse relay fallback
if force_direct && !use_direct {
let reason = format!(
"direct_only: P2P failed (local_ok={local_direct_ok}, peer_ok={peer_direct_ok})"
);
emit_call_debug(&app, "connect:direct_only_failed", serde_json::json!({
"reason": reason,
"candidate_diags": race_result.candidate_diags,
}));
return Err(reason);
}
tracing::info!( tracing::info!(
?chosen_path, ?chosen_path,
use_direct, use_direct,

View File

@@ -208,6 +208,7 @@ const sAlias = document.getElementById("s-alias") as HTMLInputElement;
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement; const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement; const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
const sCallDebug = document.getElementById("s-call-debug") as HTMLInputElement; const sCallDebug = document.getElementById("s-call-debug") as HTMLInputElement;
const sDirectOnly = document.getElementById("s-direct-only") as HTMLInputElement;
const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement; const sCallDebugSection = document.getElementById("s-call-debug-section") as HTMLDivElement;
const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement; const sCallDebugLogEl = document.getElementById("s-call-debug-log") as HTMLDivElement;
const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement; const sCallDebugClearBtn = document.getElementById("s-call-debug-clear") as HTMLButtonElement;
@@ -287,6 +288,9 @@ interface Settings {
/// renders into the rolling Debug Log panel in settings. Off in /// renders into the rolling Debug Log panel in settings. Off in
/// normal mode keeps the GUI quiet but logcat always has a copy. /// normal mode keeps the GUI quiet but logcat always has a copy.
callDebugLogs: boolean; callDebugLogs: boolean;
/// Debug: skip relay fallback on direct calls — fail if P2P
/// doesn't connect. Useful for testing NAT traversal.
directOnly: boolean;
} }
function loadSettings(): Settings { function loadSettings(): Settings {
@@ -301,6 +305,7 @@ function loadSettings(): Settings {
osAec: true, agc: true, quality: "auto", recentRooms: [], osAec: true, agc: true, quality: "auto", recentRooms: [],
dredDebugLogs: false, dredDebugLogs: false,
callDebugLogs: false, callDebugLogs: false,
directOnly: false,
}; };
try { try {
const raw = localStorage.getItem("wzp-settings"); const raw = localStorage.getItem("wzp-settings");
@@ -1175,6 +1180,7 @@ function openSettings() {
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec; sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
sDredDebug.checked = !!s.dredDebugLogs; sDredDebug.checked = !!s.dredDebugLogs;
sCallDebug.checked = !!s.callDebugLogs; sCallDebug.checked = !!s.callDebugLogs;
sDirectOnly.checked = !!s.directOnly;
// Show the debug-log panel only when the user has the flag on — // Show the debug-log panel only when the user has the flag on —
// keeps the settings panel short in normal use. // keeps the settings panel short in normal use.
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none"; sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
@@ -1337,6 +1343,7 @@ settingsSave.addEventListener("click", () => {
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto"; s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
s.dredDebugLogs = sDredDebug.checked; s.dredDebugLogs = sDredDebug.checked;
s.callDebugLogs = sCallDebug.checked; s.callDebugLogs = sCallDebug.checked;
s.directOnly = sDirectOnly.checked;
saveSettingsObj(s); saveSettingsObj(s);
// Push the new flags to the Rust side immediately so the next // Push the new flags to the Rust side immediately so the next
// frame / call already honors them without waiting for a restart. // frame / call already honors them without waiting for a restart.
@@ -1692,6 +1699,7 @@ listen("signal-event", (event: any) => {
peerDirectAddr: data.peer_direct_addr ?? null, peerDirectAddr: data.peer_direct_addr ?? null,
peerLocalAddrs: data.peer_local_addrs ?? [], peerLocalAddrs: data.peer_local_addrs ?? [],
peerMappedAddr: data.peer_mapped_addr ?? null, peerMappedAddr: data.peer_mapped_addr ?? null,
directOnly: loadSettings().directOnly || false,
}); });
showCallScreen(); showCallScreen();
} catch (e: any) { } catch (e: any) {