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
@@ -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,