feat: deliver polished desktop first-launch experience with smart recovery (#850)
* feat: implement desktop boot outcome architecture
- Add structured DesktopBootOutcome with target/status fields
- Implement boot outcome computation and validation
- Add desktop hosts configuration management (Tauri + TypeScript)
- Add desktop hosts probing with timeout and retry logic
- Support local/remote host classification and health checks
This provides the foundational infrastructure for desktop onboarding
flow to determine whether to show local setup, remote connection,
or recovery screens based on OpenCode availability and remote host
reachability.
* feat: add desktop onboarding UI components
Add comprehensive onboarding flow for desktop app:
- ChooserScreen: First-launch local/remote selection
- LocalSetupScreen: CLI installation guidance and manual detection
- RecoveryScreen: Recovery mode with routing to local/remote
- RemoteConnectionForm: Remote host connection with validation
- DesktopConnectionRecovery: Recovery variants and routing logic
- ConnectionSettingsPage: Manage remote connections
Components handle:
- Local vs remote choice persistence
- Recovery scenarios (unreachable, wrong-service, missing)
- Manual CLI detection (replaced auto-polling)
- Back navigation and state preservation
* feat: integrate desktop onboarding with app shell
- Update App.tsx to handle onboarding routing and recovery
- Add onboarding mode switching (first-launch/local-setup/recovery)
- Integrate desktop hosts in SettingsView
- Update DesktopHostSwitcher with recovery routing
- Add desktop shell utilities for onboarding detection
- Update web manifest for desktop app metadata
Completes the desktop onboarding feature integration,
allowing users to choose local or remote OpenCode on
first launch and recover from connection failures.
* fix: hide back button in remote connection form for first-launch chooser
In first-launch chooser mode, the back button is redundant since users
can simply click the "Local Install" tab. The back button is still shown
in recovery mode where there's no tab interface.
Changes:
- Add showBackButton prop to RemoteConnectionForm (default: true)
- Set showBackButton={false} in ChooserScreen remote tab
- Keep showBackButton={true} in RecoveryScreen for navigation
* refactor: remove Connection Settings page and simplify recovery UI
Remove the Connection Settings page as it was redundant:
- Local server is single-instance (no need to "choose")
- Remote servers are one-time setup (first-launch chooser)
- SSH Instances remain for multi-instance management
Changes:
- Remove ConnectionSettingsPage component and directory
- Remove 'connection' from Settings metadata
- Remove "Open Settings" button from recovery screens
- Remove desktopBootBypassToSettings state and logic
- Update recovery config to use 'local' icon instead of 'settings'
- Update tests to reflect removed showOpenSettings field
This simplifies the UX by focusing on:
- First-launch chooser for initial local/remote decision
- Remote Instances (SSH) for managing multiple remote machines
- No persistent "server management" needed for typical desktop usage
* fix: remove unused enableCliPolling prop and clean up TypeScript errors
Remove the obsolete enableCliPolling prop that was used for auto-
polling CLI detection. We replaced this with manual "Check and Continue"
button in a previous commit, so this prop is no longer needed.
Changes:
- Remove enableCliPolling from OnboardingScreen props and usage
- Remove enableCliPolling from App.tsx calls
- Remove unused 'connection' case from getSettingsNavIcon()
- Remove unused RiGlobalLine import
This resolves all TypeScript compilation errors reported by Copilot.
* fix: remove unused onChooseLocal prop and CLI_MISSING_ERROR_REGEX
These were left over from the refactoring:
- onChooseLocal in RecoveryScreen was defined but never used
- CLI_MISSING_ERROR_REGEX in App.tsx was leftover from removed enableCliPolling code
* fix: remove unused variables and fix React Hook dependency warnings
Remove unused memoized components and variables that were causing
lint errors in packages/ui:
- MainLayout.tsx: Remove unused MemoHeader, MemoChatView, MemoPlanView,
MemoGitView, MemoDiffView, MemoTerminalView, MemoFilesView,
MemoRightSidebarTabs, DesktopLeftSidebar, and DesktopRightPanel
- useGitHubPrStatusStore.ts: Remove unused prVisualPriority function
- useChatScrollManager.ts: Add missing markProgrammaticScroll dependency
to React.useEffect hook
These fixes resolve the CI lint failures in PR 850.
* chore: remove local claude settings from repo
* refactor(desktop): drop vibrancy code from onboarding PR
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
bb1d522838
commit
9b169aaacf
@@ -97,6 +97,7 @@ const makeId = (): string => {
|
||||
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
|
||||
if (status === 'ok') return 'bg-status-success';
|
||||
if (status === 'auth') return 'bg-status-warning';
|
||||
if (status === 'wrong-service') return 'bg-status-error';
|
||||
if (status === 'unreachable') return 'bg-status-error';
|
||||
return 'bg-muted-foreground/40';
|
||||
};
|
||||
@@ -104,6 +105,7 @@ const statusDotClass = (status: HostProbeResult['status'] | null): string => {
|
||||
const statusLabel = (status: HostProbeResult['status'] | null): string => {
|
||||
if (status === 'ok') return 'Connected';
|
||||
if (status === 'auth') return 'Auth required';
|
||||
if (status === 'wrong-service') return 'Wrong service';
|
||||
if (status === 'unreachable') return 'Unreachable';
|
||||
return 'Unknown';
|
||||
};
|
||||
@@ -111,6 +113,7 @@ const statusLabel = (status: HostProbeResult['status'] | null): string => {
|
||||
const statusIcon = (status: HostProbeResult['status'] | null) => {
|
||||
if (status === 'ok') return <RiCheckLine className="h-4 w-4" />;
|
||||
if (status === 'auth') return <RiShieldKeyholeLine className="h-4 w-4" />;
|
||||
if (status === 'wrong-service') return <RiCloudOffLine className="h-4 w-4" />;
|
||||
if (status === 'unreachable') return <RiCloudOffLine className="h-4 w-4" />;
|
||||
return <RiEarthLine className="h-4 w-4" />;
|
||||
};
|
||||
@@ -516,7 +519,7 @@ export function DesktopHostSwitcherDialog({
|
||||
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
|
||||
}));
|
||||
|
||||
if (probe.status === 'unreachable') {
|
||||
if (probe.status === 'unreachable' || probe.status === 'wrong-service') {
|
||||
toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`);
|
||||
setSwitchingHostId(null);
|
||||
return;
|
||||
@@ -909,7 +912,7 @@ export function DesktopHostSwitcherDialog({
|
||||
)}
|
||||
onClick={() => void setDefault(host.id)}
|
||||
aria-label={isDefault ? 'Default instance' : 'Set as default'}
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || (!isDefault && (statusKind === 'unreachable' || statusKind === 'wrong-service'))}
|
||||
>
|
||||
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -925,7 +928,7 @@ export function DesktopHostSwitcherDialog({
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
|
||||
statusKind === 'unreachable'
|
||||
statusKind === 'unreachable' || statusKind === 'wrong-service'
|
||||
? 'text-muted-foreground/30 cursor-not-allowed'
|
||||
: 'text-muted-foreground/60 hover:text-foreground',
|
||||
)}
|
||||
@@ -933,14 +936,14 @@ export function DesktopHostSwitcherDialog({
|
||||
e.stopPropagation();
|
||||
openInNewWindow(host);
|
||||
}}
|
||||
disabled={statusKind === 'unreachable'}
|
||||
disabled={statusKind === 'unreachable' || statusKind === 'wrong-service'}
|
||||
aria-label="Open in new window"
|
||||
>
|
||||
<RiWindowLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>
|
||||
{statusKind === 'unreachable' ? 'Instance unreachable' : 'Open in new window'}
|
||||
{(statusKind === 'unreachable' || statusKind === 'wrong-service') ? 'Instance unreachable' : 'Open in new window'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import React from 'react';
|
||||
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { restartDesktopApp } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RemoteConnectionForm } from './RemoteConnectionForm';
|
||||
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const DOCS_URL = 'https://opencode.ai/docs';
|
||||
const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
|
||||
|
||||
type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
|
||||
|
||||
type ChooserScreenProps = {
|
||||
/** Callback when CLI becomes available */
|
||||
onCliAvailable?: () => void;
|
||||
};
|
||||
|
||||
function BashCommand({ onCopy }: { onCopy: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code>
|
||||
<span style={{ color: 'var(--syntax-keyword)' }}>curl</span>
|
||||
<span className="text-muted-foreground"> -fsSL </span>
|
||||
<span style={{ color: 'var(--syntax-string)' }}>https://opencode.ai/install</span>
|
||||
<span className="text-muted-foreground"> | </span>
|
||||
<span style={{ color: 'var(--syntax-keyword)' }}>bash</span>
|
||||
</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HINT_DELAY_MS = 30000;
|
||||
|
||||
export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [showHint, setShowHint] = React.useState(false);
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
||||
const [isRetrying, setIsRetrying] = React.useState(false);
|
||||
const [isChecking, setIsChecking] = React.useState(false);
|
||||
const [checkError, setCheckError] = React.useState<string | null>(null);
|
||||
const [opencodeBinary, setOpencodeBinary] = React.useState('');
|
||||
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
|
||||
const [activeTab, setActiveTab] = React.useState<'local' | 'remote'>('local');
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsDesktopApp(isDesktopShell());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
setPlatform('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/Windows/i.test(ua)) {
|
||||
setPlatform('windows');
|
||||
return;
|
||||
}
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) {
|
||||
setPlatform('macos');
|
||||
return;
|
||||
}
|
||||
if (/Linux/i.test(ua)) {
|
||||
setPlatform('linux');
|
||||
return;
|
||||
}
|
||||
setPlatform('unknown');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
|
||||
if (value) {
|
||||
setOpencodeBinary(value);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (isDesktopApp && isTauriShell()) {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch (error) {
|
||||
console.error('Failed to start window dragging:', error);
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
return data.openCodeRunning === true || data.isOpenCodeReady === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBrowse = React.useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp || !isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
if (!tauri?.dialog?.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: 'Select opencode binary',
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === 'string' && selected.trim().length > 0) {
|
||||
setOpencodeBinary(selected.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
// Persist the user's first choice (local or remote)
|
||||
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
||||
if (!isTauriShell()) return;
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
await desktopHostsSet({
|
||||
...config,
|
||||
// Only change defaultHostId when switching to local; remote keeps
|
||||
// whatever was there (or null) until a successful connect.
|
||||
...(choice === 'local' ? { defaultHostId: 'local' } : {}),
|
||||
initialHostChoiceCompleted: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApplyPath = React.useCallback(async () => {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
|
||||
|
||||
// In first-launch mode, persist the local choice when user manually
|
||||
// sets the binary path, so the choice is remembered after restart.
|
||||
if (isTauriShell()) {
|
||||
await persistFirstChoice('local');
|
||||
}
|
||||
|
||||
// In desktop boot flow, always restart the entire Tauri app so Rust
|
||||
// can re-evaluate the boot outcome with the updated binary path.
|
||||
if (isTauriShell()) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
} finally {
|
||||
setTimeout(() => setIsRetrying(false), 1000);
|
||||
}
|
||||
}, [opencodeBinary, persistFirstChoice]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(INSTALL_COMMAND);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleChooseRemote = React.useCallback(() => {
|
||||
setActiveTab('remote');
|
||||
}, []);
|
||||
|
||||
const handleCheckAndContinue = React.useCallback(async () => {
|
||||
setIsChecking(true);
|
||||
setCheckError(null);
|
||||
try {
|
||||
const available = await checkCliAvailability();
|
||||
if (available) {
|
||||
// In first-launch mode, persist the local choice when CLI becomes
|
||||
// available, so the choice is remembered after restart.
|
||||
if (isTauriShell()) {
|
||||
await persistFirstChoice('local');
|
||||
}
|
||||
onCliAvailable?.();
|
||||
} else {
|
||||
setCheckError('OpenCode CLI is not ready yet. Please confirm installation is complete and try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setCheckError(err instanceof Error ? err.message : 'Detection failed');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [checkCliAvailability, onCliAvailable, persistFirstChoice]);
|
||||
|
||||
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
|
||||
const binaryPlaceholder =
|
||||
platform === 'windows'
|
||||
? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
|
||||
: platform === 'linux'
|
||||
? '/home/you/.bun/bin/opencode'
|
||||
: '/Users/you/.bun/bin/opencode';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div className="w-full space-y-4 text-center">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
Welcome to OpenChamber
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Choose how you want to connect to get started.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDesktopApp && isTauriShell() && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 max-w-[200px] px-4 py-2.5 rounded-lg border transition-all text-sm',
|
||||
activeTab === 'local'
|
||||
? 'border-[var(--interactive-selection)] text-foreground bg-[var(--interactive-selection)]/10'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
onClick={() => setActiveTab('local')}
|
||||
>
|
||||
Local Install
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 max-w-[200px] px-4 py-2.5 rounded-lg border transition-all text-sm',
|
||||
activeTab === 'remote'
|
||||
? 'border-[var(--interactive-selection)] text-foreground bg-[var(--interactive-selection)]/10'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
)}
|
||||
onClick={handleChooseRemote}
|
||||
>
|
||||
Connect Remote
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDesktopApp && isTauriShell() && activeTab === 'remote' ? (
|
||||
<RemoteConnectionForm
|
||||
onBack={() => setActiveTab('local')}
|
||||
showBackButton={false}
|
||||
onSwitchToLocal={() => setActiveTab('local')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{(!isDesktopApp || !isTauriShell() || activeTab === 'local') && (
|
||||
<>
|
||||
{platform === 'windows' && (
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
|
||||
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
|
||||
<li>Run the install command below inside your WSL terminal.</li>
|
||||
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
|
||||
{copied ? (
|
||||
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--status-success)' }}>
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
Copied to clipboard
|
||||
</div>
|
||||
) : (
|
||||
<BashCommand onCopy={handleCopy} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
|
||||
>
|
||||
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
|
||||
{checkError && (
|
||||
<div className="mx-auto max-w-md rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{checkError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCheckAndContinue}
|
||||
disabled={isChecking}
|
||||
className="w-full max-w-xs"
|
||||
size="lg"
|
||||
>
|
||||
{isChecking ? 'Checking...' : "I've completed installation, check and continue"}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-xl pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Already installed? Set the OpenCode CLI path:</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={opencodeBinary}
|
||||
onChange={(e) => setOpencodeBinary(e.target.value)}
|
||||
placeholder={binaryPlaceholder}
|
||||
disabled={isRetrying}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBrowse}
|
||||
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApplyPath}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showHint && activeTab === 'local' && (
|
||||
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
|
||||
{platform === 'windows' ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
On Windows, install and run OpenCode in WSL for best compatibility.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { RiRefreshLine, RiServerLine, RiMacbookLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
import {
|
||||
getDesktopRecoveryConfig,
|
||||
type RecoveryVariant,
|
||||
} from './desktopRecoveryConfig';
|
||||
|
||||
export type { RecoveryVariant } from './desktopRecoveryConfig';
|
||||
|
||||
export type DesktopConnectionRecoveryProps = {
|
||||
variant: RecoveryVariant;
|
||||
hostLabel?: string;
|
||||
hostUrl?: string;
|
||||
onRetry?: () => void;
|
||||
onUseLocal?: () => void;
|
||||
onUseRemote?: () => void;
|
||||
isRetrying?: boolean;
|
||||
};
|
||||
|
||||
/** Maps iconKey from config to actual icon component */
|
||||
function getRecoveryIcon(iconKey: 'local' | 'remote'): React.ReactNode {
|
||||
switch (iconKey) {
|
||||
case 'local':
|
||||
return <RiMacbookLine className="h-8 w-8" />;
|
||||
case 'remote':
|
||||
return <RiServerLine className="h-8 w-8" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function DesktopConnectionRecovery({
|
||||
variant,
|
||||
hostLabel,
|
||||
hostUrl,
|
||||
onRetry,
|
||||
onUseLocal,
|
||||
onUseRemote,
|
||||
isRetrying = false,
|
||||
}: DesktopConnectionRecoveryProps) {
|
||||
const config = getDesktopRecoveryConfig(variant, hostLabel, hostUrl);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8">
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* Icon and title */}
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div
|
||||
className="p-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: 'var(--status-warning)',
|
||||
opacity: 0.15,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--status-warning)' }}>
|
||||
{getRecoveryIcon(config.iconKey)}
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
|
||||
{config.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm max-w-sm">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Host info if available */}
|
||||
{hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
|
||||
<div className="rounded-lg border border-border bg-background/50 p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Server Address</div>
|
||||
<div className="font-mono text-sm text-foreground truncate">{redactSensitiveUrl(hostUrl)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{config.showRetry && onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
disabled={isRetrying}
|
||||
className="w-full"
|
||||
>
|
||||
<RiRefreshLine className={cn('h-4 w-4', isRetrying && 'animate-spin')} />
|
||||
{isRetrying ? 'Retrying…' : (config.retryLabel ?? 'Retry Connection')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{config.showUseLocal && onUseLocal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUseLocal}
|
||||
disabled={isRetrying}
|
||||
className="flex-1"
|
||||
>
|
||||
<RiMacbookLine className="h-4 w-4" />
|
||||
{config.useLocalLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{config.showUseRemote && onUseRemote && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUseRemote}
|
||||
disabled={isRetrying}
|
||||
className="flex-1"
|
||||
>
|
||||
<RiServerLine className="h-4 w-4" />
|
||||
{config.useRemoteLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import React from 'react';
|
||||
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { restartDesktopApp } from '@/lib/desktop';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const DOCS_URL = 'https://opencode.ai/docs';
|
||||
const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
|
||||
|
||||
type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
|
||||
|
||||
type LocalSetupScreenProps = {
|
||||
/** Callback when user goes back */
|
||||
onBack: () => void;
|
||||
/** Callback when CLI becomes available */
|
||||
onCliAvailable?: () => void;
|
||||
/** Whether this screen was entered from recovery flow (shows "Connect to Remote" link) */
|
||||
isFromRecovery?: boolean;
|
||||
/** Callback when user wants to switch to remote */
|
||||
onSwitchToRemote?: () => void;
|
||||
};
|
||||
|
||||
function BashCommand({ onCopy }: { onCopy: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code>
|
||||
<span style={{ color: 'var(--syntax-keyword)' }}>curl</span>
|
||||
<span className="text-muted-foreground"> -fsSL </span>
|
||||
<span style={{ color: 'var(--syntax-string)' }}>https://opencode.ai/install</span>
|
||||
<span className="text-muted-foreground"> | </span>
|
||||
<span style={{ color: 'var(--syntax-keyword)' }}>bash</span>
|
||||
</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HINT_DELAY_MS = 30000;
|
||||
|
||||
export function LocalSetupScreen({
|
||||
onBack,
|
||||
onCliAvailable,
|
||||
isFromRecovery = false,
|
||||
onSwitchToRemote,
|
||||
}: LocalSetupScreenProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [showHint, setShowHint] = React.useState(false);
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
||||
const [isRetrying, setIsRetrying] = React.useState(false);
|
||||
const [isChecking, setIsChecking] = React.useState(false);
|
||||
const [checkError, setCheckError] = React.useState<string | null>(null);
|
||||
const [opencodeBinary, setOpencodeBinary] = React.useState('');
|
||||
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsDesktopApp(isDesktopShell());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
setPlatform('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/Windows/i.test(ua)) {
|
||||
setPlatform('windows');
|
||||
return;
|
||||
}
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) {
|
||||
setPlatform('macos');
|
||||
return;
|
||||
}
|
||||
if (/Linux/i.test(ua)) {
|
||||
setPlatform('linux');
|
||||
return;
|
||||
}
|
||||
setPlatform('unknown');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
|
||||
if (value) {
|
||||
setOpencodeBinary(value);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (isDesktopApp && isTauriShell()) {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch (error) {
|
||||
console.error('Failed to start window dragging:', error);
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
return data.openCodeRunning === true || data.isOpenCodeReady === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBrowse = React.useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp || !isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
if (!tauri?.dialog?.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: 'Select opencode binary',
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === 'string' && selected.trim().length > 0) {
|
||||
setOpencodeBinary(selected.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const handleApplyPath = React.useCallback(async () => {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
|
||||
|
||||
// In desktop boot flow, always restart the entire Tauri app so Rust
|
||||
// can re-evaluate the boot outcome with the updated binary path.
|
||||
if (isTauriShell()) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
} finally {
|
||||
setTimeout(() => setIsRetrying(false), 1000);
|
||||
}
|
||||
}, [opencodeBinary]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(INSTALL_COMMAND);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCheckAndContinue = React.useCallback(async () => {
|
||||
setIsChecking(true);
|
||||
setCheckError(null);
|
||||
try {
|
||||
const available = await checkCliAvailability();
|
||||
if (available) {
|
||||
// CLI is available, proceed to main screen
|
||||
onCliAvailable?.();
|
||||
} else {
|
||||
setCheckError('OpenCode CLI is not ready yet. Please confirm installation is complete and try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setCheckError(err instanceof Error ? err.message : 'Detection failed');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [checkCliAvailability, onCliAvailable]);
|
||||
|
||||
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
|
||||
const binaryPlaceholder =
|
||||
platform === 'windows'
|
||||
? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
|
||||
: platform === 'linux'
|
||||
? '/home/you/.bun/bin/opencode'
|
||||
: '/Users/you/.bun/bin/opencode';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div className="w-full max-w-lg space-y-4 text-center">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
className="p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
Setting Up OpenCode
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Install OpenCode CLI to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{platform === 'windows' && (
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
|
||||
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
|
||||
<li>Run the install command below inside your WSL terminal.</li>
|
||||
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
|
||||
{copied ? (
|
||||
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--status-success)' }}>
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
Copied to clipboard
|
||||
</div>
|
||||
) : (
|
||||
<BashCommand onCopy={handleCopy} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
|
||||
>
|
||||
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
|
||||
{checkError && (
|
||||
<div className="mx-auto max-w-md rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{checkError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCheckAndContinue}
|
||||
disabled={isChecking}
|
||||
className="w-full max-w-xs"
|
||||
size="lg"
|
||||
>
|
||||
{isChecking ? 'Checking...' : "I've completed installation, check and continue"}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-xl pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Already installed? Set the OpenCode CLI path:</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={opencodeBinary}
|
||||
onChange={(e) => setOpencodeBinary(e.target.value)}
|
||||
placeholder={binaryPlaceholder}
|
||||
disabled={isRetrying}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBrowse}
|
||||
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApplyPath}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFromRecovery && onSwitchToRemote && (
|
||||
<div className="text-center pt-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Prefer to use a remote server?
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToRemote}
|
||||
>
|
||||
Connect to Remote Server →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
|
||||
{platform === 'windows' ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
On Windows, install and run OpenCode in WSL for best compatibility.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,335 +1,96 @@
|
||||
import React from 'react';
|
||||
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
|
||||
import { isDesktopShell, isTauriShell, startDesktopWindowDrag } from '@/lib/desktop';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { ChooserScreen } from './ChooserScreen';
|
||||
import { LocalSetupScreen } from './LocalSetupScreen';
|
||||
import { RecoveryScreen } from './RecoveryScreen';
|
||||
import type { RecoveryVariant } from './DesktopConnectionRecovery';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const DOCS_URL = 'https://opencode.ai/docs';
|
||||
const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
|
||||
|
||||
type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
|
||||
export type OnboardingScreenMode = 'first-launch' | 'local-setup' | 'recovery';
|
||||
|
||||
type OnboardingScreenProps = {
|
||||
/** Callback when user goes back from local-setup */
|
||||
onBack?: () => void;
|
||||
/** Callback when CLI becomes available */
|
||||
onCliAvailable?: () => void;
|
||||
/** Screen mode to render */
|
||||
mode?: OnboardingScreenMode;
|
||||
/** Recovery variant (only used when mode is 'recovery') */
|
||||
recoveryVariant?: RecoveryVariant;
|
||||
/** Host URL for recovery context */
|
||||
recoveryHostUrl?: string;
|
||||
/** Host label for recovery context */
|
||||
recoveryHostLabel?: string;
|
||||
/** Callback when user enters local setup from recovery */
|
||||
onEnterLocalSetup?: () => void;
|
||||
/** Callback when user wants to switch to remote (first-launch only) */
|
||||
onChooseRemote?: () => void;
|
||||
};
|
||||
|
||||
function BashCommand({ onCopy }: { onCopy: () => void }) {
|
||||
export function OnboardingScreen({
|
||||
onBack,
|
||||
onCliAvailable,
|
||||
mode = 'first-launch',
|
||||
recoveryVariant = 'missing-default-host',
|
||||
recoveryHostUrl,
|
||||
recoveryHostLabel,
|
||||
onEnterLocalSetup,
|
||||
}: OnboardingScreenProps) {
|
||||
const [showRecoveryRemoteForm, setShowRecoveryRemoteForm] = React.useState(false);
|
||||
const [recoveryEnteredLocalSetup, setRecoveryEnteredLocalSetup] = React.useState(false);
|
||||
|
||||
// Reset transient recovery subflow state when the flow identity changes, so
|
||||
// stale local-setup or remote-form views don't bleed across prop updates.
|
||||
React.useEffect(() => {
|
||||
setRecoveryEnteredLocalSetup(false);
|
||||
setShowRecoveryRemoteForm(false);
|
||||
}, [mode, recoveryVariant, recoveryHostUrl, recoveryHostLabel]);
|
||||
|
||||
// Derive the effective mode: recovery → local-setup can fall through to the
|
||||
// existing local-setup branch instead of getting stuck behind the early return.
|
||||
const effectiveMode = recoveryEnteredLocalSetup ? 'local-setup' : mode;
|
||||
|
||||
// Recovery mode
|
||||
if (effectiveMode === 'recovery') {
|
||||
return (
|
||||
<RecoveryScreen
|
||||
variant={recoveryVariant}
|
||||
hostUrl={recoveryHostUrl}
|
||||
hostLabel={recoveryHostLabel}
|
||||
showRemoteForm={showRecoveryRemoteForm}
|
||||
onCloseRemoteForm={() => setShowRecoveryRemoteForm(false)}
|
||||
onSwitchToLocalFromRemote={() => {
|
||||
setShowRecoveryRemoteForm(false);
|
||||
setRecoveryEnteredLocalSetup(true);
|
||||
}}
|
||||
onEnterLocalSetup={() => {
|
||||
setRecoveryEnteredLocalSetup(true);
|
||||
onEnterLocalSetup?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Local-setup mode
|
||||
if (effectiveMode === 'local-setup') {
|
||||
return (
|
||||
<LocalSetupScreen
|
||||
onBack={() => {
|
||||
if (recoveryEnteredLocalSetup) {
|
||||
setRecoveryEnteredLocalSetup(false);
|
||||
} else {
|
||||
onBack?.();
|
||||
}
|
||||
}}
|
||||
onCliAvailable={onCliAvailable}
|
||||
isFromRecovery={recoveryEnteredLocalSetup}
|
||||
onSwitchToRemote={() => setShowRecoveryRemoteForm(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// First-launch mode (default)
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code>
|
||||
<span style={{ color: 'var(--syntax-keyword)' }}>curl</span>
|
||||
<span className="text-muted-foreground"> -fsSL </span>
|
||||
<span style={{ color: 'var(--syntax-string)' }}>https://opencode.ai/install</span>
|
||||
<span className="text-muted-foreground"> | </span>
|
||||
<span style={{ color: 'var(--syntax-keyword)' }}>bash</span>
|
||||
</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HINT_DELAY_MS = 30000;
|
||||
|
||||
export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [showHint, setShowHint] = React.useState(false);
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
||||
const [isRetrying, setIsRetrying] = React.useState(false);
|
||||
const [opencodeBinary, setOpencodeBinary] = React.useState('');
|
||||
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsDesktopApp(isDesktopShell());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
setPlatform('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/Windows/i.test(ua)) {
|
||||
setPlatform('windows');
|
||||
return;
|
||||
}
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) {
|
||||
setPlatform('macos');
|
||||
return;
|
||||
}
|
||||
if (/Linux/i.test(ua)) {
|
||||
setPlatform('linux');
|
||||
return;
|
||||
}
|
||||
setPlatform('unknown');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) return;
|
||||
const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
|
||||
if (!data || cancelled) return;
|
||||
const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
|
||||
if (value) {
|
||||
setOpencodeBinary(value);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if (isDesktopApp) {
|
||||
await startDesktopWindowDrag();
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const checkCliAvailability = React.useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json();
|
||||
return data.openCodeRunning === true || data.isOpenCodeReady === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRetry = React.useCallback(async () => {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
} finally {
|
||||
setTimeout(() => setIsRetrying(false), 1000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBrowse = React.useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp || !isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
if (!tauri?.dialog?.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
title: 'Select opencode binary',
|
||||
multiple: false,
|
||||
directory: false,
|
||||
});
|
||||
if (typeof selected === 'string' && selected.trim().length > 0) {
|
||||
setOpencodeBinary(selected.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
const handleApplyPath = React.useCallback(async () => {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
} finally {
|
||||
setTimeout(() => setIsRetrying(false), 1000);
|
||||
}
|
||||
}, [opencodeBinary]);
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(INSTALL_COMMAND);
|
||||
if (result.ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
console.error('Failed to copy:', result.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const poll = async () => {
|
||||
const available = await checkCliAvailability();
|
||||
if (available) {
|
||||
onCliAvailable?.();
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
||||
poll();
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [checkCliAvailability, onCliAvailable]);
|
||||
|
||||
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
|
||||
const binaryPlaceholder =
|
||||
platform === 'windows'
|
||||
? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
|
||||
: platform === 'linux'
|
||||
? '/home/you/.bun/bin/opencode'
|
||||
: '/Users/you/.bun/bin/opencode';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-background p-8 relative cursor-default select-none"
|
||||
onMouseDown={handleDragStart}
|
||||
>
|
||||
<div className="w-full space-y-4 text-center">
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
Welcome to OpenChamber
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<a
|
||||
href="https://opencode.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
OpenCode CLI
|
||||
<RiExternalLinkLine className="h-4 w-4" />
|
||||
</a>
|
||||
{' '}is required to continue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{platform === 'windows' && (
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
|
||||
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
|
||||
<li>Run the install command below inside your WSL terminal.</li>
|
||||
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-background border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
|
||||
{copied ? (
|
||||
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--status-success)' }}>
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
Copied to clipboard
|
||||
</div>
|
||||
) : (
|
||||
<BashCommand onCopy={handleCopy} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
|
||||
>
|
||||
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
|
||||
<p className="text-sm text-muted-foreground animate-pulse">
|
||||
Waiting for OpenCode installation...
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isRetrying ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-xl pt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Already installed? Set the OpenCode CLI path:</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={opencodeBinary}
|
||||
onChange={(e) => setOpencodeBinary(e.target.value)}
|
||||
placeholder={binaryPlaceholder}
|
||||
disabled={isRetrying}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBrowse}
|
||||
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
|
||||
>
|
||||
Browse
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleApplyPath}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
|
||||
{platform === 'windows' ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
On Windows, install and run OpenCode in WSL for best compatibility.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChooserScreen
|
||||
onCliAvailable={onCliAvailable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { isTauriShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnectionRecovery';
|
||||
import { RemoteConnectionForm } from './RemoteConnectionForm';
|
||||
import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
|
||||
import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
|
||||
|
||||
type RecoveryScreenProps = {
|
||||
/** Recovery variant */
|
||||
variant: RecoveryVariant;
|
||||
/** Host URL for recovery context */
|
||||
hostUrl?: string;
|
||||
/** Host label for recovery context */
|
||||
hostLabel?: string;
|
||||
/** Callback when user wants to retry */
|
||||
onRetry?: () => void;
|
||||
/** Callback when user chooses remote */
|
||||
onChooseRemote?: () => void;
|
||||
/** Whether to show the remote connection form */
|
||||
showRemoteForm?: boolean;
|
||||
/** Callback when closing remote form */
|
||||
onCloseRemoteForm?: () => void;
|
||||
/** Callback when switching to local from remote form */
|
||||
onSwitchToLocalFromRemote?: () => void;
|
||||
/** Callback when entering local setup */
|
||||
onEnterLocalSetup?: () => void;
|
||||
/** Whether retry action is in progress */
|
||||
isRetrying?: boolean;
|
||||
};
|
||||
|
||||
export function RecoveryScreen({
|
||||
variant,
|
||||
hostUrl,
|
||||
hostLabel,
|
||||
onRetry,
|
||||
onChooseRemote,
|
||||
showRemoteForm = false,
|
||||
onCloseRemoteForm,
|
||||
onSwitchToLocalFromRemote,
|
||||
onEnterLocalSetup,
|
||||
isRetrying = false,
|
||||
}: RecoveryScreenProps) {
|
||||
// Persist the user's first choice (local or remote)
|
||||
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
||||
if (!isTauriShell()) return;
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
await desktopHostsSet({
|
||||
...config,
|
||||
// Only change defaultHostId when switching to local; remote keeps
|
||||
// whatever was there (or null) until a successful connect.
|
||||
...(choice === 'local' ? { defaultHostId: 'local' } : {}),
|
||||
initialHostChoiceCompleted: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRecoveryRetry = React.useCallback(async () => {
|
||||
// In desktop boot flow, always restart the entire Tauri app so Rust
|
||||
// can re-evaluate the boot outcome.
|
||||
if (isTauriShell()) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch('/api/config/reload', { method: 'POST' });
|
||||
onRetry?.();
|
||||
}, [onRetry]);
|
||||
|
||||
const handleRecoveryUseLocal = React.useCallback(async () => {
|
||||
const step = resolveRecoveryNextStep(variant, 'use-local');
|
||||
if (step.kind === 'local-setup') {
|
||||
// local-unavailable + local → enter local-setup subflow without reload
|
||||
onEnterLocalSetup?.();
|
||||
return;
|
||||
}
|
||||
// switch-default-to-local → persist local choice and restart
|
||||
await persistFirstChoice('local');
|
||||
|
||||
if (isTauriShell()) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}, [variant, persistFirstChoice, onEnterLocalSetup]);
|
||||
|
||||
const handleRecoveryUseRemote = React.useCallback(() => {
|
||||
const step = resolveRecoveryNextStep(variant, 'use-remote');
|
||||
if (step.kind === 'remote-form') {
|
||||
onChooseRemote?.();
|
||||
}
|
||||
}, [variant, onChooseRemote]);
|
||||
|
||||
// Recovery mode — show recovery component first; only switch to remote form on explicit user action
|
||||
if (showRemoteForm) {
|
||||
// For remote-wrong-service, do NOT auto-populate the known bad URL
|
||||
const prefillUrl = variant === 'remote-wrong-service' ? '' : (hostUrl || '');
|
||||
const prefillLabel = variant === 'remote-wrong-service' ? '' : (hostLabel || '');
|
||||
return (
|
||||
<RemoteConnectionForm
|
||||
onBack={onCloseRemoteForm || (() => onChooseRemote?.())}
|
||||
initialUrl={prefillUrl}
|
||||
initialLabel={prefillLabel}
|
||||
isRecoveryMode={true}
|
||||
onSwitchToLocal={onSwitchToLocalFromRemote || (() => {
|
||||
persistFirstChoice('local').then(() => {
|
||||
if (isTauriShell()) {
|
||||
restartDesktopApp();
|
||||
} else {
|
||||
onEnterLocalSetup?.();
|
||||
}
|
||||
});
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DesktopConnectionRecovery
|
||||
variant={variant}
|
||||
hostLabel={hostLabel}
|
||||
hostUrl={hostUrl}
|
||||
onRetry={handleRecoveryRetry}
|
||||
onUseLocal={handleRecoveryUseLocal}
|
||||
onUseRemote={handleRecoveryUseRemote}
|
||||
isRetrying={isRetrying}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
desktopHostsGet,
|
||||
desktopHostsSet,
|
||||
desktopHostProbe,
|
||||
normalizeHostUrl,
|
||||
type HostProbeResult,
|
||||
} from '@/lib/desktopHosts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
|
||||
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 getProbeStatusMessage(status: ProbeStatus): string | null {
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
return null; // Success is shown separately
|
||||
case 'auth':
|
||||
return 'Server requires authentication. You can still connect, but may need to provide credentials.';
|
||||
case 'wrong-service':
|
||||
return 'Server responded but is not running OpenChamber. Verify the address points to an OpenChamber server.';
|
||||
case 'unreachable':
|
||||
return 'Server is unreachable. Check your network connection and verify the server address.';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockingStatus(status: ProbeStatus): boolean {
|
||||
return status === 'wrong-service' || status === 'unreachable';
|
||||
}
|
||||
|
||||
export function RemoteConnectionForm({
|
||||
onBack,
|
||||
showBackButton = true,
|
||||
initialUrl = '',
|
||||
initialLabel = '',
|
||||
isRecoveryMode = false,
|
||||
onConnect,
|
||||
onSwitchToLocal,
|
||||
}: RemoteConnectionFormProps) {
|
||||
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 normalizedUrl = normalizeHostUrl(url);
|
||||
|
||||
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' ? 'success' : 'error');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Connection test failed');
|
||||
setState('error');
|
||||
}
|
||||
}, [normalizedUrl]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (!normalizedUrl) return;
|
||||
|
||||
setState('testing');
|
||||
setProbeResult(null);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const probe = await desktopHostProbe(normalizedUrl);
|
||||
setProbeResult(probe);
|
||||
|
||||
// Block connection on wrong-service or unreachable
|
||||
if (isBlockingStatus(probe.status)) {
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
const hostLabel = label.trim() || normalizedUrl;
|
||||
|
||||
const existingHost = config.hosts.find(
|
||||
(h) => h.url === normalizedUrl
|
||||
);
|
||||
|
||||
const hostId = existingHost ? existingHost.id : `host-${Date.now().toString(16)}`;
|
||||
|
||||
const newHost = {
|
||||
id: hostId,
|
||||
label: hostLabel,
|
||||
url: normalizedUrl,
|
||||
};
|
||||
|
||||
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 (isTauriShell()) {
|
||||
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_restart');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save connection');
|
||||
setState('error');
|
||||
}
|
||||
}, [normalizedUrl, label, onConnect]);
|
||||
|
||||
const isTesting = state === 'testing';
|
||||
const canTest = normalizedUrl !== null && !isTesting;
|
||||
const canConnect = normalizedUrl !== null && !isTesting && !isBlockingStatus(probeResult?.status ?? null);
|
||||
|
||||
const probeMessage = getProbeStatusMessage(probeResult?.status ?? null);
|
||||
const isSuccess = probeResult?.status === 'ok';
|
||||
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">
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="typography-ui-header text-xl font-semibold text-foreground">
|
||||
{isRecoveryMode ? 'Connect to a Different Server' : 'Connect to Remote Server'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isRecoveryMode
|
||||
? 'Enter the address of an OpenChamber server to connect to.'
|
||||
: 'Enter the address of an OpenChamber server to connect to.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="remote-url" className="text-sm text-foreground">
|
||||
Server Address
|
||||
</label>
|
||||
<Input
|
||||
id="remote-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
placeholder="https://your-server.example.com:4096"
|
||||
disabled={isTesting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="remote-label" className="text-sm text-foreground">
|
||||
Name (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="remote-label"
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={handleLabelChange}
|
||||
placeholder="My Remote Server"
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
Connected successfully ({probeResult.latencyMs}ms)
|
||||
</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)',
|
||||
}}
|
||||
>
|
||||
Server requires authentication. You can still connect.
|
||||
</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">Connection Failed</div>
|
||||
<div className="opacity-90">{probeMessage}</div>
|
||||
</div>
|
||||
<div className="text-xs opacity-80">
|
||||
{probeResult.status === 'unreachable'
|
||||
? 'Suggestions: Check the server address, verify the server is running, or check your network connection.'
|
||||
: 'Suggestions: Verify the URL points to an OpenChamber server, or contact the server administrator.'}
|
||||
</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 ? 'Testing\u2026' : 'Test Connection'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
disabled={!canConnect}
|
||||
>
|
||||
Connect & Restart
|
||||
</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">What would you like to do?</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="flex-1"
|
||||
>
|
||||
Choose Different Server
|
||||
</Button>
|
||||
{!isRecoveryMode && onSwitchToLocal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSwitchToLocal}
|
||||
className="flex-1"
|
||||
>
|
||||
Use Local Instead
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { getDesktopRecoveryConfig } from './desktopRecoveryConfig';
|
||||
|
||||
describe('getDesktopRecoveryConfig', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. local-unavailable: both actions visible + retry labeled "Retry Local"
|
||||
// ---------------------------------------------------------------------------
|
||||
test('local-unavailable exposes both actions and Retry Local', () => {
|
||||
const config = getDesktopRecoveryConfig('local-unavailable');
|
||||
|
||||
expect(config.title).toBe('Local OpenCode Unavailable');
|
||||
expect(config.iconKey).toBe('local');
|
||||
expect(config.showRetry).toBe(true);
|
||||
expect(config.retryLabel).toBe('Retry Local');
|
||||
expect(config.showUseLocal).toBe(true);
|
||||
expect(config.showUseRemote).toBe(true);
|
||||
// local-unavailable uses setup-oriented label since local needs installing
|
||||
expect(config.useLocalLabel).toBe('Set Up Local');
|
||||
expect(config.useRemoteLabel).toBe('Use Remote');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. remote-unreachable: both actions + retry
|
||||
// ---------------------------------------------------------------------------
|
||||
test('remote-unreachable exposes both actions + retry', () => {
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-unreachable',
|
||||
'My Server',
|
||||
'https://example.com:4096',
|
||||
);
|
||||
|
||||
expect(config.title).toBe('Remote Server Unreachable');
|
||||
expect(config.iconKey).toBe('remote');
|
||||
expect(config.showRetry).toBe(true);
|
||||
expect(config.retryLabel).toBe('Retry Connection');
|
||||
expect(config.showUseLocal).toBe(true);
|
||||
expect(config.showUseRemote).toBe(true);
|
||||
// remote variants keep standard "Use Local"
|
||||
expect(config.useLocalLabel).toBe('Use Local');
|
||||
expect(config.useRemoteLabel).toBe('Use Remote');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. remote-wrong-service: both actions, NO retry
|
||||
// ---------------------------------------------------------------------------
|
||||
test('remote-wrong-service exposes both actions and no retry', () => {
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-wrong-service',
|
||||
'Bad Host',
|
||||
'https://wrong.example.com',
|
||||
);
|
||||
|
||||
expect(config.title).toBe('Incompatible Server');
|
||||
expect(config.iconKey).toBe('remote');
|
||||
expect(config.showRetry).toBe(false);
|
||||
expect(config.retryLabel).toBe(undefined);
|
||||
expect(config.showUseLocal).toBe(true);
|
||||
expect(config.showUseRemote).toBe(true);
|
||||
expect(config.useLocalLabel).toBe('Use Local');
|
||||
expect(config.useRemoteLabel).toBe('Use Remote');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. missing-default-host: chooser-with-context (both actions, no retry)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('missing-default-host behaves like chooser-with-context', () => {
|
||||
const config = getDesktopRecoveryConfig('missing-default-host');
|
||||
|
||||
expect(config.title).toBe('No Default Connection');
|
||||
expect(config.iconKey).toBe('local');
|
||||
expect(config.showRetry).toBe(false);
|
||||
expect(config.retryLabel).toBe(undefined);
|
||||
expect(config.showUseLocal).toBe(true);
|
||||
expect(config.showUseRemote).toBe(true);
|
||||
expect(config.useLocalLabel).toBe('Use Local');
|
||||
expect(config.useRemoteLabel).toBe('Use Remote');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. descriptions redact sensitive query params for remote variants
|
||||
// ---------------------------------------------------------------------------
|
||||
test('remote-unreachable description redacts sensitive query params in URL', () => {
|
||||
const sensitiveUrl =
|
||||
'https://example.com:4096?token=super-secret&auth=abc123';
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-unreachable',
|
||||
undefined,
|
||||
sensitiveUrl,
|
||||
);
|
||||
|
||||
// Secrets must never appear in the description
|
||||
expect(config.description).not.toContain('super-secret');
|
||||
expect(config.description).not.toContain('abc123');
|
||||
// Redaction marker is present
|
||||
expect(config.description).toContain('REDACTED');
|
||||
expect(config.description).toContain('example.com');
|
||||
});
|
||||
|
||||
test('remote-wrong-service description redacts sensitive query params in URL', () => {
|
||||
const sensitiveUrl =
|
||||
'https://wrong.example.com?api_key=sk-12345&secret=mysecret';
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-wrong-service',
|
||||
undefined,
|
||||
sensitiveUrl,
|
||||
);
|
||||
|
||||
// Secrets must never appear in the description
|
||||
expect(config.description).not.toContain('sk-12345');
|
||||
expect(config.description).not.toContain('mysecret');
|
||||
// Redaction marker is present
|
||||
expect(config.description).toContain('REDACTED');
|
||||
expect(config.description).toContain('wrong.example.com');
|
||||
});
|
||||
|
||||
test('local-unavailable description does not reference host URL', () => {
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'local-unavailable',
|
||||
'Some Host',
|
||||
'https://example.com?token=secret',
|
||||
);
|
||||
|
||||
// local-unavailable ignores hostUrl in its description
|
||||
expect(config.description).not.toContain('example.com');
|
||||
expect(config.description).not.toContain('secret');
|
||||
});
|
||||
|
||||
test('missing-default-host description does not reference host URL', () => {
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'missing-default-host',
|
||||
'Some Host',
|
||||
'https://example.com?token=secret',
|
||||
);
|
||||
|
||||
expect(config.description).not.toContain('example.com');
|
||||
expect(config.description).not.toContain('secret');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. URL-like hostLabel is also redacted (sensitive data leak prevention)
|
||||
// ---------------------------------------------------------------------------
|
||||
test('remote-unreachable redacts URL-like hostLabel containing sensitive query params', () => {
|
||||
const urlAsLabel =
|
||||
'https://example.com:4096?token=super-secret&auth=abc123';
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-unreachable',
|
||||
urlAsLabel,
|
||||
'https://fallback.example.com',
|
||||
);
|
||||
|
||||
// Secrets in hostLabel must never appear in the description
|
||||
expect(config.description).not.toContain('super-secret');
|
||||
expect(config.description).not.toContain('abc123');
|
||||
// Redaction marker is present
|
||||
expect(config.description).toContain('REDACTED');
|
||||
expect(config.description).toContain('example.com');
|
||||
});
|
||||
|
||||
test('remote-wrong-service redacts URL-like hostLabel containing sensitive query params', () => {
|
||||
const urlAsLabel =
|
||||
'https://wrong.example.com?api_key=sk-12345&secret=mysecret';
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-wrong-service',
|
||||
urlAsLabel,
|
||||
'https://fallback.example.com',
|
||||
);
|
||||
|
||||
// Secrets in hostLabel must never appear in the description
|
||||
expect(config.description).not.toContain('sk-12345');
|
||||
expect(config.description).not.toContain('mysecret');
|
||||
// Redaction marker is present
|
||||
expect(config.description).toContain('REDACTED');
|
||||
expect(config.description).toContain('wrong.example.com');
|
||||
});
|
||||
|
||||
test('remote-unreachable redacts embedded credentials in URL-like hostLabel', () => {
|
||||
const urlWithCreds = 'https://admin:s3cret@example.com:4096';
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-unreachable',
|
||||
urlWithCreds,
|
||||
'https://fallback.example.com',
|
||||
);
|
||||
|
||||
// Username/password must never appear in the description
|
||||
expect(config.description).not.toContain('admin');
|
||||
expect(config.description).not.toContain('s3cret');
|
||||
// Hostname should still be visible
|
||||
expect(config.description).toContain('example.com');
|
||||
});
|
||||
|
||||
test('remote-wrong-service redacts embedded credentials in URL-like hostLabel', () => {
|
||||
const urlWithCreds = 'https://user:pass123@wrong.example.com';
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-wrong-service',
|
||||
urlWithCreds,
|
||||
'https://fallback.example.com',
|
||||
);
|
||||
|
||||
expect(config.description).not.toContain('user');
|
||||
expect(config.description).not.toContain('pass123');
|
||||
expect(config.description).toContain('wrong.example.com');
|
||||
});
|
||||
|
||||
test('non-URL hostLabel is used as-is without redaction', () => {
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-unreachable',
|
||||
'My Server',
|
||||
'https://example.com?token=secret',
|
||||
);
|
||||
|
||||
// Plain label should appear verbatim
|
||||
expect(config.description).toContain('My Server');
|
||||
// hostUrl secrets should not leak (already tested above, but sanity check)
|
||||
expect(config.description).not.toContain('secret');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback descriptions when no host info is provided
|
||||
// ---------------------------------------------------------------------------
|
||||
test('remote-unreachable falls back to generic text when no host info', () => {
|
||||
const config = getDesktopRecoveryConfig('remote-unreachable');
|
||||
|
||||
expect(config.description).toContain('the remote server');
|
||||
expect(config.description).not.toContain('undefined');
|
||||
});
|
||||
|
||||
test('remote-wrong-service falls back to generic text when no host info', () => {
|
||||
const config = getDesktopRecoveryConfig('remote-wrong-service');
|
||||
|
||||
expect(config.description).toContain('unknown');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Whitespace-only hostLabel is treated as absent
|
||||
// ---------------------------------------------------------------------------
|
||||
test('whitespace-only hostLabel falls back to hostUrl', () => {
|
||||
const config = getDesktopRecoveryConfig(
|
||||
'remote-unreachable',
|
||||
' ',
|
||||
'https://fallback.example.com?token=secret',
|
||||
);
|
||||
|
||||
// hostLabel is whitespace-only → should use redacted hostUrl instead
|
||||
expect(config.description).not.toContain('secret');
|
||||
expect(config.description).toContain('fallback.example.com');
|
||||
expect(config.description).toContain('REDACTED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { redactSensitiveUrl } from '@/lib/desktopHosts';
|
||||
|
||||
export type RecoveryVariant =
|
||||
| 'local-unavailable'
|
||||
| 'remote-unreachable'
|
||||
| 'remote-wrong-service'
|
||||
| 'remote-missing'
|
||||
| 'missing-default-host';
|
||||
|
||||
export type DesktopRecoveryConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
iconKey: 'local' | 'remote';
|
||||
showRetry: boolean;
|
||||
retryLabel?: string;
|
||||
showUseLocal: boolean;
|
||||
showUseRemote: boolean;
|
||||
/** Label for the "use local" primary action button */
|
||||
useLocalLabel: string;
|
||||
/** Label for the "use remote" primary action button */
|
||||
useRemoteLabel: string;
|
||||
};
|
||||
|
||||
function formatHostDisplay(hostLabel?: string, hostUrl?: string): string | undefined {
|
||||
if (hostLabel?.trim()) return redactSensitiveUrl(hostLabel.trim());
|
||||
if (hostUrl) return redactSensitiveUrl(hostUrl);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDesktopRecoveryConfig(
|
||||
variant: RecoveryVariant,
|
||||
hostLabel?: string,
|
||||
hostUrl?: string,
|
||||
): DesktopRecoveryConfig {
|
||||
switch (variant) {
|
||||
case 'local-unavailable':
|
||||
return {
|
||||
title: 'Local OpenCode Unavailable',
|
||||
description:
|
||||
'OpenCode CLI could not be started or is not installed. Install OpenCode or connect to a remote server instead.',
|
||||
iconKey: 'local',
|
||||
showRetry: true,
|
||||
retryLabel: 'Retry Local',
|
||||
showUseLocal: true,
|
||||
showUseRemote: true,
|
||||
useLocalLabel: 'Set Up Local',
|
||||
useRemoteLabel: 'Use Remote',
|
||||
};
|
||||
|
||||
case 'remote-missing':
|
||||
return {
|
||||
title: 'No Default Connection',
|
||||
description: 'Your saved default connection could not be found. Choose how you want to connect.',
|
||||
iconKey: 'local',
|
||||
showRetry: false,
|
||||
showUseLocal: true,
|
||||
showUseRemote: true,
|
||||
useLocalLabel: 'Use Local',
|
||||
useRemoteLabel: 'Use Remote',
|
||||
};
|
||||
|
||||
case 'remote-unreachable': {
|
||||
const host = formatHostDisplay(hostLabel, hostUrl);
|
||||
return {
|
||||
title: 'Remote Server Unreachable',
|
||||
description: `Could not connect to "${host || 'the remote server'}". Check your network connection and verify the server address.`,
|
||||
iconKey: 'remote',
|
||||
showRetry: true,
|
||||
retryLabel: 'Retry Connection',
|
||||
showUseLocal: true,
|
||||
showUseRemote: true,
|
||||
useLocalLabel: 'Use Local',
|
||||
useRemoteLabel: 'Use Remote',
|
||||
};
|
||||
}
|
||||
|
||||
case 'remote-wrong-service': {
|
||||
const host = formatHostDisplay(hostLabel, hostUrl);
|
||||
return {
|
||||
title: 'Incompatible Server',
|
||||
description: `The server at "${host || 'unknown'}" is not running OpenChamber. Verify the address points to an OpenChamber server.`,
|
||||
iconKey: 'remote',
|
||||
showRetry: false,
|
||||
showUseLocal: true,
|
||||
showUseRemote: true,
|
||||
useLocalLabel: 'Use Local',
|
||||
useRemoteLabel: 'Use Remote',
|
||||
};
|
||||
}
|
||||
|
||||
case 'missing-default-host':
|
||||
return {
|
||||
title: 'No Default Connection',
|
||||
description: 'Your saved default connection could not be found. Choose how you want to connect.',
|
||||
iconKey: 'local',
|
||||
showRetry: false,
|
||||
showUseLocal: true,
|
||||
showUseRemote: true,
|
||||
useLocalLabel: 'Use Local',
|
||||
useRemoteLabel: 'Use Remote',
|
||||
};
|
||||
|
||||
default: {
|
||||
// TypeScript exhaustive check - this should never be reached
|
||||
const exhaustive: never = variant;
|
||||
throw new Error(`Unknown recovery variant: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
|
||||
import type { RecoveryPrimaryAction, RecoveryNextStep } from './desktopRecoveryRouting';
|
||||
import type { RecoveryVariant } from './desktopRecoveryConfig';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compile-time completeness: this Record must list every RecoveryVariant key
|
||||
// and every RecoveryPrimaryAction key. Adding a new variant/action to the
|
||||
// union without updating this table will cause a type error.
|
||||
// ---------------------------------------------------------------------------
|
||||
const EXPECTED_ROUTING: Record<RecoveryVariant, Record<RecoveryPrimaryAction, RecoveryNextStep['kind']>> = {
|
||||
'local-unavailable': {
|
||||
'use-local': 'local-setup',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
'remote-unreachable': {
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
'remote-wrong-service': {
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
'remote-missing': {
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
'missing-default-host': {
|
||||
'use-local': 'switch-default-to-local',
|
||||
'use-remote': 'remote-form',
|
||||
},
|
||||
};
|
||||
|
||||
describe('resolveRecoveryNextStep', () => {
|
||||
for (const [variant, actions] of Object.entries(EXPECTED_ROUTING) as [
|
||||
RecoveryVariant,
|
||||
Record<RecoveryPrimaryAction, RecoveryNextStep['kind']>,
|
||||
][]) {
|
||||
for (const [action, expectedKind] of Object.entries(actions) as [
|
||||
RecoveryPrimaryAction,
|
||||
RecoveryNextStep['kind'],
|
||||
][]) {
|
||||
test(`${variant} + ${action} -> ${expectedKind}`, () => {
|
||||
const result = resolveRecoveryNextStep(variant, action);
|
||||
expect(result).toEqual({ kind: expectedKind });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RecoveryVariant } from './desktopRecoveryConfig';
|
||||
|
||||
export type RecoveryPrimaryAction = 'use-local' | 'use-remote';
|
||||
|
||||
export type RecoveryNextStep =
|
||||
| { kind: 'local-setup' }
|
||||
| { kind: 'switch-default-to-local' }
|
||||
| { kind: 'remote-form' };
|
||||
|
||||
export function resolveRecoveryNextStep(
|
||||
variant: RecoveryVariant,
|
||||
action: RecoveryPrimaryAction,
|
||||
): RecoveryNextStep {
|
||||
if (action === 'use-remote') {
|
||||
return { kind: 'remote-form' };
|
||||
}
|
||||
|
||||
// action === 'use-local'
|
||||
switch (variant) {
|
||||
case 'local-unavailable':
|
||||
return { kind: 'local-setup' };
|
||||
case 'remote-unreachable':
|
||||
case 'remote-wrong-service':
|
||||
case 'remote-missing':
|
||||
case 'missing-default-host':
|
||||
return { kind: 'switch-default-to-local' };
|
||||
default: {
|
||||
const exhaustive: never = variant;
|
||||
throw new Error(`Unhandled RecoveryVariant: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user