diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 163e8889..816ef34a 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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', diff --git a/packages/electron/preload.mjs b/packages/electron/preload.mjs index 6f7c7923..cf35004b 100644 --- a/packages/electron/preload.mjs +++ b/packages/electron/preload.mjs @@ -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); diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index a91ad56e..ab7bf511 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -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 = {}; + 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 ; - if (status === 'ok') return ; - if (status === 'auth') return ; - if (status === 'update-recommended') return ; - if (status === 'incompatible') return ; - if (status === 'wrong-service') return ; - if (status === 'unreachable') return ; - return ; -}; - 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([]); const [defaultHostId, setDefaultHostId] = React.useState(null); - const [statusById, setStatusById] = React.useState>({}); - const [probingHostIds, setProbingHostIds] = React.useState>({}); + const [statusById, setStatusById] = React.useState>(() => ({ ...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 = {}; - 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 => { + 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) => { 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({ )}
-
+
{isLoading ? (
{t('desktopHostSwitcher.state.loading')}
) : ( @@ -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 (
@@ -907,32 +953,33 @@ export function DesktopHostSwitcherDialog({ aria-label={t('desktopHostSwitcher.actions.switchToAria', { instance: displayLabel })} > + {/* Same reading order as the settings device list: name + + badges on the first line, a toned status line under it, + then the address. */}
-
-
- - {displayLabel} - - {isSsh && ( - - SSH - - )} - {isActive && ( - {t('desktopHostSwitcher.header.current')} - )} -
- - {statusIcon(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)) }) - : ''} - +
+ + {displayLabel} + {isActive && ( + + {t('desktopHostSwitcher.header.current')} + + )} + {isSsh && ( + + SSH + + )}
-
+
+ {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')}` : ''} +
+
{displayUrl}
diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index b227e023..ab0cd862 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -480,22 +480,23 @@ const DesktopServicesMenu = React.memo(function DesktopServicesMenu({
) : null} -
- {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. */} +
+ {rateLimitGroups.map((group) => { const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; return ( - - {index > 0 ?
: null} -
+
+
{group.providerName}
{group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( -
- {group.error ?? t('header.services.noRateLimitsReported')} +
+ {group.error ?? t('header.services.noRateLimitsReported')}
) : ( -
+
{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}
)} - +
); })}
diff --git a/packages/ui/src/components/mcp/McpDropdown.tsx b/packages/ui/src/components/mcp/McpDropdown.tsx index 55c696ac..c5717ff9 100644 --- a/packages/ui/src/components/mcp/McpDropdown.tsx +++ b/packages/ui/src/components/mcp/McpDropdown.tsx @@ -144,7 +144,10 @@ export const McpDropdownContent: React.FC = ({ active,
: null} -
+ {/* Desktop dropdown: servers grouped in one mobile-style card; the mobile + sheet variant keeps its own density and chrome. */} +
+
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 = ({ 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', )} >
@@ -212,6 +215,7 @@ export const McpDropdownContent: React.FC = ({ active, {t('mcpDropdown.empty.configureInConfig')}
)} +
); diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index e24baf70..ee29b750 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -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 => 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 => 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 = () => { : ''}
-

- {host.relay ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)} +

+ {host.relay && !host.apiUrl ? t('mobile.connect.relay.badge') : redactSensitiveUrl(host.apiUrl || host.url)}

- {/* 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 : (