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:
+50
-10
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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') : '';
|
||||
|
||||
@@ -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