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:
@@ -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') : '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user