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
+30 -11
View File
@@ -1,28 +1,47 @@
import { isElectronShell } from '@/lib/desktop';
import { desktopHostsGet } from '@/lib/desktopHosts';
import { desktopHostProbe, desktopHostsGet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
import { getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
/**
* On desktop startup, re-open the E2EE relay tunnel if the default host is a
* relay host. Relay hosts have no reachable HTTP base, so the Electron shell
* boots the LOCAL UI and defers reconnection to the renderer: here we read the
* persisted relay descriptor + client token and activate the tunnel in-process
* via switchRuntimeEndpoint({ relay }). Direct hosts don't need this — the shell
* injects their apiBaseUrl/token as window globals before render.
* On desktop startup, reconnect a relay-capable default host. The Electron
* shell boots the LOCAL UI for any host that carries a relay leg and defers
* transport selection to the renderer: here we probe the direct address first
* (cheap, preferred on the home network) and fall back to the E2EE tunnel via
* switchRuntimeEndpoint({ relay }) — the multi-transport model mobile uses.
* Direct-only hosts never reach this path (the shell injects their
* apiBaseUrl/token as window globals before render).
*
* Safe to call unconditionally; it is a no-op outside the Electron shell and when
* the default host is local or already active.
*/
export const restoreDesktopRelayRuntime = async (): Promise<void> => {
export const restoreDesktopRelayRuntime = async (targetHostId?: string): Promise<void> => {
if (!isElectronShell()) return;
const config = await desktopHostsGet().catch(() => null);
const defaultHostId = config?.defaultHostId;
if (!config || !defaultHostId || defaultHostId === 'local') return;
const host = config.hosts.find((entry) => entry.id === defaultHostId);
if (!config) return;
// An explicit target (a "new window for host X") wins over the default-host
// relaunch logic.
const hostId = targetHostId || (config.defaultHostId !== 'local' ? config.defaultHostId : null);
if (!hostId) return;
const host = config.hosts.find((entry) => entry.id === hostId);
if (!host?.relay) return;
// Must match runtimeKeyForHost() in DesktopHostSwitcher so switch/resolve agree.
const runtimeKey = `host:${host.id}`;
if (getRuntimeKey() === runtimeKey) return;
const directUrl = host.apiUrl ? normalizeHostUrl(getDesktopHostApiUrl(host)) : null;
if (directUrl) {
const probe = await desktopHostProbe(directUrl, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null })
.catch(() => ({ status: 'unreachable' as const, latencyMs: 0 }));
if (probe.status !== 'unreachable' && probe.status !== 'wrong-service' && probe.status !== 'incompatible') {
switchRuntimeEndpoint({
apiBaseUrl: directUrl,
clientToken: host.clientToken || null,
requestHeaders: host.requestHeaders || null,
runtimeKey,
});
return;
}
}
switchRuntimeEndpoint({
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
clientToken: host.clientToken || null,