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
+50 -10
View File
@@ -566,10 +566,13 @@ const buildRendererRuntimeConfig = (uiUrl, runtimeConfig = {}) => {
const apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : (state.apiBaseUrl || '');
const clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : (state.clientToken || '');
const requestHeaders = sanitizeRuntimeRequestHeaders(runtimeConfig.requestHeaders || state.requestHeaders || {});
// Relay-capable hosts have no injectable HTTP base: the renderer reads this
// host id, probes the direct leg, and falls back to the E2EE tunnel itself.
const relayHostId = typeof runtimeConfig.relayHostId === 'string' ? runtimeConfig.relayHostId : '';
if (shouldUseSameOriginDevProxy(uiUrl, apiBaseUrl)) {
return { apiBaseUrl: '', clientToken: '', requestHeaders: {} };
return { apiBaseUrl: '', clientToken: '', requestHeaders: {}, relayHostId };
}
return { apiBaseUrl, clientToken, requestHeaders };
return { apiBaseUrl, clientToken, requestHeaders, relayHostId };
};
const readDesktopLocalClientToken = () => {
@@ -630,9 +633,11 @@ const sanitizeHostRelayForStorage = (value) => {
return { relayUrl, serverId, hostEncPubJwk: jwk };
};
// Shared storage shape for a persisted host (direct or relay). Returns null for
// entries that can't be stored (missing id, reserved 'local', or no usable
// transport).
// Shared storage shape for a persisted host. A host may carry a direct HTTP
// transport, a relay transport, or BOTH (a multi-transport device: direct on
// the home network, relay away — mirrors the mobile connection model). Returns
// null for entries that can't be stored (missing id, reserved 'local', or no
// usable transport at all).
const buildStoredHostEntry = (entry) => {
const id = typeof entry?.id === 'string' ? entry.id.trim() : '';
if (!id || id === LOCAL_HOST_ID) return null;
@@ -643,15 +648,18 @@ const buildStoredHostEntry = (entry) => {
const labelRaw = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : '';
const relay = sanitizeHostRelayForStorage(entry?.relay);
const relayField = relay ? { relay } : {};
const directUrl = sanitizeHostUrlForStorage(entry?.url);
const apiUrl = directUrl ? (sanitizeHostUrlForStorage(entry?.apiUrl) || directUrl) : null;
if (directUrl) {
return { id, label: labelRaw || directUrl, url: directUrl, apiUrl, ...tokenField, ...headerFields, ...relayField };
}
if (relay) {
const url = `relay://${relay.serverId}`;
return { id, label: labelRaw || url, url, ...tokenField, ...headerFields, relay };
}
const url = sanitizeHostUrlForStorage(entry?.url);
if (!url) return null;
const apiUrl = sanitizeHostUrlForStorage(entry?.apiUrl) || url;
return { id, label: labelRaw || url, url, apiUrl, ...tokenField, ...headerFields };
return null;
};
const readDesktopHostsConfig = () => {
@@ -2189,6 +2197,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
`--openchamber-macos-major=${desktopMacosMajor}`,
`--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`,
`--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`,
`--openchamber-relay-host-id=${rendererRuntimeConfig.relayHostId || ''}`,
],
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
backgroundThrottling: false,
@@ -3958,6 +3967,36 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
return null;
}
case 'desktop_new_window_for_host': {
// Open a saved host in a new window. Hosts with a relay leg boot the
// LOCAL UI and let the renderer pick the transport (direct first, E2EE
// tunnel fallback) via the injected relay host id — a fixed apiBaseUrl
// would strand the window when the direct leg is unreachable.
const hostId = typeof args.hostId === 'string' ? args.hostId.trim() : '';
const config = readDesktopHostsConfig();
const host = config.hosts.find((entry) => entry.id === hostId);
if (!host) throw new Error('Host not found');
if (host.relay) {
const windowUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : (state.sidecarUrl || state.localOrigin);
await createAdditionalWindow(windowUrl, {
apiBaseUrl: '',
clientToken: host.clientToken || '',
requestHeaders: sanitizeRuntimeRequestHeaders(host.requestHeaders || {}),
relayHostId: host.id,
});
return null;
}
const targetUrl = normalizeHostUrl(host.apiUrl || host.url);
if (!targetUrl) throw new Error('Invalid URL');
const windowUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') : targetUrl;
await createAdditionalWindow(windowUrl, {
apiBaseUrl: targetUrl,
clientToken: host.clientToken || '',
requestHeaders: sanitizeRuntimeRequestHeaders(host.requestHeaders || {}),
});
return null;
}
case 'desktop_new_window_at_url': {
const targetUrl = normalizeHostUrl(String(args.url || ''));
if (!targetUrl) {
@@ -4394,6 +4433,7 @@ const COMMANDS_SAFE_FOR_REMOTE = new Set([
'desktop_host_probe',
'desktop_new_window',
'desktop_new_window_at_url',
'desktop_new_window_for_host',
'desktop_set_window_title',
'desktop_set_window_theme',
'desktop_is_window_fullscreen',
+8
View File
@@ -62,6 +62,14 @@ if (clientToken && isLocalPage) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_CLIENT_TOKEN__', clientToken);
}
// Which saved host this window should connect to over the relay-capable path
// (direct probe first, E2EE tunnel fallback). Local pages only — the id is
// only useful together with the desktop IPC channel anyway.
const relayHostId = readArgValue('--openchamber-relay-host-id');
if (relayHostId && isLocalPage) {
contextBridge.exposeInMainWorld('__OPENCHAMBER_RELAY_HOST_ID__', relayHostId);
}
if (runtimeHeadersRaw && isLocalPage) {
try {
const runtimeHeaders = JSON.parse(runtimeHeadersRaw);
@@ -21,6 +21,7 @@ import {
desktopHostsSet,
desktopLocalClientTokenGet,
desktopOpenNewWindowAtUrl,
desktopOpenNewWindowForHost,
getDesktopHostApiUrl,
locationMatchesHost,
normalizeHostUrl,
@@ -51,8 +52,15 @@ const runtimeKeyForHost = (host: DesktopHost): string => {
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
/** Which transport the successful probe used (multi-transport hosts). */
via?: 'relay';
};
// Last known statuses survive the dropdown unmounting (it remounts on every
// open). Rows show the previous result immediately — refreshed quietly by the
// open-probe — instead of shouting "Unknown" at the user for a few seconds.
const lastKnownHostStatuses: Record<string, HostStatus> = {};
type HostDisplayStatus = HostProbeResult['status'] | 'checking' | null;
const toNavigationUrl = (rawUrl: string): string => {
@@ -93,6 +101,14 @@ const statusDotClass = (status: HostDisplayStatus): string => {
return 'bg-muted-foreground/40';
};
// Text tone matching statusDotClass, for the per-row status line.
const statusTextClass = (status: HostDisplayStatus): string => {
if (status === 'ok') return 'text-[var(--status-success)]';
if (status === 'auth' || status === 'update-recommended') return 'text-[var(--status-warning)]';
if (status === 'incompatible' || status === 'wrong-service' || status === 'unreachable') return 'text-[var(--status-error)]';
return 'text-muted-foreground';
};
const isBlockedHostStatus = (status: HostProbeResult['status'] | null): boolean => {
return status === 'unreachable' || status === 'wrong-service' || status === 'incompatible';
};
@@ -120,17 +136,6 @@ const statusLabelKey = (status: HostDisplayStatus):
return 'desktopHostSwitcher.status.unknown';
};
const statusIcon = (status: HostDisplayStatus) => {
if (status === 'checking') return <Icon name="loader-4" className="h-4 w-4 animate-spin" />;
if (status === 'ok') return <Icon name="check" className="h-4 w-4" />;
if (status === 'auth') return <Icon name="shield-keyhole" className="h-4 w-4" />;
if (status === 'update-recommended') return <Icon name="shield-keyhole" className="h-4 w-4" />;
if (status === 'incompatible') return <Icon name="cloud-off" className="h-4 w-4" />;
if (status === 'wrong-service') return <Icon name="cloud-off" className="h-4 w-4" />;
if (status === 'unreachable') return <Icon name="cloud-off" className="h-4 w-4" />;
return <Icon name="earth" className="h-4 w-4" />;
};
const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
const sshPhaseLabelKey = (phase: DesktopSshInstanceStatus['phase'] | undefined):
@@ -308,8 +313,10 @@ export function DesktopHostSwitcherDialog({
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
const [probingHostIds, setProbingHostIds] = React.useState<Record<string, true>>({});
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>(() => ({ ...lastKnownHostStatuses }));
React.useEffect(() => {
Object.assign(lastKnownHostStatuses, statusById);
}, [statusById]);
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
@@ -420,20 +427,17 @@ export function DesktopHostSwitcherDialog({
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isDesktopShell()) return;
setIsProbing(true);
const nextProbingHostIds: Record<string, true> = {};
for (const host of hosts) {
nextProbingHostIds[host.id] = true;
}
setProbingHostIds(nextProbingHostIds);
try {
const localClientToken = await getLocalClientToken();
const results = await Promise.all(
hosts.map(async (h) => {
// Relay hosts have no HTTP address to probe — check reachability
// through a throwaway E2EE tunnel instead.
if (h.relay) {
const res = await probeRelayDesktopHost(h.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
const probeRelayLeg = async (): Promise<HostStatus> => {
const res = await probeRelayDesktopHost(h.relay!).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return { status: res.status, latencyMs: res.latencyMs, ...(res.status === 'ok' ? { via: 'relay' as const } : {}) };
};
// Relay-only host: no HTTP address — probe through the E2EE tunnel.
if (h.relay && !h.apiUrl) {
return [h.id, await probeRelayLeg()] as const;
}
const url = normalizeHostUrl(isElectronShell() ? getDesktopHostApiUrl(h) : h.url);
if (!url) {
@@ -441,6 +445,12 @@ export function DesktopHostSwitcherDialog({
}
const clientToken = h.id === LOCAL_HOST_ID ? localClientToken : (h.clientToken || '');
const res = await desktopHostProbe(url, { clientToken: clientToken || null, requestHeaders: h.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Multi-transport host away from its network: the direct leg fails
// but the relay may still reach it.
if (isBlockedHostStatus(res.status) && h.relay) {
const relayStatus = await probeRelayLeg();
if (relayStatus.status === 'ok') return [h.id, relayStatus] as const;
}
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
@@ -450,7 +460,6 @@ export function DesktopHostSwitcherDialog({
}
setStatusById(next);
} finally {
setProbingHostIds({});
setIsProbing(false);
}
}, []);
@@ -500,54 +509,74 @@ export function DesktopHostSwitcherDialog({
}, [open]);
const handleSwitch = React.useCallback(async (host: DesktopHost) => {
// Relay hosts have no reachable HTTP origin — they ride the E2EE tunnel.
// Activate it in-renderer via switchRuntimeEndpoint({ relay }); the runtime
// fetch/socket layers route through the tunnel from the singleton registry.
if (host.relay) {
setSwitchingHostId(host.id);
const probe = await probeRelayDesktopHost(host.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
const reachable = probe.status === 'ok';
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
if (!reachable) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
// Relay legs ride the E2EE tunnel activated in-renderer via
// switchRuntimeEndpoint({ relay }); the runtime fetch/socket layers route
// through the tunnel from the singleton registry.
const activateRelay = (relay: NonNullable<DesktopHost['relay']>) => {
switchRuntimeEndpoint({
apiBaseUrl: typeof window !== 'undefined' ? window.location.origin : '',
clientToken: host.clientToken || null,
runtimeKey: runtimeKeyForHost(host),
relay: host.relay,
relay,
});
onHostSwitched?.();
setSwitchingHostId(null);
return;
}
};
const origin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(host.url) || '');
const apiOrigin = host.id === LOCAL_HOST_ID ? localOrigin : (normalizeHostUrl(getDesktopHostApiUrl(host)) || '');
if (!origin) return;
const relayOnly = Boolean(host.relay) && !host.apiUrl && host.id !== LOCAL_HOST_ID;
if (!origin && !relayOnly) return;
if (isElectronShell()) {
if (!apiOrigin) return;
if (!apiOrigin && !host.relay) return;
setSwitchingHostId(host.id);
const clientToken = host.id === LOCAL_HOST_ID ? await getLocalClientToken() : (host.clientToken || '');
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
setStatusById((prev) => ({
...prev,
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
if (isBlockedHostStatus(probe.status)) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
// The dropdown already probed every host when it opened — act on that
// result instead of re-probing (re-probes doubled the switch latency and
// flashed transient Unreachable states over a known-good host).
const cached = statusById[host.id];
if (cached?.status === 'ok') {
if (cached.via === 'relay' && host.relay) {
activateRelay(host.relay);
} else if (apiOrigin) {
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) });
} else if (host.relay) {
activateRelay(host.relay);
}
onHostSwitched?.();
setSwitchingHostId(null);
return;
}
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) });
// No usable probe result — probe now: direct first, relay fallback.
// Statuses are written once, with the final outcome, so the row never
// flashes intermediate failures while the fallback is still running.
let finalStatus: HostStatus = { status: 'unreachable', latencyMs: 0 };
let transport: 'direct' | 'relay' | null = null;
if (apiOrigin) {
const probe = await desktopHostProbe(apiOrigin, { clientToken: clientToken || null, requestHeaders: host.requestHeaders || null }).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
finalStatus = { status: probe.status, latencyMs: probe.latencyMs };
if (!isBlockedHostStatus(probe.status)) transport = 'direct';
}
if (!transport && host.relay) {
const probe = await probeRelayDesktopHost(host.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
if (probe.status === 'ok') {
finalStatus = { status: probe.status, latencyMs: probe.latencyMs, via: 'relay' };
transport = 'relay';
}
}
setStatusById((prev) => ({ ...prev, [host.id]: finalStatus }));
if (!transport) {
toast.error(t('desktopHostSwitcher.toast.instanceUnreachable', { host: redactSensitiveUrl(host.label) }));
setSwitchingHostId(null);
return;
}
if (transport === 'relay' && host.relay) {
activateRelay(host.relay);
} else {
switchRuntimeEndpoint({ apiBaseUrl: apiOrigin, clientToken: clientToken || null, requestHeaders: host.requestHeaders || null, runtimeKey: runtimeKeyForHost(host) });
}
onHostSwitched?.();
setSwitchingHostId(null);
return;
@@ -662,7 +691,7 @@ export function DesktopHostSwitcherDialog({
} catch {
window.location.href = target;
}
}, [localOrigin, onHostSwitched, sshHostIds, sshStatusesById, t]);
}, [localOrigin, onHostSwitched, sshHostIds, sshStatusesById, statusById, t]);
const cancelEdit = React.useCallback(() => {
setEditingId(null);
@@ -703,14 +732,21 @@ export function DesktopHostSwitcherDialog({
}, [configHosts, persist]);
const openInNewWindow = React.useCallback((host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch((err: unknown) => {
const reportFailure = (err: unknown) => {
toast.error(t('desktopHostSwitcher.error.failedToOpenNewWindow'), {
description: err instanceof Error ? err.message : String(err),
});
});
};
// Relay-capable hosts can't be expressed as a fixed window URL — the new
// window boots the local UI and picks direct-vs-tunnel itself.
if (host.relay && host.id !== LOCAL_HOST_ID) {
desktopOpenNewWindowForHost(host.id).catch(reportFailure);
return;
}
const origin = host.id === LOCAL_HOST_ID ? localOrigin : getDesktopHostApiUrl(host);
if (!origin) return;
const target = toNavigationUrl(origin);
desktopOpenNewWindowAtUrl(target, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null }).catch(reportFailure);
}, [localOrigin, t]);
const switchToLocal = React.useCallback(async () => {
@@ -866,7 +902,7 @@ export function DesktopHostSwitcherDialog({
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
<div className={cn('space-y-1', embedded && 'space-y-1.5 px-3 py-1')}>
{isLoading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">{t('desktopHostSwitcher.state.loading')}</div>
) : (
@@ -877,22 +913,32 @@ export function DesktopHostSwitcherDialog({
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
const status = statusById[host.id] || null;
const sshStatus = sshStatusesById[host.id] || null;
const isChecking = !isSsh && Boolean(probingHostIds[host.id]);
const statusKind: HostDisplayStatus = isSsh ? sshPhaseToHostStatus(sshStatus?.phase) : (isChecking ? 'checking' : (status?.status ?? null));
// While a probe runs, keep showing the last known result (quiet
// refresh); only fall back to "Checking" when there has never
// been one. "Unknown" is never shown — an unprobed host is by
// definition being checked.
const statusKind: HostDisplayStatus = isSsh
? sshPhaseToHostStatus(sshStatus?.phase)
: (status?.status ?? 'checking');
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? localOrigin : (normalizeHostUrl(host.url) || host.url);
const displayLabel = host.id === LOCAL_HOST_ID
? t('desktopHostSwitcher.instance.local')
: redactSensitiveUrl(host.label);
// Relay hosts have a relay:// pseudo-URL that means nothing to a
// person — say how the connection works instead.
const displayUrl = host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(effectiveUrl);
// Relay-only hosts have a relay:// pseudo-URL that means nothing
// to a person — say how the connection works instead. Hosts with
// a direct leg show their address.
const displayUrl = host.relay && !host.apiUrl ? t('mobile.connect.relay.badge') : redactSensitiveUrl(effectiveUrl);
return (
<div
key={host.id}
className={cn(
'group flex items-center gap-2 px-2.5 py-2 rounded-md overflow-hidden',
// Dropdown (embedded): mobile-style card per host; the
// active host reads as selected, not just labelled.
embedded && 'rounded-xl bg-[var(--surface-muted)] px-3 py-2.5',
embedded && isActive && 'bg-[var(--interactive-selection)]/25',
isEditing ? 'bg-interactive-hover/20' : 'hover:bg-interactive-hover/30'
)}
>
@@ -907,32 +953,33 @@ export function DesktopHostSwitcherDialog({
aria-label={t('desktopHostSwitcher.actions.switchToAria', { instance: displayLabel })}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(statusKind))} />
{/* Same reading order as the settings device list: name +
badges on the first line, a toned status line under it,
then the address. */}
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 max-w-[45%] items-center gap-1.5">
<span className="typography-ui-label truncate text-foreground">
{displayLabel}
</span>
{isSsh && (
<span className="typography-micro flex-shrink-0 px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
</span>
)}
{isActive && (
<span className="typography-micro flex-shrink-0 text-muted-foreground">{t('desktopHostSwitcher.header.current')}</span>
)}
</div>
<span className="inline-flex min-w-0 flex-1 items-center gap-1 typography-micro text-muted-foreground">
<span className="flex-shrink-0">{statusIcon(statusKind)}</span>
<span className="truncate">
{isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(statusKind))}
{!isSsh && statusKind === 'ok' && typeof status?.latencyMs === 'number'
? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(status.latencyMs)) })
: ''}
</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="typography-ui-label font-medium truncate text-foreground">
{displayLabel}
</span>
{isActive && (
<span className="typography-micro flex-shrink-0 text-muted-foreground bg-muted px-1 rounded leading-none pb-px border border-border/50">
{t('desktopHostSwitcher.header.current')}
</span>
)}
{isSsh && (
<span className="typography-micro flex-shrink-0 px-1 rounded leading-none pb-px text-[var(--status-info)] bg-[var(--status-info)]/10">
SSH
</span>
)}
</div>
<div className="typography-micro text-muted-foreground truncate font-mono">
<div className={cn('typography-micro truncate', statusTextClass(statusKind))}>
{isSsh ? t(sshPhaseLabelKey(sshStatus?.phase)) : t(statusLabelKey(statusKind))}
{!isSsh && statusKind === 'ok' && typeof status?.latencyMs === 'number'
? t('desktopHostSwitcher.status.ping', { ms: Math.max(0, Math.round(status.latencyMs)) })
: ''}
{!isSsh && status?.via === 'relay' ? ` · ${t('settings.remoteInstances.clientAuth.state.viaRelay')}` : ''}
</div>
<div className="typography-micro text-muted-foreground/70 truncate font-mono">
{displayUrl}
</div>
</div>
+10 -9
View File
@@ -480,22 +480,23 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
</div>
) : null}
<div className="py-2">
{rateLimitGroups.map((group, index) => {
{/* One elevated card per provider (same card language as the mobile
usage popover) instead of a flat run of divider-separated rows. */}
<div className="space-y-2 px-3 py-2.5">
{rateLimitGroups.map((group) => {
const providerExpandedFamilies = expandedFamilies[group.providerId] ?? [];
return (
<React.Fragment key={group.providerId}>
{index > 0 ? <div className="mx-4 my-2 border-t border-[var(--interactive-border)]" /> : null}
<div className="flex items-center gap-2 px-4 py-2">
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-3">
<div className="flex items-center gap-2 pb-2">
<ProviderLogo providerId={group.providerId} className="h-4 w-4" />
<span className="typography-ui-label font-medium text-foreground">{group.providerName}</span>
</div>
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? (
<div className="px-4 pb-2">
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
<div>
<span className="typography-ui-label text-muted-foreground">{group.error ?? t('header.services.noRateLimitsReported')}</span>
</div>
) : (
<div className="space-y-3 px-4 pb-2">
<div className="space-y-3">
{group.entries.map(([label, window]) => {
const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent;
const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label);
@@ -584,7 +585,7 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
) : null}
</div>
)}
</React.Fragment>
</div>
);
})}
</div>
@@ -144,7 +144,10 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
</div>
</div> : null}
<div className={cn('max-h-64 overflow-y-auto py-2', mobileListDensity && 'space-y-1 py-3', listClassName)}>
{/* Desktop dropdown: servers grouped in one mobile-style card; the mobile
sheet variant keeps its own density and chrome. */}
<div className={cn('max-h-64 overflow-y-auto', mobileListDensity ? 'space-y-1 py-3' : 'px-3 py-2.5', listClassName)}>
<div className={cn(!mobileListDensity && sortedNames.length > 0 && 'rounded-xl bg-[var(--surface-muted)] p-1.5')}>
{sortedNames.map((serverName) => {
const serverStatus = status[serverName];
const tone = statusTone(serverStatus);
@@ -157,7 +160,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
key={serverName}
className={cn(
'flex items-center justify-between rounded-lg hover:bg-interactive-hover/50',
mobileListDensity ? 'gap-3 px-4 py-3' : 'gap-2 px-4 py-1.5',
mobileListDensity ? 'gap-3 px-4 py-3' : 'gap-2 px-2.5 py-2',
)}
>
<div className="min-w-0 flex-1">
@@ -212,6 +215,7 @@ export const McpDropdownContent: React.FC<McpDropdownContentProps> = ({ active,
{t('mcpDropdown.empty.configureInConfig')}
</div>
)}
</div>
</div>
</div>
);
@@ -625,33 +625,49 @@ export const RemoteInstancesPage: React.FC = () => {
? crypto.randomUUID()
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`);
if (redeemed.kind === 'relay') {
const { relay, token } = redeemed;
// Relay hosts are keyed by serverId (one host per server, regardless of
// which relay routes it), so re-importing updates the existing record.
const existing = directHosts.find((host) => host.relay?.serverId === relay.serverId);
const displayUrl = relayHostDisplayUrl(relay.serverId);
if (existing) {
const nextHosts = directHosts.map((host) => host.id === existing.id
? { ...host, label: payload.label || host.label, url: displayUrl, apiUrl: undefined, clientToken: token, relay }
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
} else {
// payload.label is normally the issuing server's hostname; the pseudo-URL
// is only a last-resort display name.
await persistDirectHosts([{ id: makeId(), label: payload.label || displayUrl, url: displayUrl, clientToken: token, relay }, ...directHosts], directDefaultHostId);
}
// Persist EVERY transport the link carried, not just the one that answered
// the redeem — a multi-transport host connects directly on the home network
// and falls back to the relay away from it (same model as mobile devices).
// The single token works over both transports.
const linkRelayCandidate = payload.candidates.find(
(candidate): candidate is Extract<PairingEndpointCandidate, { type: 'relay' }> => candidate.type === 'relay',
);
const relay: DesktopHostRelay | undefined = redeemed.kind === 'relay'
? redeemed.relay
: linkRelayCandidate
? { relayUrl: linkRelayCandidate.relayUrl, serverId: linkRelayCandidate.serverId, hostEncPubJwk: linkRelayCandidate.hostEncPubJwk }
: undefined;
const firstDirectUrl = payload.candidates
.filter((candidate): candidate is Extract<PairingEndpointCandidate, { type: 'lan' | 'tunnel' }> => candidate.type !== 'relay')
.map((candidate) => normalizeHostUrl(candidate.url))
.find((value): value is string => Boolean(value));
const directUrl = redeemed.kind === 'direct' ? redeemed.url : firstDirectUrl;
const { token } = redeemed;
const url = directUrl || (relay ? relayHostDisplayUrl(relay.serverId) : null);
if (!url) {
setDirectError(t('desktopHostSwitcher.error.invalidUrl'));
return;
}
const transportFields = {
url,
apiUrl: directUrl || undefined,
clientToken: token,
...(relay ? { relay } : {}),
};
// One host per server: match by relay serverId when the link has a relay
// leg, else by direct URL — re-importing updates the record in place.
const existing = directHosts.find((host) => (
relay ? host.relay?.serverId === relay.serverId : (!host.relay && normalizeHostUrl(host.apiUrl || host.url) === url)
));
if (existing) {
const nextHosts = directHosts.map((host) => host.id === existing.id
? { ...host, label: payload.label || host.label, ...transportFields }
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
} else {
const { url, token } = redeemed;
const existing = directHosts.find((host) => !host.relay && normalizeHostUrl(host.apiUrl || host.url) === url);
if (existing) {
const nextHosts = directHosts.map((host) => host.id === existing.id
? { ...host, label: payload.label || host.label, url, apiUrl: url, clientToken: token }
: host);
await persistDirectHosts(nextHosts, directDefaultHostId);
} else {
await persistDirectHosts([{ id: makeId(), label: payload.label || redactSensitiveUrl(url), url, apiUrl: url, clientToken: token }, ...directHosts], directDefaultHostId);
}
// payload.label is normally the issuing server's hostname.
await persistDirectHosts([{ id: makeId(), label: payload.label || redactSensitiveUrl(url), ...transportFields }, ...directHosts], directDefaultHostId);
}
setDirectConnectLink('');
setDirectError(null);
@@ -733,15 +749,23 @@ export const RemoteInstancesPage: React.FC = () => {
if (!showInstanceManagement || directHosts.length === 0) return;
let cancelled = false;
void Promise.all(directHosts.map(async (host) => {
const result = host.relay
? await probeRelayDesktopHost(host.relay).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }))
: await (async () => {
const url = normalizeHostUrl(getDesktopHostApiUrl(host));
if (!url) return { status: 'unreachable', latencyMs: 0 } as HostProbeResult;
return desktopHostProbe(url, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null })
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
})();
return [host.id, result] as const;
const relayProbe = () => probeRelayDesktopHost(host.relay!).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
// Relay-only host: tunnel probe. Multi-transport host: direct first,
// relay as the away-from-home fallback.
if (host.relay && !host.apiUrl) {
return [host.id, await relayProbe()] as const;
}
const url = normalizeHostUrl(getDesktopHostApiUrl(host));
if (!url) {
return [host.id, host.relay ? await relayProbe() : ({ status: 'unreachable', latencyMs: 0 } as HostProbeResult)] as const;
}
const direct = await desktopHostProbe(url, { clientToken: host.clientToken || null, requestHeaders: host.requestHeaders || null })
.catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
if (direct.status === 'unreachable' && host.relay) {
const relayResult = await relayProbe();
if (relayResult.status === 'ok') return [host.id, relayResult] as const;
}
return [host.id, direct] as const;
})).then((entries) => {
if (cancelled) return;
setDirectHostStatus(Object.fromEntries(entries));
@@ -1518,18 +1542,19 @@ export const RemoteInstancesPage: React.FC = () => {
: ''}
</span>
</div>
<p className={cn('typography-micro text-muted-foreground truncate', !host.relay && 'font-mono')}>
{host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)}
<p className={cn('typography-micro text-muted-foreground truncate', host.apiUrl && 'font-mono')}>
{host.relay && !host.apiUrl ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => void setDefaultDirectHost(host.id)} disabled={directSaving || directDefaultHostId === host.id} aria-label={t('desktopHostSwitcher.actions.setAsDefaultAria')}>
{directDefaultHostId === host.id ? <Icon name="star-fill" className="h-3.5 w-3.5" /> : <Icon name="star" className="h-3.5 w-3.5" />}
</Button>
{/* The edit form is URL/token-centric; saving it would drop a
relay host's tunnel descriptor. Relay hosts are re-imported
via a fresh pairing link instead. */}
{host.relay ? null : (
{/* The edit form is URL/token-centric; relay-ONLY hosts have
nothing it can edit and are re-imported via a fresh pairing
link instead. Multi-transport hosts keep their relay leg
through the edit (object spread preserves it). */}
{host.relay && !host.apiUrl ? null : (
<Button type="button" variant="ghost" size="xs" className="!font-normal" onClick={() => beginEditDirectHost(host)} disabled={directSaving}>
<Icon name="pencil" className="h-3.5 w-3.5" />
{t('desktopHostSwitcher.actions.edit')}
+34 -7
View File
@@ -23,11 +23,13 @@ const sanitizeRequestHeaders = (headers: unknown): Record<string, string> | unde
};
/**
* Private-relay reachability for a host. When present, the host is reached over
* the E2EE relay tunnel (no direct `apiUrl`); `hostEncPubJwk` is the trust anchor
* that pins the tunnel to the real server. The relay admission `grant` is a
* one-time pairing artifact and is intentionally NOT persisted steady-state
* relay connections route by `serverId` alone (mirrors the mobile app).
* Private-relay reachability for a host. A host may carry this ALONGSIDE a
* direct `apiUrl` (multi-transport: direct on the home network, E2EE tunnel
* away mirrors the mobile connection model) or as its only transport.
* `hostEncPubJwk` is the trust anchor that pins the tunnel to the real server.
* The relay admission `grant` is a one-time pairing artifact and is
* intentionally NOT persisted steady-state relay connections route by
* `serverId` alone.
*/
export type DesktopHostRelay = {
relayUrl: string;
@@ -288,9 +290,14 @@ export const desktopInstallIdGet = async (): Promise<string> => {
return typeof raw === 'string' ? raw.trim() : '';
};
const RELAY_PROBE_TIMEOUT_MS = 8_000;
/**
* Reachability check for a relay host: open a throwaway E2EE tunnel and hit
* /health. Relay hosts have no HTTP address for `desktopHostProbe`.
* /health. Relay hosts have no HTTP address for `desktopHostProbe`. Hard
* timeout: a ghost relay registration (relay lost the host, host doesn't know)
* leaves the tunnel in `connecting` forever the probe must report
* unreachable instead of hanging every status/switch flow with it.
*/
export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise<HostProbeResult> => {
const tunnel = createRelayTunnelClient({
@@ -300,7 +307,16 @@ export const probeRelayDesktopHost = async (relay: DesktopHostRelay): Promise<Ho
});
const startedAt = Date.now();
try {
const response = await tunnel.fetch('/health');
const response = await Promise.race([
tunnel.fetch('/health'),
new Promise<null>((resolve) => {
const timer = window.setTimeout(() => resolve(null), RELAY_PROBE_TIMEOUT_MS);
if (typeof timer !== 'number' && typeof (timer as { unref?: () => void }).unref === 'function') {
(timer as unknown as { unref: () => void }).unref();
}
}),
]);
if (!response) return { status: 'unreachable', latencyMs: 0 };
return { status: response.ok ? 'ok' : 'unreachable', latencyMs: Math.max(0, Date.now() - startedAt) };
} catch {
return { status: 'unreachable', latencyMs: 0 };
@@ -335,3 +351,14 @@ export const desktopOpenNewWindowAtUrl = async (url: string, options?: { clientT
if (!invoke) return;
await invoke('desktop_new_window_at_url', { url, clientToken: options?.clientToken || undefined, requestHeaders: options?.requestHeaders || undefined });
};
/**
* Open a saved host in a new window by id. Required for relay-capable hosts
* the new window boots the local UI and picks the transport itself (direct
* first, E2EE tunnel fallback), which a fixed URL cannot express.
*/
export const desktopOpenNewWindowForHost = async (hostId: string): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_new_window_for_host', { hostId });
};
+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,
@@ -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') : '';
+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 });
};