Files
openchamber/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
T
Bohdan Triapitsyn c7bc026b4b refactor: remove legacy Tauri desktop support
Electron updater now uses Electron release metadata only
Removed legacy Tauri package and migration workflow
Replaced Tauri shim usage with the desktop bridge
2026-06-03 02:42:00 +03:00

345 lines
11 KiB
TypeScript

import { useState, useCallback } from 'react';
import {
desktopHostsGet,
desktopHostsSet,
desktopHostProbe,
resolveDesktopHostUrl,
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;
}
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,
}: 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 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 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);
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 && (
<div className="flex items-center">
<Button variant="ghost" onClick={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>
);
}