feat(desktop): support remote-only startup
Allow Desktop to skip its in-process OpenChamber server with OPENCHAMBER_SKIP_LOCAL_SERVER=1 while continuing to load the packaged UI shell. Carry local runtime availability through the boot contract so unavailable or unconfigured remotes enter a remote-only chooser instead of offering broken local recovery actions. The chooser can select saved instances, add a server by URL, or redeem an OpenChamber pairing link over direct or E2EE relay transports. Keep additional windows, Mini Chat, background startup, and unreachable-host recovery functional without a local origin. Render boot and recovery surfaces with the active theme background rather than exposing the native vibrancy backing. Document the environment variable and cover serverless boot routing plus malformed pairing imports with focused tests.
This commit is contained in:
@@ -31,6 +31,27 @@ describe('resolveDesktopBootView', () => {
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-unreachable', hostId: 'remote-a', url: 'https://x.test' });
|
||||
});
|
||||
|
||||
test('preserves disabled local runtime capability for remote recovery', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: {
|
||||
target: 'remote',
|
||||
status: 'unreachable',
|
||||
hostId: 'remote-a',
|
||||
url: 'https://x.test',
|
||||
localAvailable: false,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
screen: 'recovery',
|
||||
variant: 'remote-unreachable',
|
||||
hostId: 'remote-a',
|
||||
url: 'https://x.test',
|
||||
localAvailable: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('returns main for local ok', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
@@ -77,13 +98,22 @@ describe('resolveDesktopBootView', () => {
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-incompatible', hostId: 'old-host', url: 'https://old.test' });
|
||||
});
|
||||
|
||||
test('returns recovery view for local unreachable', () => {
|
||||
test('returns chooser for local unreachable', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: 'local', status: 'unreachable' },
|
||||
}),
|
||||
).toEqual({ screen: 'recovery', variant: 'local-unavailable' });
|
||||
).toEqual({ screen: 'chooser' });
|
||||
});
|
||||
|
||||
test('returns remote-only chooser when local runtime is disabled', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: 'local', status: 'unreachable', localAvailable: false },
|
||||
}),
|
||||
).toEqual({ screen: 'chooser', localAvailable: false });
|
||||
});
|
||||
|
||||
test('returns recovery view for remote missing', () => {
|
||||
|
||||
@@ -18,32 +18,34 @@
|
||||
* This makes it easier to add new states without updating multiple files and
|
||||
* allows UI to reason about outcomes with simple status checks.
|
||||
*/
|
||||
type DesktopBootAvailability = { localAvailable?: boolean };
|
||||
|
||||
export type DesktopBootOutcome =
|
||||
// Main screens - CLI or remote connection is working
|
||||
| { target: 'local'; status: 'ok' }
|
||||
| { target: 'remote'; status: 'ok'; hostId: string; url: string }
|
||||
| ({ target: 'local'; status: 'ok' } & DesktopBootAvailability)
|
||||
| ({ target: 'remote'; status: 'ok'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
|
||||
// First launch - user hasn't made a choice yet
|
||||
| { target: null; status: 'not-configured' }
|
||||
| ({ target: null; status: 'not-configured' } & DesktopBootAvailability)
|
||||
|
||||
// Recovery screens - something is wrong
|
||||
| { target: 'local'; status: 'unreachable' }
|
||||
| { target: 'remote'; status: 'unreachable'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'incompatible'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'wrong-service'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'missing'; hostId: string };
|
||||
| ({ target: 'local'; status: 'unreachable' } & DesktopBootAvailability)
|
||||
| ({ target: 'remote'; status: 'unreachable'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ target: 'remote'; status: 'incompatible'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ target: 'remote'; status: 'wrong-service'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ target: 'remote'; status: 'missing'; hostId: string } & DesktopBootAvailability);
|
||||
|
||||
// ── UI-facing view ──
|
||||
|
||||
export type DesktopBootView =
|
||||
| { screen: 'main' }
|
||||
| { screen: 'main'; hostId: string; url: string }
|
||||
| { screen: 'chooser' }
|
||||
| { screen: 'recovery'; variant: 'local-unavailable' }
|
||||
| { screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-missing'; hostId: string };
|
||||
| ({ screen: 'main' } & DesktopBootAvailability)
|
||||
| ({ screen: 'main'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ screen: 'chooser' } & DesktopBootAvailability)
|
||||
| ({ screen: 'recovery'; variant: 'local-unavailable' } & DesktopBootAvailability)
|
||||
| ({ screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ screen: 'recovery'; variant: 'remote-incompatible'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string } & DesktopBootAvailability)
|
||||
| ({ screen: 'recovery'; variant: 'remote-missing'; hostId: string } & DesktopBootAvailability);
|
||||
|
||||
// ── Resolver inputs ──
|
||||
|
||||
@@ -76,6 +78,7 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const availability = record.localAvailable === false ? { localAvailable: false } : {};
|
||||
const target = record.target;
|
||||
const status = record.status;
|
||||
|
||||
@@ -93,7 +96,7 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
if (target === 'remote' || target === 'local') {
|
||||
if (status === 'ok' && target === 'local') {
|
||||
// { target: 'local'; status: 'ok' } is valid
|
||||
return { valid: true, outcome: { target: 'local', status: 'ok' } };
|
||||
return { valid: true, outcome: { target: 'local', status: 'ok', ...availability } };
|
||||
}
|
||||
|
||||
if (status === 'ok' && target === 'remote') {
|
||||
@@ -101,19 +104,19 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url } };
|
||||
return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url, ...availability } };
|
||||
}
|
||||
|
||||
if (status === 'unreachable') {
|
||||
if (target === 'local') {
|
||||
// { target: 'local'; status: 'unreachable' } is valid
|
||||
return { valid: true, outcome: { target: 'local', status: 'unreachable' } };
|
||||
return { valid: true, outcome: { target: 'local', status: 'unreachable', ...availability } };
|
||||
} else {
|
||||
// { target: 'remote'; status: 'unreachable' } requires hostId and url
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url } };
|
||||
return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url, ...availability } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +125,7 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url } };
|
||||
return { valid: true, outcome: { target: 'remote', status, hostId: record.hostId, url: record.url, ...availability } };
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
@@ -130,14 +133,14 @@ function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
if (typeof record.hostId !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId } };
|
||||
return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId, ...availability } };
|
||||
}
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
if (status === 'not-configured') {
|
||||
// { target: null; status: 'not-configured' } is valid (first launch)
|
||||
return { valid: true, outcome: { target: null, status: 'not-configured' } };
|
||||
return { valid: true, outcome: { target: null, status: 'not-configured', ...availability } };
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
@@ -166,35 +169,36 @@ export function resolveDesktopBootView(
|
||||
if (!outcome) {
|
||||
return null;
|
||||
}
|
||||
const availability = outcome.localAvailable === false ? { localAvailable: false } : {};
|
||||
|
||||
// Main screens - CLI or remote connection is working
|
||||
if (outcome.status === 'ok') {
|
||||
if (outcome.target === 'local') {
|
||||
return { screen: 'main' };
|
||||
return { screen: 'main', ...availability };
|
||||
} else if (outcome.target === 'remote') {
|
||||
return { screen: 'main', hostId: outcome.hostId, url: outcome.url };
|
||||
return { screen: 'main', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||
}
|
||||
}
|
||||
|
||||
// First launch - user hasn't made a choice yet
|
||||
if (outcome.target === null && outcome.status === 'not-configured') {
|
||||
return { screen: 'chooser' };
|
||||
return { screen: 'chooser', ...availability };
|
||||
}
|
||||
|
||||
// Recovery screens - something is wrong
|
||||
if (outcome.target === 'local' && outcome.status === 'unreachable') {
|
||||
return { screen: 'recovery', variant: 'local-unavailable' };
|
||||
return { screen: 'chooser', ...availability };
|
||||
}
|
||||
|
||||
if (outcome.target === 'remote') {
|
||||
if (outcome.status === 'unreachable') {
|
||||
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url };
|
||||
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||
} else if (outcome.status === 'incompatible') {
|
||||
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url };
|
||||
return { screen: 'recovery', variant: 'remote-incompatible', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||
} else if (outcome.status === 'wrong-service') {
|
||||
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url };
|
||||
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url, ...availability };
|
||||
} else if (outcome.status === 'missing') {
|
||||
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId };
|
||||
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId, ...availability };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
||||
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
||||
|
||||
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
@@ -54,6 +54,12 @@ describe('resolveDesktopHostUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('importDesktopHostPairing', () => {
|
||||
test('rejects malformed pairing links before changing hosts', async () => {
|
||||
await expect(importDesktopHostPairing('not-a-connect-link', [])).rejects.toThrow('invalid-connect-link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('desktop host runtime headers', () => {
|
||||
test('parses persisted request headers from desktop config', async () => {
|
||||
await withDesktopBridge(async (cmd) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hasDesktopInvoke, invokeDesktop } from '@/lib/desktop';
|
||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
||||
import { parsePairingConnectionPayload, type PairingEndpointCandidate } from '@/lib/connectionPayload';
|
||||
|
||||
type DesktopInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
@@ -79,6 +80,122 @@ export type DesktopHostsConfigInput = {
|
||||
localClientToken?: string | null;
|
||||
};
|
||||
|
||||
const desktopPlatformName = (): string | undefined => {
|
||||
if (typeof navigator === 'undefined') return undefined;
|
||||
const ua = navigator.userAgent;
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) return 'macos';
|
||||
if (/Windows/i.test(ua)) return 'windows';
|
||||
if (/Linux/i.test(ua)) return 'linux';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const importDesktopHostPairing = async (
|
||||
link: string,
|
||||
hosts: DesktopHost[],
|
||||
): Promise<{ hosts: DesktopHost[]; hostId: string }> => {
|
||||
const payload = parsePairingConnectionPayload(link);
|
||||
if (!payload) throw new Error('invalid-connect-link');
|
||||
|
||||
const installId = await desktopInstallIdGet().catch(() => '');
|
||||
const redeemInit: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pairingId: payload.pairingId,
|
||||
secret: payload.secret,
|
||||
clientLabel: payload.label || 'OpenChamber Desktop',
|
||||
clientKind: 'desktop',
|
||||
deviceName: 'OpenChamber Desktop',
|
||||
devicePlatform: desktopPlatformName(),
|
||||
...(installId ? { dedupeKey: `desktop:${installId}` } : {}),
|
||||
}),
|
||||
};
|
||||
const readToken = async (response: Response): Promise<string | null> => {
|
||||
if (!response.ok) return null;
|
||||
const body = (await response.json().catch(() => null)) as { clientToken?: unknown } | null;
|
||||
const token = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
|
||||
return token || null;
|
||||
};
|
||||
|
||||
let redeemed: { directUrl?: string; relay?: DesktopHostRelay; token: string } | null = null;
|
||||
const candidates = [...payload.candidates].sort(
|
||||
(a, b) => (a.type === 'relay' ? 1 : 0) - (b.type === 'relay' ? 1 : 0),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.type === 'relay') {
|
||||
const tunnel = createRelayTunnelClient({
|
||||
relayUrl: candidate.relayUrl,
|
||||
serverId: candidate.serverId,
|
||||
hostEncPubJwk: candidate.hostEncPubJwk,
|
||||
...(candidate.grant ? { grant: candidate.grant } : {}),
|
||||
});
|
||||
try {
|
||||
const token = await readToken(await tunnel.fetch('/api/client-auth/pairing/redeem', redeemInit));
|
||||
if (token) {
|
||||
redeemed = {
|
||||
relay: { relayUrl: candidate.relayUrl, serverId: candidate.serverId, hostEncPubJwk: candidate.hostEncPubJwk },
|
||||
token,
|
||||
};
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Try the next advertised transport.
|
||||
} finally {
|
||||
tunnel.close();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const directUrl = normalizeHostUrl(candidate.url);
|
||||
if (!directUrl) continue;
|
||||
try {
|
||||
const token = await readToken(await fetch(`${directUrl}/api/client-auth/pairing/redeem`, redeemInit));
|
||||
if (token) {
|
||||
redeemed = { directUrl, token };
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Try the next advertised transport.
|
||||
}
|
||||
}
|
||||
if (!redeemed) throw new Error('pairing-redeem-failed');
|
||||
|
||||
const relayCandidate = payload.candidates.find(
|
||||
(candidate): candidate is Extract<PairingEndpointCandidate, { type: 'relay' }> => candidate.type === 'relay',
|
||||
);
|
||||
const relay = redeemed.relay || (relayCandidate
|
||||
? { relayUrl: relayCandidate.relayUrl, serverId: relayCandidate.serverId, hostEncPubJwk: relayCandidate.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.directUrl || firstDirectUrl;
|
||||
const url = directUrl || (relay ? relayHostDisplayUrl(relay.serverId) : null);
|
||||
if (!url) throw new Error('pairing-missing-transport');
|
||||
|
||||
const existing = hosts.find((host) => (
|
||||
relay ? host.relay?.serverId === relay.serverId : (!host.relay && normalizeHostUrl(host.apiUrl || host.url) === url)
|
||||
));
|
||||
const hostId = existing?.id || (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `host-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const nextHost: DesktopHost = {
|
||||
...(existing || {}),
|
||||
id: hostId,
|
||||
label: payload.label || existing?.label || redactSensitiveUrl(url),
|
||||
url,
|
||||
apiUrl: directUrl,
|
||||
clientToken: redeemed.token,
|
||||
...(relay ? { relay } : {}),
|
||||
};
|
||||
return {
|
||||
hostId,
|
||||
hosts: existing
|
||||
? hosts.map((host) => host.id === hostId ? nextHost : host)
|
||||
: [nextHost, ...hosts],
|
||||
};
|
||||
};
|
||||
|
||||
export type HostProbeResult = {
|
||||
status: 'ok' | 'auth' | 'update-recommended' | 'incompatible' | 'wrong-service' | 'unreachable';
|
||||
latencyMs: number;
|
||||
|
||||
Reference in New Issue
Block a user