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
+163
-116
@@ -21,8 +21,18 @@ import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell } from '@/lib/desktop';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop';
|
||||
import {
|
||||
getInjectedBootOutcome,
|
||||
getBootInjectionStatus,
|
||||
resolveDesktopBootView,
|
||||
canDismissInitialLoading,
|
||||
shouldRestartDesktopBootFlow,
|
||||
type BootInjectionStatus,
|
||||
type DesktopBootView,
|
||||
} from '@/lib/desktopBoot';
|
||||
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
||||
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
@@ -42,9 +52,6 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
|
||||
const CLI_MISSING_ERROR_REGEX =
|
||||
/ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i;
|
||||
|
||||
const AboutDialogWrapper: React.FC = () => {
|
||||
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
|
||||
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
||||
@@ -168,14 +175,21 @@ function App({ apis }: AppProps) {
|
||||
const { uiFont, monoFont } = useFontPreferences();
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
||||
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
|
||||
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
||||
const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
|
||||
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
|
||||
const [bootInjectionStatus, setBootInjectionStatus] = React.useState<BootInjectionStatus>(() => {
|
||||
return getBootInjectionStatus();
|
||||
});
|
||||
const [bootView, setBootView] = React.useState<DesktopBootView | null>(() => {
|
||||
const outcome = getInjectedBootOutcome();
|
||||
return outcome !== null
|
||||
? resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome })
|
||||
: null;
|
||||
});
|
||||
const appReadyDispatchedRef = React.useRef(false);
|
||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||
const recentDesktopNotificationTagsRef = React.useRef<Map<string, number>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
setStreamPerfEnabled(showMemoryDebug);
|
||||
@@ -221,25 +235,55 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
}, [uiFont, monoFont]);
|
||||
|
||||
const bootOutcomeKnown = bootInjectionStatus === 'valid';
|
||||
const bootViewIsMain = bootView?.screen === 'main';
|
||||
|
||||
// Splash dismissal: use the authoritative loading gate from desktopBoot.
|
||||
// Desktop shells strictly require a valid boot outcome before dismissing.
|
||||
// Non-main outcomes (chooser/recovery) can dismiss without waiting for init.
|
||||
React.useEffect(() => {
|
||||
if (isInitialized) {
|
||||
const hideInitialLoading = () => {
|
||||
const loadingElement = document.getElementById('initial-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.classList.add('fade-out');
|
||||
|
||||
setTimeout(() => {
|
||||
loadingElement.remove();
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(hideInitialLoading, 150);
|
||||
return () => clearTimeout(timer);
|
||||
if (!canDismissInitialLoading({
|
||||
isDesktopShell: isDesktopRuntime,
|
||||
isInitialized,
|
||||
bootOutcomeKnown,
|
||||
bootViewIsMain,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
}, [isInitialized]);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const loadingElement = document.getElementById('initial-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.classList.add('fade-out');
|
||||
setTimeout(() => {
|
||||
loadingElement.remove();
|
||||
}, 300);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isDesktopRuntime, isInitialized, bootOutcomeKnown, bootViewIsMain]);
|
||||
|
||||
// Deterministic malformed handling: update splash text so the user
|
||||
// sees a specific error instead of a generic spinner, but do NOT
|
||||
// dismiss the splash (that only happens on a valid outcome).
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopRuntime || bootInjectionStatus !== 'malformed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingElement = document.getElementById('initial-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.textContent = 'Desktop startup failed — please restart the app.';
|
||||
}
|
||||
}, [isDesktopRuntime, bootInjectionStatus]);
|
||||
|
||||
// Non-desktop fallback: remove splash after 5 seconds even if init stalls.
|
||||
React.useEffect(() => {
|
||||
if (isDesktopRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
const loadingElement = document.getElementById('initial-loading');
|
||||
if (loadingElement && !isInitialized) {
|
||||
@@ -251,7 +295,7 @@ function App({ apis }: AppProps) {
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(fallbackTimer);
|
||||
}, [isInitialized]);
|
||||
}, [isDesktopRuntime, isInitialized]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -372,68 +416,6 @@ function App({ apis }: AppProps) {
|
||||
};
|
||||
}, [embeddedSessionChat]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat || !isDesktopRuntime || typeof window === 'undefined' || typeof EventSource === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new EventSource('/api/notifications/stream');
|
||||
|
||||
const handleMessage = (event: MessageEvent<string>) => {
|
||||
type DesktopNotificationEvent = {
|
||||
type?: string;
|
||||
properties?: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tag?: string;
|
||||
desktopStdoutActive?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
let payload: DesktopNotificationEvent;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(event.data) as DesktopNotificationEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload?.type !== 'openchamber:notification') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.properties?.desktopStdoutActive === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tag = typeof payload.properties?.tag === 'string' ? payload.properties.tag : '';
|
||||
if (tag) {
|
||||
const now = Date.now();
|
||||
const lastSeenAt = recentDesktopNotificationTagsRef.current.get(tag) ?? 0;
|
||||
if (now - lastSeenAt < 5000) {
|
||||
return;
|
||||
}
|
||||
recentDesktopNotificationTagsRef.current.set(tag, now);
|
||||
}
|
||||
|
||||
void apis.notifications.notifyAgentCompletion({
|
||||
title: payload.properties?.title,
|
||||
body: payload.properties?.body,
|
||||
tag: tag || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
source.addEventListener('message', handleMessage as EventListener);
|
||||
source.onerror = () => {
|
||||
// Let EventSource reconnect automatically.
|
||||
};
|
||||
|
||||
return () => {
|
||||
source.removeEventListener('message', handleMessage as EventListener);
|
||||
source.close();
|
||||
};
|
||||
}, [apis.notifications, embeddedSessionChat, isDesktopRuntime]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
|
||||
return;
|
||||
@@ -529,54 +511,119 @@ function App({ apis }: AppProps) {
|
||||
}
|
||||
}, [clearError, embeddedSessionChat, error]);
|
||||
|
||||
// Poll for the injected boot outcome until it becomes available (desktop only).
|
||||
// The Rust backend sets window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ once the
|
||||
// sidecar reaches a stable state. We poll with exponential backoff to handle
|
||||
// potential race conditions during startup and config writes.
|
||||
React.useEffect(() => {
|
||||
if (embeddedSessionChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
|
||||
if (!isDesktopRuntime || bootInjectionStatus !== 'not-injected') {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | {
|
||||
openCodeRunning?: unknown;
|
||||
isOpenCodeReady?: unknown;
|
||||
opencodeBinaryResolved?: unknown;
|
||||
lastOpenCodeError?: unknown;
|
||||
};
|
||||
if (!data || cancelled) return;
|
||||
const openCodeRunning = data.openCodeRunning === true;
|
||||
const isOpenCodeReady = data.isOpenCodeReady === true;
|
||||
const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : '';
|
||||
const hasResolvedBinary = resolvedBinary.length > 0;
|
||||
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
|
||||
const cliMissing =
|
||||
!openCodeRunning &&
|
||||
(CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady));
|
||||
setShowCliOnboarding(cliMissing);
|
||||
let attempts = 0;
|
||||
const BASE_INTERVAL = 200;
|
||||
const MAX_INTERVAL = 2000;
|
||||
const MAX_ATTEMPTS = 50; // 10 seconds total (200ms * 50 with exponential backoff cap)
|
||||
|
||||
const pollWithBackoff = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
attempts++;
|
||||
const status = getBootInjectionStatus();
|
||||
|
||||
if (status !== 'not-injected') {
|
||||
cancelled = true;
|
||||
setBootInjectionStatus(status);
|
||||
|
||||
if (status === 'valid') {
|
||||
const outcome = getInjectedBootOutcome();
|
||||
if (outcome) {
|
||||
setBootView(resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome }));
|
||||
}
|
||||
}
|
||||
// If status is 'malformed', we keep the splash visible with error text
|
||||
// handled by the separate useEffect below
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff with cap
|
||||
const nextInterval = Math.min(BASE_INTERVAL * Math.pow(1.1, attempts), MAX_INTERVAL);
|
||||
|
||||
if (attempts >= MAX_ATTEMPTS) {
|
||||
// Max attempts reached - keep polling but show error
|
||||
const loadingElement = document.getElementById('initial-loading');
|
||||
if (loadingElement && !loadingElement.textContent?.includes('taking longer')) {
|
||||
loadingElement.textContent = 'Desktop startup is taking longer than expected...';
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(pollWithBackoff, nextInterval);
|
||||
};
|
||||
|
||||
void run();
|
||||
// Start polling
|
||||
window.setTimeout(pollWithBackoff, BASE_INTERVAL);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [embeddedSessionChat]);
|
||||
}, [isDesktopRuntime, bootInjectionStatus]);
|
||||
|
||||
const handleDesktopBootDismiss = React.useCallback(async () => {
|
||||
if (shouldRestartDesktopBootFlow({
|
||||
isTauriShell: isTauriShell(),
|
||||
isDesktopLocalOriginActive: isDesktopLocalOriginActive(),
|
||||
})) {
|
||||
await restartDesktopApp();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleCliAvailable = React.useCallback(() => {
|
||||
setShowCliOnboarding(false);
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
if (showCliOnboarding) {
|
||||
// Map boot outcome kind to recovery variant
|
||||
const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => {
|
||||
if (view.screen === 'recovery') {
|
||||
return view.variant;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Desktop boot view routing.
|
||||
// When the boot outcome resolves to a non-main screen (chooser, recovery),
|
||||
// render OnboardingScreen with appropriate mode/variant.
|
||||
if (isDesktopRuntime && bootView && bootView.screen !== 'main') {
|
||||
// First-launch chooser
|
||||
if (bootView.screen === 'chooser') {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<OnboardingScreen
|
||||
mode="first-launch"
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
onChooseRemote={() => {
|
||||
// Switch to remote tab - handled internally by OnboardingScreen
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Recovery screens
|
||||
const recoveryVariant = mapBootViewToRecoveryVariant(bootView);
|
||||
const hostUrl = bootView.screen === 'recovery' && 'url' in bootView ? bootView.url : undefined;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<OnboardingScreen onCliAvailable={handleCliAvailable} />
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<OnboardingScreen
|
||||
mode="recovery"
|
||||
recoveryVariant={recoveryVariant}
|
||||
recoveryHostUrl={hostUrl}
|
||||
recoveryHostLabel={undefined}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
@@ -652,7 +699,7 @@ function App({ apis }: AppProps) {
|
||||
<FireworksProvider>
|
||||
<VoiceProvider>
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||
<MainLayout />
|
||||
<Toaster />
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,12 +458,20 @@ export const restartToApplyUpdate = async (): Promise<boolean> => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return restartDesktopApp();
|
||||
};
|
||||
|
||||
export const restartDesktopApp = async (): Promise<boolean> => {
|
||||
if (!isTauriShell()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
await tauri?.core?.invoke?.('desktop_restart');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to restart for update (tauri)', error);
|
||||
console.warn('Failed to restart desktop app (tauri)', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
resolveDesktopBootView,
|
||||
canDismissInitialLoading,
|
||||
getInjectedBootOutcome,
|
||||
getBootInjectionStatus,
|
||||
shouldRestartDesktopBootFlow,
|
||||
} from './desktopBoot';
|
||||
|
||||
describe('resolveDesktopBootView', () => {
|
||||
test('returns chooser for first launch (not-configured)', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: null, status: 'not-configured' },
|
||||
}),
|
||||
).toEqual({ screen: 'chooser' });
|
||||
});
|
||||
|
||||
test('returns recovery view for broken saved remote', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: {
|
||||
target: 'remote',
|
||||
status: 'unreachable',
|
||||
hostId: 'remote-a',
|
||||
url: 'https://x.test',
|
||||
},
|
||||
}),
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-unreachable', hostId: 'remote-a', url: 'https://x.test' });
|
||||
});
|
||||
|
||||
test('returns main for local ok', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: 'local', status: 'ok' },
|
||||
}),
|
||||
).toEqual({ screen: 'main' });
|
||||
});
|
||||
|
||||
test('returns main with hostId for remote ok', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: 'remote', status: 'ok', hostId: 'remote-1', url: 'https://example.com' },
|
||||
}),
|
||||
).toEqual({ screen: 'main', hostId: 'remote-1', url: 'https://example.com' });
|
||||
});
|
||||
|
||||
test('returns recovery-remote for remote wrong-service', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: {
|
||||
target: 'remote',
|
||||
status: 'wrong-service',
|
||||
hostId: 'bad-host',
|
||||
url: 'https://bad.test',
|
||||
},
|
||||
}),
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-wrong-service', hostId: 'bad-host', url: 'https://bad.test' });
|
||||
});
|
||||
|
||||
test('returns recovery view for local unreachable', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: 'local', status: 'unreachable' },
|
||||
}),
|
||||
).toEqual({ screen: 'recovery', variant: 'local-unreachable' });
|
||||
});
|
||||
|
||||
test('returns recovery view for remote missing', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: { target: 'remote', status: 'missing', hostId: 'gone-1' },
|
||||
}),
|
||||
).toEqual({ screen: 'recovery', variant: 'remote-missing', hostId: 'gone-1' });
|
||||
});
|
||||
|
||||
test('returns null for non-desktop shell', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: false,
|
||||
bootOutcome: { target: 'local', status: 'ok' },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when no boot outcome and desktop shell', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
bootOutcome: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDismissInitialLoading', () => {
|
||||
test('does not dismiss desktop loading before boot outcome is known', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: true,
|
||||
bootOutcomeKnown: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('dismisses desktop when main outcome is known and initialized', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: true,
|
||||
bootOutcomeKnown: true,
|
||||
bootViewIsMain: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('does not dismiss desktop when main outcome is known but not initialized', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: false,
|
||||
bootOutcomeKnown: true,
|
||||
bootViewIsMain: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('dismisses desktop for non-main outcome without waiting for init', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: false,
|
||||
bootOutcomeKnown: true,
|
||||
bootViewIsMain: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('does not dismiss desktop for non-main outcome when outcome is not known', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: true,
|
||||
bootOutcomeKnown: false,
|
||||
bootViewIsMain: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('dismisses non-desktop when initialized', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: false,
|
||||
isInitialized: true,
|
||||
bootOutcomeKnown: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('does not dismiss non-desktop when not initialized', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: false,
|
||||
isInitialized: false,
|
||||
bootOutcomeKnown: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRestartDesktopBootFlow', () => {
|
||||
test('restarts the desktop app when boot UI is running in the startup window', () => {
|
||||
expect(
|
||||
shouldRestartDesktopBootFlow({
|
||||
isTauriShell: true,
|
||||
isDesktopLocalOriginActive: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('does not restart when the local desktop origin is already active', () => {
|
||||
expect(
|
||||
shouldRestartDesktopBootFlow({
|
||||
isTauriShell: true,
|
||||
isDesktopLocalOriginActive: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('does not restart outside the tauri shell', () => {
|
||||
expect(
|
||||
shouldRestartDesktopBootFlow({
|
||||
isTauriShell: false,
|
||||
isDesktopLocalOriginActive: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInjectedBootOutcome', () => {
|
||||
// Bun test runner does not provide `window`. Mock it for these tests.
|
||||
const mockWindow = () => {
|
||||
const w: Record<string, unknown> = {};
|
||||
(globalThis as Record<string, unknown>).window = w;
|
||||
return w;
|
||||
};
|
||||
const restoreWindow = () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
};
|
||||
|
||||
test('returns null when window global is undefined', () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
try {
|
||||
expect(getInjectedBootOutcome()).toBeNull();
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for malformed payload with unknown kind', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'unknown-kind' };
|
||||
try {
|
||||
expect(getInjectedBootOutcome()).toBeNull();
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for payload missing required hostId', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-remote', url: 'https://x.test' };
|
||||
try {
|
||||
expect(getInjectedBootOutcome()).toBeNull();
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for non-object payload', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = 'not-an-object';
|
||||
try {
|
||||
expect(getInjectedBootOutcome()).toBeNull();
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns valid outcome for well-formed main-local', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' };
|
||||
try {
|
||||
expect(getInjectedBootOutcome()).toEqual({ kind: 'main-local' });
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for payload with numeric kind', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 42 };
|
||||
try {
|
||||
expect(getInjectedBootOutcome()).toBeNull();
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDesktopBootView validation', () => {
|
||||
test('returns null for unknown kind via default branch', () => {
|
||||
expect(
|
||||
resolveDesktopBootView({
|
||||
isDesktopShell: true,
|
||||
// @ts-expect-error — testing unknown kind
|
||||
bootOutcome: { kind: 'totally-unknown' },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBootInjectionStatus', () => {
|
||||
const mockWindow = () => {
|
||||
const w: Record<string, unknown> = {};
|
||||
(globalThis as Record<string, unknown>).window = w;
|
||||
return w;
|
||||
};
|
||||
const restoreWindow = () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
};
|
||||
|
||||
test('returns "not-injected" when window is undefined', () => {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
try {
|
||||
expect(getBootInjectionStatus()).toBe('not-injected');
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "not-injected" when global is absent', () => {
|
||||
mockWindow();
|
||||
// Do not set the global — it should be absent.
|
||||
try {
|
||||
expect(getBootInjectionStatus()).toBe('not-injected');
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "not-injected" when global is explicitly null', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = null;
|
||||
try {
|
||||
expect(getBootInjectionStatus()).toBe('not-injected');
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "malformed" when global is present but invalid', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'bad' };
|
||||
try {
|
||||
expect(getBootInjectionStatus()).toBe('malformed');
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "valid" when global is present and well-formed', () => {
|
||||
const w = mockWindow();
|
||||
w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' };
|
||||
try {
|
||||
expect(getBootInjectionStatus()).toBe('valid');
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDismissInitialLoading with malformed injection', () => {
|
||||
test('does NOT dismiss desktop splash when injection is malformed', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: true,
|
||||
bootOutcomeKnown: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('dismisses desktop main outcome when valid and initialized', () => {
|
||||
expect(
|
||||
canDismissInitialLoading({
|
||||
isDesktopShell: true,
|
||||
isInitialized: true,
|
||||
bootOutcomeKnown: true,
|
||||
bootViewIsMain: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Authoritative desktop boot outcome types and UI-facing resolver.
|
||||
*
|
||||
* The Rust backend computes a `DesktopBootOutcome` at startup and injects
|
||||
* it as `window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__`. This module provides
|
||||
* pure functions to read that outcome and derive the minimal UI state
|
||||
* needed for the loading/chooser/recovery/main decision.
|
||||
*/
|
||||
|
||||
// ── Boot outcome (must match Rust injection) ──
|
||||
|
||||
/**
|
||||
* Structured boot outcome type.
|
||||
*
|
||||
* Instead of 8 magic string kinds, we use a structured type that clearly
|
||||
* separates the target (local/remote/null) from the status (ok/not-configured/error).
|
||||
*
|
||||
* This makes it easier to add new states without updating multiple files and
|
||||
* allows UI to reason about outcomes with simple status checks.
|
||||
*/
|
||||
export type DesktopBootOutcome =
|
||||
// Main screens - CLI or remote connection is working
|
||||
| { target: 'local'; status: 'ok' }
|
||||
| { target: 'remote'; status: 'ok'; hostId: string; url: string }
|
||||
|
||||
// First launch - user hasn't made a choice yet
|
||||
| { target: null; status: 'not-configured' }
|
||||
|
||||
// Recovery screens - something is wrong
|
||||
| { target: 'local'; status: 'unreachable' }
|
||||
| { target: 'remote'; status: 'unreachable'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'wrong-service'; hostId: string; url: string }
|
||||
| { target: 'remote'; status: 'missing'; hostId: string };
|
||||
|
||||
// ── UI-facing view ──
|
||||
|
||||
export type DesktopBootView =
|
||||
| { screen: 'main' }
|
||||
| { screen: 'main'; hostId: string; url: string }
|
||||
| { screen: 'chooser' }
|
||||
| { screen: 'recovery'; variant: 'local-unavailable' }
|
||||
| { screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string }
|
||||
| { screen: 'recovery'; variant: 'remote-missing'; hostId: string };
|
||||
|
||||
// ── Resolver inputs ──
|
||||
|
||||
export type DesktopBootViewInput = {
|
||||
isDesktopShell: boolean;
|
||||
bootOutcome: DesktopBootOutcome | null;
|
||||
};
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
/** Valid target values */
|
||||
const VALID_TARGETS = ['local', 'remote', null] as const;
|
||||
|
||||
/** Valid status values */
|
||||
const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'wrong-service', 'missing'] as const;
|
||||
|
||||
/** Return type for `validateBootOutcome`. */
|
||||
type ValidationResult =
|
||||
| { valid: true; outcome: DesktopBootOutcome }
|
||||
| { valid: false };
|
||||
|
||||
/**
|
||||
* Runtime-validate a raw injected payload.
|
||||
* Returns a tagged result so callers can distinguish "not set yet" (null raw)
|
||||
* from "set but malformed" (valid: false).
|
||||
*/
|
||||
function validateBootOutcome(raw: unknown): ValidationResult {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const target = record.target;
|
||||
const status = record.status;
|
||||
|
||||
// Validate target
|
||||
if (target !== null && (typeof target !== 'string' || !VALID_TARGETS.includes(target as never))) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Validate status
|
||||
if (typeof status !== 'string' || !VALID_STATUSES.includes(status as never)) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
// Validate required fields per combination
|
||||
if (target === 'remote' || target === 'local') {
|
||||
if (status === 'ok' && target === 'local') {
|
||||
// { target: 'local'; status: 'ok' } is valid
|
||||
return { valid: true, outcome: { target: 'local', status: 'ok' } };
|
||||
}
|
||||
|
||||
if (status === 'ok' && target === 'remote') {
|
||||
// { target: 'remote'; status: 'ok' } requires hostId and url
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url } };
|
||||
}
|
||||
|
||||
if (status === 'unreachable') {
|
||||
if (target === 'local') {
|
||||
// { target: 'local'; status: 'unreachable' } is valid
|
||||
return { valid: true, outcome: { target: 'local', status: 'unreachable' } };
|
||||
} else {
|
||||
// { target: 'remote'; status: 'unreachable' } requires hostId and url
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url } };
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'wrong-service') {
|
||||
if (target !== 'remote') return { valid: false };
|
||||
if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'wrong-service', hostId: record.hostId, url: record.url } };
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
if (target !== 'remote') return { valid: false };
|
||||
if (typeof record.hostId !== 'string') {
|
||||
return { valid: false };
|
||||
}
|
||||
return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId } };
|
||||
}
|
||||
}
|
||||
|
||||
if (target === null) {
|
||||
if (status === 'not-configured') {
|
||||
// { target: null; status: 'not-configured' } is valid (first launch)
|
||||
return { valid: true, outcome: { target: null, status: 'not-configured' } };
|
||||
}
|
||||
|
||||
if (status === 'missing') {
|
||||
// { target: null; status: 'missing' } would be redundant with not-configured
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the minimal UI view from the injected boot outcome.
|
||||
*
|
||||
* Returns `null` when not in desktop shell, when the outcome is not yet
|
||||
* known, or when the injected payload is malformed.
|
||||
*/
|
||||
export function resolveDesktopBootView(
|
||||
input: DesktopBootViewInput,
|
||||
): DesktopBootView | null {
|
||||
if (!input.isDesktopShell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outcome = input.bootOutcome;
|
||||
if (!outcome) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Main screens - CLI or remote connection is working
|
||||
if (outcome.status === 'ok') {
|
||||
if (outcome.target === 'local') {
|
||||
return { screen: 'main' };
|
||||
} else if (outcome.target === 'remote') {
|
||||
return { screen: 'main', hostId: outcome.hostId, url: outcome.url };
|
||||
}
|
||||
}
|
||||
|
||||
// First launch - user hasn't made a choice yet
|
||||
if (outcome.target === null && outcome.status === 'not-configured') {
|
||||
return { screen: 'chooser' };
|
||||
}
|
||||
|
||||
// Recovery screens - something is wrong
|
||||
if (outcome.target === 'local' && outcome.status === 'unreachable') {
|
||||
return { screen: 'recovery', variant: 'local-unavailable' };
|
||||
}
|
||||
|
||||
if (outcome.target === 'remote') {
|
||||
if (outcome.status === 'unreachable') {
|
||||
return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url };
|
||||
} else if (outcome.status === 'wrong-service') {
|
||||
return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url };
|
||||
} else if (outcome.status === 'missing') {
|
||||
return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId };
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown outcome — defensive null.
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Loading gate ──
|
||||
|
||||
export type BootInjectionStatus =
|
||||
| 'not-injected'
|
||||
| 'malformed'
|
||||
| 'valid';
|
||||
|
||||
export type InitialLoadingState = {
|
||||
isDesktopShell: boolean;
|
||||
isInitialized: boolean;
|
||||
bootOutcomeKnown: boolean;
|
||||
/**
|
||||
* Whether the resolved boot view is 'main'.
|
||||
* When false (chooser/recovery), splash dismisses on bootOutcomeKnown alone.
|
||||
* When true or absent, splash also requires isInitialized.
|
||||
*/
|
||||
bootViewIsMain?: boolean;
|
||||
};
|
||||
|
||||
export type DesktopBootFlowRestartInput = {
|
||||
isTauriShell: boolean;
|
||||
isDesktopLocalOriginActive: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the initial loading screen can be dismissed.
|
||||
*
|
||||
* Desktop shells must wait until a valid boot outcome is injected by Rust.
|
||||
* For non-main views (chooser, recovery), the splash can dismiss as soon as
|
||||
* the outcome is known — `isInitialized` is not required because OpenCode
|
||||
* may not be available in those flows.
|
||||
* For main views, both `isInitialized` and `bootOutcomeKnown` are required.
|
||||
* Non-desktop shells only need the app to be initialized.
|
||||
*/
|
||||
export function canDismissInitialLoading(state: InitialLoadingState): boolean {
|
||||
if (!state.isDesktopShell) {
|
||||
return state.isInitialized;
|
||||
}
|
||||
|
||||
if (!state.bootOutcomeKnown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-main boot views (chooser, recovery) can dismiss without waiting for init.
|
||||
if (state.bootViewIsMain === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return state.isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot/recovery UI can render in the Tauri startup window before the local
|
||||
* desktop HTTP origin is active. In that state, same-origin reloads and
|
||||
* `/api/*` requests cannot recover the app, so callers must restart Tauri.
|
||||
*/
|
||||
export function shouldRestartDesktopBootFlow(input: DesktopBootFlowRestartInput): boolean {
|
||||
return input.isTauriShell && !input.isDesktopLocalOriginActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the boot outcome injected by the Rust backend.
|
||||
* Returns `null` when not in desktop, when the outcome has not been set yet,
|
||||
* or when the injected payload is malformed.
|
||||
*/
|
||||
export function getInjectedBootOutcome(): DesktopBootOutcome | null {
|
||||
const status = getBootInjectionStatus();
|
||||
if (status !== 'valid') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = (window as { __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: unknown })
|
||||
.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__;
|
||||
|
||||
const result = validateBootOutcome(raw);
|
||||
return result.valid ? result.outcome : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the injection status of the desktop boot outcome.
|
||||
*
|
||||
* Distinguishes three states:
|
||||
* - `'not-injected'`: the global is absent or null (keep waiting)
|
||||
* - `'malformed'`: the global is present but failed validation (deterministic failure)
|
||||
* - `'valid'`: the global is present and passes validation
|
||||
*/
|
||||
export function getBootInjectionStatus(): BootInjectionStatus {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'not-injected';
|
||||
}
|
||||
|
||||
const raw = (window as { __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: unknown })
|
||||
.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__;
|
||||
|
||||
if (raw === undefined || raw === null) {
|
||||
return 'not-injected';
|
||||
}
|
||||
|
||||
const result = validateBootOutcome(raw);
|
||||
return result.valid ? 'valid' : 'malformed';
|
||||
}
|
||||
@@ -17,10 +17,18 @@ export type DesktopHost = {
|
||||
export type DesktopHostsConfig = {
|
||||
hosts: DesktopHost[];
|
||||
defaultHostId: string | null;
|
||||
initialHostChoiceCompleted: boolean;
|
||||
};
|
||||
|
||||
/** Backward-compatible input type — callers may omit `initialHostChoiceCompleted`. */
|
||||
export type DesktopHostsConfigInput = {
|
||||
hosts: DesktopHost[];
|
||||
defaultHostId: string | null;
|
||||
initialHostChoiceCompleted?: boolean;
|
||||
};
|
||||
|
||||
export type HostProbeResult = {
|
||||
status: 'ok' | 'auth' | 'unreachable';
|
||||
status: 'ok' | 'auth' | 'wrong-service' | 'unreachable';
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
@@ -48,6 +56,12 @@ export const redactSensitiveUrl = (raw: string): string => {
|
||||
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
// Redact embedded credentials (userinfo) to prevent leaking user:pass
|
||||
if (url.username || url.password) {
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
}
|
||||
|
||||
const keys = Array.from(new Set(Array.from(url.searchParams.keys())));
|
||||
for (const key of keys) {
|
||||
if (SENSITIVE_QUERY_KEY.test(key)) {
|
||||
@@ -121,12 +135,12 @@ const getInvoke = (): TauriInvoke | null => {
|
||||
export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
return { hosts: [], defaultHostId: 'local' };
|
||||
return { hosts: [], defaultHostId: 'local', initialHostChoiceCompleted: false };
|
||||
}
|
||||
|
||||
const raw = await invoke('desktop_hosts_get');
|
||||
if (!isRecord(raw)) {
|
||||
return { hosts: [], defaultHostId: null };
|
||||
return { hosts: [], defaultHostId: null, initialHostChoiceCompleted: false };
|
||||
}
|
||||
|
||||
const hostsRaw = raw.hosts;
|
||||
@@ -139,16 +153,20 @@ export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
|
||||
readString(raw, 'default_host_id') ||
|
||||
readString(raw, 'defaultHostID');
|
||||
|
||||
return { hosts, defaultHostId };
|
||||
const initialHostChoiceCompleted =
|
||||
raw.initialHostChoiceCompleted === true || raw.initial_host_choice_completed === true;
|
||||
|
||||
return { hosts, defaultHostId, initialHostChoiceCompleted };
|
||||
};
|
||||
|
||||
export const desktopHostsSet = async (config: DesktopHostsConfig): Promise<void> => {
|
||||
export const desktopHostsSet = async (config: DesktopHostsConfigInput): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_hosts_set', {
|
||||
config: {
|
||||
input: {
|
||||
hosts: config.hosts,
|
||||
defaultHostId: config.defaultHostId,
|
||||
initialHostChoiceCompleted: config.initialHostChoiceCompleted,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -166,7 +184,7 @@ export const desktopHostProbe = async (url: string): Promise<HostProbeResult> =>
|
||||
|
||||
const rawStatus = raw.status;
|
||||
const status: HostProbeResult['status'] =
|
||||
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'unreachable'
|
||||
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'wrong-service' || rawStatus === 'unreachable'
|
||||
? rawStatus
|
||||
: 'unreachable';
|
||||
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
// Minimal type declarations for bun:test to satisfy tsc.
|
||||
// Only the subset used by our test files is declared.
|
||||
|
||||
declare module "bun:test" {
|
||||
export function describe(name: string, fn: () => void): void;
|
||||
export function test(name: string, fn: () => void | Promise<void>): void;
|
||||
export function expect(value: unknown): {
|
||||
toEqual(expected: unknown): void;
|
||||
toBe(expected: unknown): void;
|
||||
toBeTruthy(): void;
|
||||
toBeFalsy(): void;
|
||||
toBeNull(): void;
|
||||
toThrow(expected?: string | RegExp): void;
|
||||
toContain(expected: unknown): void;
|
||||
toBeGreaterThan(expected: number): void;
|
||||
toBeLessThan(expected: number): void;
|
||||
toHaveLength(expected: number): void;
|
||||
not: {
|
||||
toEqual(expected: unknown): void;
|
||||
toBe(expected: unknown): void;
|
||||
toContain(expected: unknown): void;
|
||||
};
|
||||
};
|
||||
}
|
||||
Vendored
+3
@@ -1,8 +1,11 @@
|
||||
import type { DesktopBootOutcome } from '@/lib/desktopBoot';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCHAMBER_HOME__?: string;
|
||||
__OPENCHAMBER_MACOS_MAJOR__?: number;
|
||||
__OPENCHAMBER_LOCAL_ORIGIN__?: string;
|
||||
__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user