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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user