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:
@@ -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<OnboardingPlatform>('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<typeof setTimeout> | 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 (
|
||||
<div
|
||||
@@ -240,7 +241,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isDesktopApp && (
|
||||
{isDesktopApp && localAvailable && (
|
||||
<div className="app-region-no-drag flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -272,9 +273,10 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
{isDesktopApp && activeTab === 'remote' ? (
|
||||
<div className="app-region-no-drag">
|
||||
<RemoteConnectionForm
|
||||
onBack={() => setActiveTab('local')}
|
||||
onBack={() => localAvailable && setActiveTab('local')}
|
||||
showBackButton={false}
|
||||
onSwitchToLocal={() => setActiveTab('local')}
|
||||
showInstancePicker={!localAvailable}
|
||||
onSwitchToLocal={localAvailable ? () => setActiveTab('local') : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<ChooserScreen
|
||||
onCliAvailable={onCliAvailable}
|
||||
localAvailable={localAvailable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<ConnectionState>('idle');
|
||||
const [probeResult, setProbeResult] = useState<HostProbeResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [hosts, setHosts] = useState<DesktopHost[]>([]);
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
|
||||
{t('desktopHostSwitcher.actions.switchInstance')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t('settings.remoteInstances.direct.description')}</p>
|
||||
</div>
|
||||
{error ? <div className="text-sm text-[var(--status-error)]">{error}</div> : null}
|
||||
<div className="space-y-2">
|
||||
{hosts.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('settings.remoteInstances.direct.state.empty')}
|
||||
</div>
|
||||
) : hosts.map((host) => (
|
||||
<Button
|
||||
key={host.id}
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => void selectHost(host.id)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{host.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setView('import')}>
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setView('add')}>
|
||||
{t('settings.remoteInstances.direct.actions.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showInstancePicker && view === 'import') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<Button variant="ghost" onClick={() => setView('instances')} className="p-0 text-muted-foreground">
|
||||
{t('onboarding.common.actions.back')}
|
||||
</Button>
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t('settings.remoteInstances.direct.import.description')}</p>
|
||||
</div>
|
||||
<Input
|
||||
value={connectLink}
|
||||
onChange={(event) => setConnectLink(event.target.value)}
|
||||
placeholder={t('settings.remoteInstances.direct.import.placeholder')}
|
||||
disabled={isTesting}
|
||||
autoFocus
|
||||
/>
|
||||
{error ? <div className="text-sm text-[var(--status-error)]">{error}</div> : null}
|
||||
<Button onClick={() => void handleImport()} disabled={isTesting || !connectLink.trim()}>
|
||||
{t('settings.remoteInstances.direct.import.action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{showBackButton && (
|
||||
{(showBackButton || showInstancePicker) && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" onClick={onBack} className="p-0 text-muted-foreground hover:text-foreground">
|
||||
<Button variant="ghost" onClick={showInstancePicker ? () => setView('instances') : onBack} className="p-0 text-muted-foreground hover:text-foreground">
|
||||
{t('onboarding.common.actions.back')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user