feat(signal): transparent reconnect + auto-swap on relay change
Two related UX fixes, same state-machine surface: 1. Relay drops / goes offline / restarts: the client now auto- reconnects in the background instead of silently falling to "not registered" and requiring the user to tap Deregister + Register. 2. User switches relay in settings: client auto-swaps — close old transport, register against new, all transparent. ## Signal state additions (desktop/src-tauri/src/lib.rs) - `SignalState.desired_relay_addr: Option<String>` — what the user CURRENTLY wants. `Some(x)` means "keep me connected to x", `None` means "user explicitly asked for idle". This is the pivot that distinguishes "connection dropped, retry" from "user deregistered, stop". - `SignalState.reconnect_in_progress: bool` — single-flight guard so concurrent triggers (recv-loop exit + manual register_signal + another recv-loop exit after a brief success) don't spawn duplicate supervisors. ## Refactor The old `register_signal` Tauri command was doing the whole connect + Register + spawn-recv-loop flow inline. Split into: - `internal_deregister(signal_state, keep_desired)` — shared teardown helper that nulls out transport/endpoint/call state and optionally clears `desired_relay_addr`. - `do_register_signal(signal_state, app, relay)` — core connect + register + spawn-recv-loop flow, callable from both the Tauri command and the reconnect supervisor. Returns an explicit `impl Future<...> + Send` to avoid auto-trait inference bailing inside the tokio::spawn chain (rustc loses the Send trail through the recv-loop spawn inside the fn body). - `register_signal` Tauri command — now thin: if already registered to the same relay, no-op; otherwise internal_deregister(keep_desired=false), set desired_relay_addr = Some(new), call do_register_signal. The Rust side handles the "change of server" transition entirely on its own, no deregister+register dance from JS needed. - `deregister` Tauri command — internal_deregister(keep_desired = false) so the recv-loop exit path sees the cleared desired addr and does NOT spawn a supervisor. ## Reconnect supervisor New `signal_reconnect_supervisor(signal_state, app, relay)` task. Spawned from the recv-loop exit path when the loop exits unexpectedly AND `desired_relay_addr.is_some()` AND no supervisor is already running. - Exponential backoff: 1s, 2s, 4s, 8s, 15s, 30s (capped at 30s, never gives up). First attempt is immediate (attempt 0 skips the wait). - On each iteration checks whether `desired_relay_addr` was cleared (user deregistered mid-flight) or another path already re-registered; either short-circuits the supervisor. - Also detects if the user changed relays while the supervisor was sleeping — resets the backoff counter and retries against the new addr. - On success, exits so the newly-spawned recv loop owns the connection from that point. If THAT drops again, a fresh supervisor spawns. - Emits `call-debug-log` and `signal-event` events at every state transition so the GUI can display "reconnecting...", "registered" banners. ## UI wiring (desktop/src/main.ts) - signal-event handler gets two new cases: - `"reconnecting"` — amber "🔄 reconnecting to <relay>…" in the registered banner area - `"registered"` — green "✓ registered (<fp prefix>…)" to clear the reconnecting badge - Relay-selection click handler checks if a signal is currently registered and, if the user picked a different relay, fires `register_signal` with the new address. Rust side handles the swap transparently. Full workspace test: 423 passing (no regressions). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -347,6 +347,9 @@ function renderRelayDialogList() {
|
||||
|
||||
// Click to select
|
||||
item.addEventListener("click", () => {
|
||||
const prev = loadSettings();
|
||||
const prevRelayAddr = prev.relays[prev.selectedRelay]?.address;
|
||||
|
||||
const s = loadSettings();
|
||||
s.selectedRelay = i;
|
||||
|
||||
@@ -358,6 +361,30 @@ function renderRelayDialogList() {
|
||||
saveSettingsObj(s);
|
||||
renderRelayDialogList();
|
||||
renderRelayButton();
|
||||
|
||||
// If the user switched relays and we're currently registered,
|
||||
// transparently re-register against the new one. The Rust
|
||||
// `register_signal` command is idempotent and handles the
|
||||
// swap internally (close old transport → connect new). This
|
||||
// makes "change server" a single-click operation instead of
|
||||
// manual deregister + re-register.
|
||||
const newRelayAddr = r.address;
|
||||
if (newRelayAddr && newRelayAddr !== prevRelayAddr) {
|
||||
(async () => {
|
||||
// Is a signal currently registered? get_signal_status is
|
||||
// cheap and lets us decide whether to kick the swap.
|
||||
try {
|
||||
const st: any = await invoke("get_signal_status");
|
||||
if (st && st.status === "registered") {
|
||||
await invoke<string>("register_signal", { relay: newRelayAddr });
|
||||
// `signal-event { type: "registered" }` from Rust will
|
||||
// update directRegistered for us — no manual render here.
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("relay swap: failed to re-register", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
relayDialogList.appendChild(item);
|
||||
@@ -1237,5 +1264,26 @@ listen("signal-event", (event: any) => {
|
||||
callStatusText.textContent = "";
|
||||
incomingCallPanel.classList.add("hidden");
|
||||
break;
|
||||
case "reconnecting":
|
||||
// Signal supervisor is retrying the relay connection. Show
|
||||
// a non-blocking indicator; the user can keep using
|
||||
// everything that doesn't need a live signal.
|
||||
{
|
||||
const relay = typeof data.relay === "string" ? data.relay : "relay";
|
||||
directRegistered.textContent = `🔄 reconnecting to ${relay}…`;
|
||||
directRegistered.style.color = "var(--yellow)";
|
||||
directRegistered.classList.remove("hidden");
|
||||
}
|
||||
break;
|
||||
case "registered":
|
||||
// Supervisor (re-)succeeded, or the first register landed.
|
||||
// Clear the banner and show the registered state.
|
||||
{
|
||||
const fp = typeof data.fingerprint === "string" ? data.fingerprint : "";
|
||||
directRegistered.textContent = `✓ registered${fp ? ` (${fp.slice(0, 16)}…)` : ""}`;
|
||||
directRegistered.style.color = "var(--green)";
|
||||
directRegistered.classList.remove("hidden");
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user