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:
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user