diff --git a/packages/electron/README.md b/packages/electron/README.md index afccf5d5..7dfa7efa 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -111,6 +111,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u |----------|-----| | `OPENCHAMBER_ELECTRON_DEV=1` | Marks the runtime as desktop development mode | | `OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1` | Uses staged web assets instead of the HMR dev server | +| `OPENCHAMBER_SKIP_LOCAL_SERVER=1` | Skips the in-process local OpenChamber server and uses the configured default remote instance; packaged/bundled UI remains available for connection recovery | | `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` | | `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` | | `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server | diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index cfb83696..c0720727 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -183,6 +183,7 @@ const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA'; const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24; const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json'; const OPENCODE_SHUTDOWN_GRACE_MS = 100; +const SKIP_LOCAL_SERVER = process.env.OPENCHAMBER_SKIP_LOCAL_SERVER === '1'; const { autoUpdater } = updaterPkg; @@ -194,6 +195,7 @@ const state = { clientToken: null, requestHeaders: {}, bootOutcome: null, + startupResolved: false, initScript: null, mainWindow: null, quitRequested: false, @@ -1531,6 +1533,7 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken }; const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => { + const availability = { localAvailable }; if (envTargetUrl) { const status = probe?.status === 'unreachable' ? 'unreachable' @@ -1539,23 +1542,23 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => : probe?.status === 'wrong-service' ? 'wrong-service' : 'ok'; - return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl }; + return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl, ...availability }; } const defaultId = config.defaultHostId || ''; if (!defaultId) { - return { target: null, status: 'not-configured' }; + return { target: null, status: 'not-configured', ...availability }; } if (defaultId === LOCAL_HOST_ID) { return localAvailable - ? { target: 'local', status: 'ok' } - : { target: 'local', status: 'unreachable' }; + ? { target: 'local', status: 'ok', ...availability } + : { target: 'local', status: 'unreachable', ...availability }; } const host = config.hosts.find((entry) => entry.id === defaultId); if (!host) { - return { target: 'remote', status: 'missing', hostId: defaultId }; + return { target: 'remote', status: 'missing', hostId: defaultId, ...availability }; } const status = probe?.status === 'unreachable' @@ -1565,7 +1568,7 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => : probe?.status === 'wrong-service' ? 'wrong-service' : 'ok'; - return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url }; + return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability }; }; const buildStartupSplashHtml = () => { @@ -2468,6 +2471,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } }; const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = {}) => { + state.startupResolved = true; state.localOrigin = localOrigin; state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl; state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : ''; @@ -2506,7 +2510,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = }; const openMainWindow = async () => { - if (!state.localOrigin) { + if (!state.startupResolved) { const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders }); } @@ -2540,7 +2544,7 @@ const openMainWindow = async () => { }; const createAdditionalWindow = async (url, runtimeConfig = {}) => { - if (!state.localOrigin) { + if (!state.startupResolved || !url) { return null; } const browserWindow = createBrowserWindow({ @@ -2553,12 +2557,14 @@ const createAdditionalWindow = async (url, runtimeConfig = {}) => { }; const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => { - const base = state.localOrigin || state.sidecarUrl; + const base = shouldUsePackagedUi() + ? buildPackagedUiUrl('/mini-chat.html') + : state.localOrigin || state.sidecarUrl; if (!base) { throw new Error('Local UI is not available'); } - const url = new URL(shouldUsePackagedUi() ? buildPackagedUiUrl('/mini-chat.html') : '/mini-chat.html', base); + const url = new URL(shouldUsePackagedUi() ? base : '/mini-chat.html', base); url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft'); if (sessionId) url.searchParams.set('sessionId', sessionId); if (directory) url.searchParams.set('directory', directory); @@ -2754,9 +2760,11 @@ const resolveInitialUrl = async () => { const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173'; const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`; const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`; - const localUrl = isDev && await waitForHealth(hmrApiUrl, 5_000, 100) - ? hmrApiUrl - : await spawnLocalServer(); + const localUrl = SKIP_LOCAL_SERVER + ? null + : isDev && await waitForHealth(hmrApiUrl, 5_000, 100) + ? hmrApiUrl + : await spawnLocalServer(); const localUiUrl = shouldUsePackagedUi() ? buildPackagedUiUrl('/index.html') @@ -2767,10 +2775,10 @@ const resolveInitialUrl = async () => { state.sidecarUrl = localUrl; const localAvailable = Boolean(localUrl); - const localOrigin = new URL(localUrl).origin; + const localOrigin = localUrl ? new URL(localUrl).origin : null; let initialUrl = localUiUrl; - let apiBaseUrl = localUrl; - let clientToken = readDesktopLocalClientToken(); + let apiBaseUrl = localUrl || ''; + let clientToken = localUrl ? readDesktopLocalClientToken() : ''; let requestHeaders = {}; let remoteProbe = null; @@ -2798,13 +2806,22 @@ const resolveInitialUrl = async () => { } if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); - apiBaseUrl = localUrl; - clientToken = readDesktopLocalClientToken(); + apiBaseUrl = localUrl || ''; + clientToken = localUrl ? readDesktopLocalClientToken() : ''; requestHeaders = {}; initialUrl = localUiUrl; } } + if (!initialUrl && apiBaseUrl && remoteProbe?.status !== 'unreachable') { + initialUrl = apiBaseUrl; + } + if (!initialUrl) { + throw new Error( + 'OPENCHAMBER_SKIP_LOCAL_SERVER=1 requires bundled UI, a running desktop HMR UI, or a reachable remote instance.', + ); + } + const bootOutcome = computeBootOutcome({ envTargetUrl: envTarget || null, probe: remoteProbe, @@ -4891,11 +4908,16 @@ app.whenReady().then(async () => { } if (isBackgroundStart) { - const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl(); + const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl(); state.localOrigin = localOrigin; + state.apiBaseUrl = apiBaseUrl; + state.clientToken = clientToken; state.bootOutcome = bootOutcome ?? null; state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); - state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders); + // Serverless background startup re-probes the remote when a window is + // eventually opened instead of trusting reachability from login time. + state.startupResolved = !SKIP_LOCAL_SERVER; + state.initScript = buildInitScript(localOrigin, state.bootOutcome, apiBaseUrl, clientToken, state.requestHeaders); log.info('[electron] started in background without window'); return; } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 4c16cce7..ec79df9d 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -833,10 +833,11 @@ function App({ apis }: AppProps) { if (bootView.screen === 'chooser') { return ( -
+
}> { // Switch to remote tab - handled internally by OnboardingScreen @@ -854,13 +855,14 @@ function App({ apis }: AppProps) { return ( -
+
}> diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx index f68d7871..b769206c 100644 --- a/packages/ui/src/components/onboarding/ChooserScreen.tsx +++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx @@ -21,6 +21,7 @@ type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown'; type ChooserScreenProps = { /** Callback when CLI becomes available */ onCliAvailable?: () => void; + localAvailable?: boolean; }; function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: string }) { @@ -45,7 +46,7 @@ function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: str ); } -export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { +export function ChooserScreen({ onCliAvailable, localAvailable = true }: ChooserScreenProps) { const { t } = useI18n(); const [copied, setCopied] = React.useState(false); const [isDesktopApp, setIsDesktopApp] = React.useState(false); @@ -53,7 +54,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { const [isManualChecking, setIsManualChecking] = React.useState(false); const [opencodeBinary, setOpencodeBinary] = React.useState(''); const [platform, setPlatform] = React.useState('unknown'); - const [activeTab, setActiveTab] = React.useState<'local' | 'remote'>('local'); + const [activeTab, setActiveTab] = React.useState<'local' | 'remote'>(() => localAvailable ? 'local' : 'remote'); const [advancedOpen, setAdvancedOpen] = React.useState(false); const [troubleOpen, setTroubleOpen] = React.useState(false); @@ -136,7 +137,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { // whether the OpenCode CLI is reachable. As soon as it is, transition // automatically — the user doesn't have to click anything. React.useEffect(() => { - if (activeTab !== 'local') return; + if (!localAvailable || activeTab !== 'local') return; let cancelled = false; let timer: ReturnType | null = null; @@ -164,7 +165,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { cancelled = true; if (timer) clearTimeout(timer); }; - }, [activeTab, checkCliAvailability, announceAvailable]); + }, [activeTab, checkCliAvailability, announceAvailable, localAvailable]); const handleManualCheck = React.useCallback(async () => { setIsManualChecking(true); @@ -223,7 +224,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) { ? '/home/you/.bun/bin/opencode' : '/Users/you/.bun/bin/opencode'; - const showLocal = !isDesktopApp || activeTab === 'local'; + const showLocal = localAvailable && (!isDesktopApp || activeTab === 'local'); return (
- {isDesktopApp && ( + {isDesktopApp && localAvailable && (
) : null} diff --git a/packages/ui/src/components/onboarding/OnboardingScreen.tsx b/packages/ui/src/components/onboarding/OnboardingScreen.tsx index 95db4a08..7aa8d5c7 100644 --- a/packages/ui/src/components/onboarding/OnboardingScreen.tsx +++ b/packages/ui/src/components/onboarding/OnboardingScreen.tsx @@ -23,6 +23,7 @@ type OnboardingScreenProps = { onEnterLocalSetup?: () => void; /** Callback when user wants to switch to remote (first-launch only) */ onChooseRemote?: () => void; + localAvailable?: boolean; }; export function OnboardingScreen({ @@ -33,6 +34,7 @@ export function OnboardingScreen({ recoveryHostUrl, recoveryHostLabel, onEnterLocalSetup, + localAvailable = true, }: OnboardingScreenProps) { const [showRecoveryRemoteForm, setShowRecoveryRemoteForm] = React.useState(false); const [recoveryEnteredLocalSetup, setRecoveryEnteredLocalSetup] = React.useState(false); @@ -55,6 +57,7 @@ export function OnboardingScreen({ variant={recoveryVariant} hostUrl={recoveryHostUrl} hostLabel={recoveryHostLabel} + onChooseRemote={() => setShowRecoveryRemoteForm(true)} showRemoteForm={showRecoveryRemoteForm} onCloseRemoteForm={() => setShowRecoveryRemoteForm(false)} onSwitchToLocalFromRemote={() => { @@ -65,6 +68,7 @@ export function OnboardingScreen({ setRecoveryEnteredLocalSetup(true); onEnterLocalSetup?.(); }} + localAvailable={localAvailable} /> ); } @@ -91,6 +95,7 @@ export function OnboardingScreen({ return ( ); } diff --git a/packages/ui/src/components/onboarding/RecoveryScreen.tsx b/packages/ui/src/components/onboarding/RecoveryScreen.tsx index 09a7af58..07e32aa5 100644 --- a/packages/ui/src/components/onboarding/RecoveryScreen.tsx +++ b/packages/ui/src/components/onboarding/RecoveryScreen.tsx @@ -27,6 +27,7 @@ type RecoveryScreenProps = { onEnterLocalSetup?: () => void; /** Whether retry action is in progress */ isRetrying?: boolean; + localAvailable?: boolean; }; export function RecoveryScreen({ @@ -40,6 +41,7 @@ export function RecoveryScreen({ onSwitchToLocalFromRemote, onEnterLocalSetup, isRetrying = false, + localAvailable = true, }: RecoveryScreenProps) { // Persist the user's first choice (local or remote) const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => { @@ -103,7 +105,8 @@ export function RecoveryScreen({ initialUrl={prefillUrl} initialLabel={prefillLabel} isRecoveryMode={true} - onSwitchToLocal={onSwitchToLocalFromRemote || (() => { + showInstancePicker={!localAvailable} + onSwitchToLocal={localAvailable ? (onSwitchToLocalFromRemote || (() => { persistFirstChoice('local').then(() => { if (isDesktopShell()) { restartDesktopApp(); @@ -111,7 +114,7 @@ export function RecoveryScreen({ onEnterLocalSetup?.(); } }); - })} + })) : undefined} /> ); } @@ -122,7 +125,7 @@ export function RecoveryScreen({ hostLabel={hostLabel} hostUrl={hostUrl} onRetry={handleRecoveryRetry} - onUseLocal={handleRecoveryUseLocal} + onUseLocal={localAvailable ? handleRecoveryUseLocal : undefined} onUseRemote={handleRecoveryUseRemote} isRetrying={isRetrying} /> diff --git a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx index 161a58c4..3b68c60f 100644 --- a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx +++ b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx @@ -1,9 +1,11 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { desktopHostsGet, desktopHostsSet, desktopHostProbe, resolveDesktopHostUrl, + importDesktopHostPairing, + type DesktopHost, type HostProbeResult, } from '@/lib/desktopHosts'; import { Button } from '@/components/ui/button'; @@ -27,6 +29,7 @@ export interface RemoteConnectionFormProps { onConnect?: () => void; /** Optional: callback when user wants to switch to local setup */ onSwitchToLocal?: () => void; + showInstancePicker?: boolean; } type ProbeStatus = HostProbeResult['status'] | null; @@ -62,6 +65,7 @@ export function RemoteConnectionForm({ isRecoveryMode = false, onConnect, onSwitchToLocal, + showInstancePicker = false, }: RemoteConnectionFormProps) { const { t } = useI18n(); const [url, setUrl] = useState(initialUrl); @@ -69,6 +73,16 @@ export function RemoteConnectionForm({ const [state, setState] = useState('idle'); const [probeResult, setProbeResult] = useState(null); const [error, setError] = useState(''); + const [hosts, setHosts] = useState([]); + const [view, setView] = useState<'instances' | 'add' | 'import'>(() => showInstancePicker ? 'instances' : 'add'); + const [connectLink, setConnectLink] = useState(''); + + useEffect(() => { + if (!showInstancePicker) return; + void desktopHostsGet().then((config) => setHosts(config.hosts)).catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }); + }, [showInstancePicker]); const resolvedUrl = resolveDesktopHostUrl(url); const normalizedUrl = resolvedUrl?.persistedUrl ?? null; @@ -162,6 +176,38 @@ export function RemoteConnectionForm({ } }, [resolvedUrl, label, onConnect, t]); + const selectHost = useCallback(async (hostId: string) => { + const config = await desktopHostsGet(); + await desktopHostsSet({ + hosts: config.hosts, + defaultHostId: hostId, + initialHostChoiceCompleted: true, + }); + await restartDesktopApp(); + }, []); + + const handleImport = useCallback(async () => { + setState('testing'); + setError(''); + try { + const config = await desktopHostsGet(); + const imported = await importDesktopHostPairing(connectLink, config.hosts); + await desktopHostsSet({ + hosts: imported.hosts, + defaultHostId: imported.hostId, + initialHostChoiceCompleted: true, + }); + await restartDesktopApp(); + } catch (err) { + setError( + err instanceof Error && err.message === 'invalid-connect-link' + ? t('settings.remoteInstances.direct.error.invalidConnectLink') + : t('onboarding.remoteConnection.errors.failedToSaveConnection'), + ); + setState('error'); + } + }, [connectLink, t]); + const isTesting = state === 'testing'; const canTest = normalizedUrl !== null && !isTesting; const canConnect = normalizedUrl !== null && !isTesting && !isBlockingStatus(probeResult?.status ?? null); @@ -172,12 +218,81 @@ export function RemoteConnectionForm({ const isAuth = probeResult?.status === 'auth'; const isBlocking = isBlockingStatus(probeResult?.status ?? null); + if (showInstancePicker && view === 'instances') { + return ( +
+
+
+

+ {t('desktopHostSwitcher.actions.switchInstance')} +

+

{t('settings.remoteInstances.direct.description')}

+
+ {error ?
{error}
: null} +
+ {hosts.length === 0 ? ( +
+ {t('settings.remoteInstances.direct.state.empty')} +
+ ) : hosts.map((host) => ( + + ))} +
+
+ + +
+
+
+ ); + } + + if (showInstancePicker && view === 'import') { + return ( +
+
+ +
+

+ {t('settings.remoteInstances.direct.import.action')} +

+

{t('settings.remoteInstances.direct.import.description')}

+
+ setConnectLink(event.target.value)} + placeholder={t('settings.remoteInstances.direct.import.placeholder')} + disabled={isTesting} + autoFocus + /> + {error ?
{error}
: null} + +
+
+ ); + } + return (
- {showBackButton && ( + {(showBackButton || showInstancePicker) && (
-
diff --git a/packages/ui/src/lib/desktopBoot.test.ts b/packages/ui/src/lib/desktopBoot.test.ts index 9ab78c87..3ae71fd6 100644 --- a/packages/ui/src/lib/desktopBoot.test.ts +++ b/packages/ui/src/lib/desktopBoot.test.ts @@ -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', () => { diff --git a/packages/ui/src/lib/desktopBoot.ts b/packages/ui/src/lib/desktopBoot.ts index 7c1d0880..7f192783 100644 --- a/packages/ui/src/lib/desktopBoot.ts +++ b/packages/ui/src/lib/desktopBoot.ts @@ -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; + 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 }; } } diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index 1a24853a..114c8f87 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -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 (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { 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) => { diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index d89f5cbb..0e5af343 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -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) => Promise; @@ -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 => { + 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 => 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 => 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;