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>