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
@@ -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')}