feat(desktop): multi-transport hosts with relay fallback, card-style services dropdown

- A saved host now keeps every transport its pairing link carried: direct URL
  plus the relay descriptor, with one token for both (the mobile connection
  model). Switching tries the direct leg and falls back to the E2EE tunnel;
  list probes report Connected · Relay when only the tunnel reaches the host;
  relaunch restore picks direct first
- Host switching trusts the dropdown's fresh probe instead of re-probing on
  click (no doubled latency, no transient Unreachable flashes); statuses are
  written once with the final outcome, survive the dropdown closing via a
  last-known cache, and an unprobed host reads Checking — never Unknown
- Open-in-new-window works for relay hosts: a new IPC command boots the local
  UI with the host id injected and the renderer picks the transport; the app
  render holds on the relay restore so the splash shows instead of a transient
  auth screen (10s safety valve)
- Relay host control socket gained protocol-level keepalive: a missed pong
  window terminates and reconnects, so the relay can no longer hold a ghost
  registration that leaves every client tunnel hanging; the desktop relay
  probe also hard-times-out at 8s instead of hanging status flows
- Services dropdown restyled with mobile-style cards: per-provider usage
  cards, per-host instance cards with a selected highlight and a toned
  status line, MCP servers grouped in a card
This commit is contained in:
Bohdan Triapitsyn
2026-07-10 12:24:50 +03:00
parent ba32518b88
commit 51e6ae7e3f
11 changed files with 400 additions and 175 deletions
@@ -20,6 +20,13 @@ const DATA_SOCKET_OPEN_TIMEOUT_MS = 15000;
// honest instead of counting ghosts.
const DATA_SOCKET_IDLE_TIMEOUT_MS = 90_000;
const DATA_SOCKET_IDLE_SWEEP_INTERVAL_MS = 30_000;
// Protocol-level keepalive for the control socket. Without it, a network path
// that dies silently (NAT timeout, relay-edge eviction without close frames)
// leaves the host believing it is registered while the relay has forgotten it —
// every client tunnel then hangs in `connecting` forever. A missed pong window
// terminates the socket, which drives the normal reconnect + re-registration.
const CONTROL_PING_INTERVAL_MS = 30_000;
const CONTROL_PONG_GRACE_MS = 10_000;
const DEFAULT_BATCH_WINDOW_MS = 150;
// Resolve the frame-batching flush window: explicit option wins, then env, then
@@ -283,13 +290,41 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
}
controlSocket = socket;
// Liveness: ping on an interval; any pong (or message) proves the path.
// A quiet window beyond interval+grace means the connection silently died —
// terminate so the close handler reconnects and re-registers at the relay.
let lastAliveAt = Date.now();
const pingTimer = setInterval(() => {
if (controlSocket !== socket || socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastAliveAt > CONTROL_PING_INTERVAL_MS + CONTROL_PONG_GRACE_MS) {
logger.warn('[Relay] control socket unresponsive (missed pong) — reconnecting');
try {
socket.terminate();
} catch {
// terminate is best-effort; the close handler still runs.
}
return;
}
try {
socket.ping();
} catch {
// Send failure surfaces via the error/close handlers.
}
}, CONTROL_PING_INTERVAL_MS);
if (typeof pingTimer.unref === 'function') pingTimer.unref();
socket.on('open', () => {
if (controlSocket !== socket) return;
consecutiveFailures = 0;
lastAliveAt = Date.now();
setState('connected', null);
});
socket.on('pong', () => {
lastAliveAt = Date.now();
});
socket.on('message', (data, isBinary) => {
if (controlSocket !== socket || isBinary) return;
lastAliveAt = Date.now();
handleControlMessage(data.toString('utf8'));
});
socket.on('error', (error) => {
@@ -297,6 +332,7 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
lastError = error?.message ?? String(error);
});
socket.on('close', (code, reasonBuffer) => {
clearInterval(pingTimer);
if (controlSocket !== socket) return;
controlSocket = null;
const reason = reasonBuffer ? reasonBuffer.toString('utf8') : '';