Files
openchamber/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
T
Bohdan Triapitsyn a0caec0984 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.
2026-07-21 21:11:13 +03:00

460 lines
16 KiB
TypeScript

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';
import { Input } from '@/components/ui/input';
import { isDesktopShell, restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
type ConnectionState = 'idle' | 'testing' | 'success' | 'error';
export interface RemoteConnectionFormProps {
onBack: () => void;
/** Optional: show the back button (default: true) */
showBackButton?: boolean;
/** Optional: initial URL to pre-populate */
initialUrl?: string;
/** Optional: initial label to pre-populate */
initialLabel?: string;
/** Optional: show recovery mode styling/behavior */
isRecoveryMode?: boolean;
/** Optional: callback when successfully connected */
onConnect?: () => void;
/** Optional: callback when user wants to switch to local setup */
onSwitchToLocal?: () => void;
showInstancePicker?: boolean;
}
type ProbeStatus = HostProbeResult['status'] | null;
function getProbeStatusMessageKey(status: ProbeStatus): string | null {
switch (status) {
case 'ok':
return null; // Success is shown separately
case 'auth':
return 'onboarding.remoteConnection.probe.authMessage';
case 'update-recommended':
return 'onboarding.remoteConnection.probe.updateRecommendedMessage';
case 'incompatible':
return 'onboarding.remoteConnection.probe.incompatibleMessage';
case 'wrong-service':
return 'onboarding.remoteConnection.probe.wrongServiceMessage';
case 'unreachable':
return 'onboarding.remoteConnection.probe.unreachableMessage';
default:
return null;
}
}
function isBlockingStatus(status: ProbeStatus): boolean {
return status === 'wrong-service' || status === 'unreachable' || status === 'incompatible';
}
export function RemoteConnectionForm({
onBack,
showBackButton = true,
initialUrl = '',
initialLabel = '',
isRecoveryMode = false,
onConnect,
onSwitchToLocal,
showInstancePicker = false,
}: RemoteConnectionFormProps) {
const { t } = useI18n();
const [url, setUrl] = useState(initialUrl);
const [label, setLabel] = useState(initialLabel);
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;
const handleUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setUrl(e.target.value);
setState('idle');
setProbeResult(null);
setError('');
}, []);
const handleLabelChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setLabel(e.target.value);
}, []);
const handleTest = useCallback(async () => {
if (!normalizedUrl) return;
setState('testing');
setProbeResult(null);
setError('');
try {
const result = await desktopHostProbe(normalizedUrl);
setProbeResult(result);
setState(result.status === 'ok' || result.status === 'update-recommended' ? 'success' : 'error');
} catch (err) {
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.connectionTestFailed'));
setState('error');
}
}, [normalizedUrl, t]);
const handleConnect = useCallback(async () => {
if (!resolvedUrl) return;
const targetUrl = resolvedUrl.persistedUrl;
setState('testing');
setProbeResult(null);
setError('');
try {
const probe = await desktopHostProbe(targetUrl);
setProbeResult(probe);
// Block connection on wrong-service or unreachable
if (isBlockingStatus(probe.status)) {
setState('error');
return;
}
const config = await desktopHostsGet();
const hostLabel = label.trim() || targetUrl;
const existingHost = config.hosts.find(
(h) => h.url === targetUrl
);
const hostId = existingHost ? existingHost.id : `host-${Date.now().toString(16)}`;
const newHost = {
id: hostId,
label: hostLabel,
url: targetUrl,
apiUrl: targetUrl,
};
const updatedHosts = existingHost
? config.hosts.map((h) => (h.id === hostId ? newHost : h))
: [...config.hosts, newHost];
// Set as default and mark initial choice completed
await desktopHostsSet({
hosts: updatedHosts,
defaultHostId: hostId,
initialHostChoiceCompleted: true,
});
onConnect?.();
if (resolvedUrl.redeemUrl) {
window.location.assign(resolvedUrl.redeemUrl);
return;
}
if (isDesktopShell()) {
await restartDesktopApp();
}
} catch (err) {
setError(err instanceof Error ? err.message : t('onboarding.remoteConnection.errors.failedToSaveConnection'));
setState('error');
}
}, [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);
const probeMessageKey = getProbeStatusMessageKey(probeResult?.status ?? null);
const isSuccess = probeResult?.status === 'ok';
const isUpdateRecommended = probeResult?.status === 'update-recommended';
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 || showInstancePicker) && (
<div className="flex items-center">
<Button variant="ghost" onClick={showInstancePicker ? () => setView('instances') : onBack} className="p-0 text-muted-foreground hover:text-foreground">
{t('onboarding.common.actions.back')}
</Button>
</div>
)}
<div className="space-y-2 text-center">
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
{isRecoveryMode
? t('onboarding.remoteConnection.titleRecovery')
: t('onboarding.remoteConnection.title')}
</h1>
<p className="text-muted-foreground text-sm">
{t('onboarding.remoteConnection.description')}
</p>
</div>
<div className="space-y-4">
<div className="space-y-2">
<label htmlFor="remote-url" className="text-sm text-foreground">
{t('onboarding.remoteConnection.field.serverAddress')}
</label>
<Input
id="remote-url"
type="url"
value={url}
onChange={handleUrlChange}
placeholder={t('onboarding.remoteConnection.field.serverAddressPlaceholder')}
disabled={isTesting}
/>
</div>
<div className="space-y-2">
<label htmlFor="remote-label" className="text-sm text-foreground">
{t('onboarding.remoteConnection.field.nameOptional')}
</label>
<Input
id="remote-label"
type="text"
value={label}
onChange={handleLabelChange}
placeholder={t('onboarding.remoteConnection.field.namePlaceholder')}
disabled={isTesting}
/>
</div>
</div>
{/* Success message */}
{probeResult && isSuccess && (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--status-success)',
color: 'var(--status-success)',
}}
>
{t('onboarding.remoteConnection.status.connectedSuccessfully', { latencyMs: probeResult.latencyMs })}
</div>
)}
{/* Auth warning (non-blocking) */}
{probeResult && isAuth && (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--status-warning)',
color: 'var(--status-warning)',
}}
>
{t('onboarding.remoteConnection.status.authWarning')}
</div>
)}
{probeResult && isUpdateRecommended && (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--status-warning)',
color: 'var(--status-warning)',
}}
>
{probeMessageKey ? t(probeMessageKey as Parameters<typeof t>[0]) : null}
</div>
)}
{/* Blocking errors */}
{probeResult && isBlocking && (
<div
className="rounded-lg border p-3 text-sm space-y-3"
style={{
borderColor: 'var(--status-error)',
color: 'var(--status-error)',
}}
>
<div>
<div className="font-semibold mb-1">{t('onboarding.remoteConnection.status.connectionFailed')}</div>
<div className="opacity-90">{probeMessageKey ? t(probeMessageKey as Parameters<typeof t>[0]) : null}</div>
</div>
<div className="text-xs opacity-80">
{probeResult.status === 'unreachable'
? t('onboarding.remoteConnection.status.suggestionsUnreachable')
: t('onboarding.remoteConnection.status.suggestionsWrongService')}
</div>
</div>
)}
{/* Generic error */}
{error && (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--status-error)',
color: 'var(--status-error)',
}}
>
{error}
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleTest}
disabled={!canTest}
>
{isTesting ? t('onboarding.remoteConnection.actions.testing') : t('onboarding.remoteConnection.actions.testConnection')}
</Button>
<Button
onClick={handleConnect}
disabled={!canConnect}
>
{t('onboarding.remoteConnection.actions.connectAndRestart')}
</Button>
</div>
{/* Suggested actions when connection is blocked */}
{isBlocking && (
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<div className="text-xs text-muted-foreground text-center">{t('onboarding.remoteConnection.actions.whatToDo')}</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onBack}
className="flex-1"
>
{t('onboarding.remoteConnection.actions.chooseDifferentServer')}
</Button>
{!isRecoveryMode && onSwitchToLocal && (
<Button
variant="outline"
size="sm"
onClick={onSwitchToLocal}
className="flex-1"
>
{t('onboarding.remoteConnection.actions.useLocalInstead')}
</Button>
)}
</div>
</div>
)}
</div>
</div>
);
}