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:
Siavash Sameni
2026-04-11 18:40:11 +04:00
parent 00deb97a5d
commit 20375eceb9
2 changed files with 319 additions and 14 deletions

View File

@@ -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;
}
});