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
+6 -2
View File
@@ -1,4 +1,4 @@
import { createConfiguredWebAPIs } from './runtimeConfig';
import { createConfiguredWebAPIs, getDesktopRelayRestoreReady } from './runtimeConfig';
import { registerSW } from 'virtual:pwa-register';
import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types';
@@ -110,7 +110,11 @@ if (hostedSurface === 'mobile') {
renderMobileApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createConfiguredWebAPIs());
});
} else {
void import('@openchamber/ui/main');
// Hold the render (HTML splash stays up) until a desktop relay-host restore
// has picked its transport — otherwise the app boots against a not-yet-chosen
// endpoint and flashes the auth screen before the tunnel connects. Resolves
// immediately when no relay host is involved.
void getDesktopRelayRestoreReady().then(() => import('@openchamber/ui/main'));
}
if (import.meta.env.PROD) {
+17 -3
View File
@@ -23,6 +23,11 @@ declare global {
}
}
// Resolved once the desktop relay-host restore (if any) has picked a transport.
// Immediately-resolved everywhere else. See createConfiguredWebAPIs.
let desktopRelayRestoreReady: Promise<void> = Promise.resolve();
export const getDesktopRelayRestoreReady = (): Promise<void> => desktopRelayRestoreReady;
export const createConfiguredWebAPIs = () => {
const apiBaseUrl = typeof window.__OPENCHAMBER_API_BASE_URL__ === 'string'
? window.__OPENCHAMBER_API_BASE_URL__.trim()
@@ -49,8 +54,17 @@ export const createConfiguredWebAPIs = () => {
void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {});
}
installRuntimeFetchBridge();
// Desktop only: if the default host is a relay host, re-open its tunnel now
// that the fetch bridge is installed. No-op elsewhere.
void restoreDesktopRelayRuntime().catch(() => {});
// Desktop only: reconnect a relay-capable host now that the fetch bridge is
// installed — either the host this window was opened for (injected id) or the
// default host on relaunch. No-op elsewhere; resolves in milliseconds when no
// relay host is involved. main.tsx holds the app render on this promise so
// the user sees the splash instead of a transient auth screen against an
// endpoint that is still being selected.
const relayHostId = (window as typeof window & { __OPENCHAMBER_RELAY_HOST_ID__?: string }).__OPENCHAMBER_RELAY_HOST_ID__;
desktopRelayRestoreReady = Promise.race([
restoreDesktopRelayRuntime(typeof relayHostId === 'string' && relayHostId ? relayHostId : undefined).catch(() => {}),
// Never hold the app hostage: a stuck probe/tunnel gives up to the UI.
new Promise<void>((resolve) => { window.setTimeout(resolve, 10_000); }),
]);
return createWebAPIs({ urls });
};