From 9b169aaacf2d224d8907a851f6d66009ab615026 Mon Sep 17 00:00:00 2001 From: jwcrystal <121911854+jwcrystal@users.noreply.github.com> Date: Wed, 15 Apr 2026 01:32:59 +0800 Subject: [PATCH] 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 --- packages/desktop/src-tauri/Cargo.lock | 12 + packages/desktop/src-tauri/Cargo.toml | 4 +- packages/desktop/src-tauri/src/main.rs | 1146 +++++++++++++++-- packages/ui/src/App.tsx | 279 ++-- .../desktop/DesktopHostSwitcher.tsx | 13 +- .../components/onboarding/ChooserScreen.tsx | 420 ++++++ .../onboarding/DesktopConnectionRecovery.tsx | 118 ++ .../onboarding/LocalSetupScreen.tsx | 380 ++++++ .../onboarding/OnboardingScreen.tsx | 411 ++---- .../components/onboarding/RecoveryScreen.tsx | 129 ++ .../onboarding/RemoteConnectionForm.tsx | 318 +++++ .../onboarding/desktopRecoveryConfig.test.ts | 248 ++++ .../onboarding/desktopRecoveryConfig.ts | 109 ++ .../onboarding/desktopRecoveryRouting.test.ts | 49 + .../onboarding/desktopRecoveryRouting.ts | 32 + packages/ui/src/lib/desktop.ts | 10 +- packages/ui/src/lib/desktopBoot.test.ts | 372 ++++++ packages/ui/src/lib/desktopBoot.ts | 301 +++++ packages/ui/src/lib/desktopHosts.ts | 32 +- packages/ui/src/types/bun-test.d.ts | 24 + packages/ui/src/types/desktop.d.ts | 3 + packages/web/index.html | 9 +- 22 files changed, 3851 insertions(+), 568 deletions(-) create mode 100644 packages/ui/src/components/onboarding/ChooserScreen.tsx create mode 100644 packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx create mode 100644 packages/ui/src/components/onboarding/LocalSetupScreen.tsx create mode 100644 packages/ui/src/components/onboarding/RecoveryScreen.tsx create mode 100644 packages/ui/src/components/onboarding/RemoteConnectionForm.tsx create mode 100644 packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts create mode 100644 packages/ui/src/components/onboarding/desktopRecoveryConfig.ts create mode 100644 packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts create mode 100644 packages/ui/src/components/onboarding/desktopRecoveryRouting.ts create mode 100644 packages/ui/src/lib/desktopBoot.test.ts create mode 100644 packages/ui/src/lib/desktopBoot.ts create mode 100644 packages/ui/src/types/bun-test.d.ts diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 00043dd7..b97624e3 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -4935,9 +4935,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index bd23cb48..bb157d67 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -16,7 +16,7 @@ devtools = ["tauri/devtools"] anyhow = "1.0.86" base64 = "0.22.1" log = "0.4.28" -reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12.4", default-features = false, features = ["rustls-tls", "json"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.143" tauri = { version = "2.10.3", features = ["macos-private-api"] } @@ -25,7 +25,7 @@ tauri-plugin-log = "2.8.0" tauri-plugin-shell = "2.3.5" tauri-plugin-notification = "2.3.3" tauri-plugin-updater = "2.10.0" -tokio = { version = "1.38", features = ["rt-multi-thread", "time"] } +tokio = { version = "1.38", features = ["rt-multi-thread", "time", "macros", "sync"] } url = "2.5" [build-dependencies] diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 8acccaa8..32adb70f 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1203,20 +1203,46 @@ const MIN_RESTORE_WINDOW_HEIGHT: u32 = 560; const LOCAL_HOST_ID: &str = "local"; +/// Synthetic host ID used when the boot target is forced via +/// `OPENCHAMBER_SERVER_URL` (no config-based host entry). +const ENV_OVERRIDE_HOST_ID: &str = "__env"; + +/// Synthetic host ID used when a window is opened at an explicit URL +/// via `desktop_new_window_at_url` (no config-based host entry). +const DIRECT_URL_HOST_ID: &str = "__direct"; + +/// Compare two URL strings for "same server" identity using normalized +/// origin + path. This avoids misclassification when one URL has a +/// trailing slash and the other does not (e.g. `OPENCHAMBER_SERVER_URL` +/// pointing at the local sidecar without a trailing `/`). +fn same_server_url(a: &str, b: &str) -> bool { + let parsed_a = url::Url::parse(a); + let parsed_b = url::Url::parse(b); + match (parsed_a, parsed_b) { + (Ok(a), Ok(b)) => { + a.origin() == b.origin() + && a.path().trim_end_matches('/') == b.path().trim_end_matches('/') + } + _ => a == b, + } +} + #[derive(Default)] struct SidecarState { child: Mutex>, url: Mutex>, } -/// Holds the initialization script and local origin, shared across all windows. +/// Holds per-window initialization scripts and a global local origin. +/// Each window gets its own init script (containing the correct boot outcome +/// for that window's target URL), so page reloads re-inject the right data. #[derive(Default)] struct DesktopUiInjectionState { - script: Mutex>, + /// Init scripts keyed by window label. Each window's script contains + /// the correct `__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__` for that window. + scripts: Mutex>, + /// Local origin — shared across all windows since the sidecar is global. local_origin: Mutex>, - /// Host URLs that were probed unreachable (e.g. at startup). - /// `open_new_window` checks this to avoid opening windows at dead hosts. - unreachable_hosts: Mutex>, } /// Tracks the set of currently-focused window labels. @@ -1267,6 +1293,52 @@ struct DesktopHost { struct DesktopHostsConfig { hosts: Vec, default_host_id: Option, + #[serde(default)] + initial_host_choice_completed: bool, +} + +/// Input type for `desktop_hosts_set`. Fields may be omitted to preserve +/// existing stored values, ensuring backward-compatible callers don't +/// accidentally reset onboarding state. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopHostsConfigInput { + hosts: Vec, + default_host_id: Option, + #[serde(default)] + initial_host_choice_completed: Option, +} + +/// Process-wide mutex serializing all read-modify-write operations on the +/// desktop `settings.json`. This prevents concurrent writers (host config, +/// local port, window state, vibrancy, onboarding flag) from clobbering +/// each other's independent fields. +static SETTINGS_FILE_MUTEX: Mutex<()> = Mutex::new(()); + +/// Merge a partial input into an existing config, preserving fields that +/// the caller omitted (`None`). This is the single source of truth for +/// the merge semantics used by `desktop_hosts_set`. +fn merge_desktop_hosts_config( + existing: &DesktopHostsConfig, + input: &DesktopHostsConfigInput, +) -> DesktopHostsConfig { + DesktopHostsConfig { + hosts: input.hosts.clone(), + default_host_id: input.default_host_id.clone(), + initial_host_choice_completed: input + .initial_host_choice_completed + .unwrap_or(existing.initial_host_choice_completed), + } +} + +/// Atomic read-merge-write: reads existing config from `path`, merges +/// `input` into it, and writes the result — all while holding the process +/// lock. Tests and the `desktop_hosts_set` command share this path. +fn write_desktop_hosts_config_input_to_path(path: &Path, input: &DesktopHostsConfigInput) -> Result<()> { + let _guard = SETTINGS_FILE_MUTEX.lock().expect("desktop hosts mutex"); + let existing = read_desktop_hosts_config_from_path(path); + let merged = merge_desktop_hosts_config(&existing, input); + write_desktop_hosts_config_to_path(path, &merged) } #[derive(Clone, Serialize, Deserialize)] @@ -1367,6 +1439,7 @@ fn read_desktop_local_port_from_disk() -> Option { } fn write_desktop_local_port_to_disk(port: u16) -> Result<()> { + let _guard = SETTINGS_FILE_MUTEX.lock().expect("settings file mutex"); let path = settings_file_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -1408,6 +1481,12 @@ fn read_desktop_hosts_config_from_path(path: &Path) -> DesktopHostsConfig { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let initial_host_choice_completed = parsed + .as_ref() + .and_then(|v| v.get("desktopInitialHostChoiceCompleted")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let mut hosts: Vec = Vec::new(); if let serde_json::Value::Array(items) = hosts_value { for item in items { @@ -1433,6 +1512,7 @@ fn read_desktop_hosts_config_from_path(path: &Path) -> DesktopHostsConfig { DesktopHostsConfig { hosts, default_host_id: default_value, + initial_host_choice_completed, } } @@ -1451,6 +1531,7 @@ fn read_desktop_window_state_from_disk() -> Option { } fn write_desktop_window_state_to_disk(state: &DesktopWindowState) -> Result<()> { + let _guard = SETTINGS_FILE_MUTEX.lock().expect("settings file mutex"); let path = settings_file_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -1471,10 +1552,6 @@ fn write_desktop_window_state_to_disk(state: &DesktopWindowState) -> Result<()> Ok(()) } -fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> { - write_desktop_hosts_config_to_path(&settings_file_path(), config) -} - fn write_desktop_hosts_config_to_path(path: &Path, config: &DesktopHostsConfig) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; @@ -1516,19 +1593,252 @@ fn write_desktop_hosts_config_to_path(path: &Path, config: &DesktopHostsConfig) Some(id) if !id.trim().is_empty() => serde_json::Value::String(id.trim().to_string()), _ => serde_json::Value::Null, }; + root["desktopInitialHostChoiceCompleted"] = + serde_json::Value::Bool(config.initial_host_choice_completed); fs::write(&path, serde_json::to_string_pretty(&root)?)?; Ok(()) } +// ── Boot outcome resolution ── + +/// Authoritative desktop boot outcome injected into the webview as +/// `window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__`. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DesktopBootOutcome { + target: Option, // "local" | "remote" | null + status: String, // "ok" | "not-configured" | "unreachable" | "wrong-service" | "missing" + #[serde(skip_serializing_if = "Option::is_none")] + host_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, +} + +/// Probe status classification for boot resolution. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ProbeClass { + Ok, + Auth, + Unreachable, + WrongService, + NoProbe, +} + +impl ProbeClass { + fn from_probe(probe: Option<&HostProbeResult>) -> Self { + match probe { + Some(p) if p.status == "ok" => ProbeClass::Ok, + Some(p) if p.status == "auth" => ProbeClass::Auth, + Some(p) if p.status == "wrong-service" => ProbeClass::WrongService, + Some(_) => ProbeClass::Unreachable, + None => ProbeClass::NoProbe, + } + } +} + +/// Result of the shared soft+hard probe policy. +struct ProbeWithRetryResult { + /// Whether the target is navigable (ok or auth). + navigable: bool, + /// The final probe result, if available. + probe: Option, +} + +/// Shared probe policy: soft probe first, hard retry on failure. +/// Used by both startup and open_new_window for consistency. +async fn probe_with_retry(url: &str) -> ProbeWithRetryResult { + let soft_probe = + probe_host_with_timeout(url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await; + + let (navigable, final_probe) = match &soft_probe { + Ok(probe) if matches!(probe.status.as_str(), "ok" | "auth") => { + (true, Some(probe.clone())) + } + Ok(_) => { + log::warn!( + "[desktop] host slow/unreachable ({}), retrying with extended timeout", + url + ); + match probe_host_with_timeout(url, STARTUP_REMOTE_PROBE_HARD_TIMEOUT).await { + Ok(hard_probe) if matches!(hard_probe.status.as_str(), "ok" | "auth") => { + (true, Some(hard_probe)) + } + Ok(hard_probe) => (false, Some(hard_probe)), + Err(_) => (false, None), + } + } + Err(_) => { + log::warn!( + "[desktop] host errored ({}), retrying with extended timeout", + url + ); + match probe_host_with_timeout(url, STARTUP_REMOTE_PROBE_HARD_TIMEOUT).await { + Ok(hard_probe) if matches!(hard_probe.status.as_str(), "ok" | "auth") => { + (true, Some(hard_probe)) + } + Ok(hard_probe) => (false, Some(hard_probe)), + Err(_) => (false, None), + } + } + }; + + ProbeWithRetryResult { + navigable, + probe: final_probe, + } +} + +/// Determine the boot outcome from the desktop hosts config, optional probe +/// result, local server availability, and optional env-forced URL. +/// +/// When `env_target_url` is `Some`, it overrides the config-based default +/// host selection. The returned outcome always describes the actual boot +/// target, including env-forced remotes. +/// +/// This is the single source of truth for boot resolution logic. Both the +/// initial startup and `open_new_window` should delegate to this function +/// for consistency. +fn resolve_boot_outcome( + cfg: &DesktopHostsConfig, + probe: Option<&HostProbeResult>, + local_available: bool, + env_target_url: Option<&str>, +) -> DesktopBootOutcome { + let probe_class = ProbeClass::from_probe(probe); + + // Env-forced URL takes precedence over config. This is its own + // authoritative branch — never falls through to config-based resolution. + if let Some(env_url) = env_target_url { + return match probe_class { + ProbeClass::Ok | ProbeClass::Auth | ProbeClass::NoProbe => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "ok".to_string(), + host_id: Some(ENV_OVERRIDE_HOST_ID.to_string()), + url: Some(env_url.to_string()), + }, + ProbeClass::WrongService => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "wrong-service".to_string(), + host_id: Some(ENV_OVERRIDE_HOST_ID.to_string()), + url: Some(env_url.to_string()), + }, + ProbeClass::Unreachable => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "unreachable".to_string(), + host_id: Some(ENV_OVERRIDE_HOST_ID.to_string()), + url: Some(env_url.to_string()), + }, + }; + } + + // No default host configured + let default_id = cfg.default_host_id.as_deref().unwrap_or(""); + if default_id.is_empty() { + // Whether or not choice is completed, no default means not-configured + return DesktopBootOutcome { + target: None, + status: "not-configured".to_string(), + host_id: None, + url: None, + }; + } + + // Default is local + if default_id == LOCAL_HOST_ID { + if local_available { + return DesktopBootOutcome { + target: Some("local".to_string()), + status: "ok".to_string(), + host_id: None, + url: None, + }; + } + return DesktopBootOutcome { + target: Some("local".to_string()), + status: "unreachable".to_string(), + host_id: None, + url: None, + }; + } + + // Default is a remote host — find it + let host = cfg + .hosts + .iter() + .find(|h| h.id == default_id); + + let Some(host) = host else { + return DesktopBootOutcome { + target: Some("remote".to_string()), + status: "missing".to_string(), + host_id: Some(default_id.to_string()), + url: None, + }; + }; + + let host_id = host.id.clone(); + let host_url = host.url.clone(); + + match probe_class { + ProbeClass::Ok | ProbeClass::Auth => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "ok".to_string(), + host_id: Some(host_id), + url: Some(host_url), + }, + ProbeClass::WrongService => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "wrong-service".to_string(), + host_id: Some(host_id), + url: Some(host_url), + }, + ProbeClass::Unreachable => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "unreachable".to_string(), + host_id: Some(host_id), + url: Some(host_url), + }, + ProbeClass::NoProbe => { + // No probe result and choice already completed — treat as recovery + // (the probe hasn't happened yet, but the user has made a choice, + // so this shouldn't normally occur in practice). + DesktopBootOutcome { + target: Some("remote".to_string()), + status: "unreachable".to_string(), + host_id: Some(host_id), + url: Some(host_url), + } + } + } +} + +/// Compute the boot outcome to display when the local server fails to start. +/// +/// This ensures the UI leaves the splash screen and shows an appropriate +/// chooser/recovery state instead of hanging. It delegates to the existing +/// `resolve_boot_outcome` with `local_available = false` and no probe. +fn compute_local_startup_failure_boot_outcome(cfg: &DesktopHostsConfig) -> DesktopBootOutcome { + resolve_boot_outcome(cfg, None, false, None) +} + +/// Build the init script for the startup failure fallback case. +/// +/// Uses an empty `local_origin` since the local server is not running; +/// the UI can fall back to `window.location.origin` when needed. +fn build_startup_failure_init_script(boot_outcome: &DesktopBootOutcome) -> String { + build_init_script("", Some(boot_outcome)) +} + #[tauri::command] fn desktop_hosts_get() -> Result { Ok(read_desktop_hosts_config_from_disk()) } #[tauri::command] -fn desktop_hosts_set(config: DesktopHostsConfig) -> Result<(), String> { - write_desktop_hosts_config_to_disk(&config).map_err(|err| err.to_string()) +fn desktop_hosts_set(input: DesktopHostsConfigInput) -> Result<(), String> { + write_desktop_hosts_config_input_to_path(&settings_file_path(), &input) + .map_err(|err| err.to_string()) } #[derive(Clone, Serialize)] @@ -1575,9 +1885,42 @@ async fn probe_host_with_timeout(url: &str, timeout: Duration) -> Result Option { + let deadline = std::time::Instant::now() + timeout; + let mut interval = initial_interval; + let mut last_probe: Option = None; + + while std::time::Instant::now() < deadline { + match probe_host_with_timeout(url, max_interval).await { + Ok(probe) if matches!(probe.status.as_str(), "ok" | "auth") => { + return Some(probe); + } + Ok(probe) => { + last_probe = Some(probe); + } + Err(_) => {} + } + + tokio::time::sleep(interval).await; + interval = (interval * 2).min(max_interval); + } + + last_probe +} + +/// Uses the same probe_with_retry policy as startup/new-window (soft + hard) +/// so that first-launch/recovery remote connect accepts slow-but-valid hosts. #[tauri::command] async fn desktop_host_probe(url: String) -> Result { - probe_host_with_timeout(&url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await + let result = probe_with_retry(&url).await; + result + .probe + .ok_or_else(|| "Probe failed".to_string()) } #[derive(Clone, Serialize)] @@ -2064,23 +2407,16 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { let app_handle = app.clone(); tauri::async_runtime::spawn(async move { let mut rx = rx; - let mut stdout_buffer = String::new(); while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(bytes) => { - stdout_buffer.push_str(&String::from_utf8_lossy(&bytes)); - - while let Some(newline_index) = stdout_buffer.find('\n') { - let line = stdout_buffer[..newline_index].trim_end_matches('\r'); - if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) { - if let Ok(parsed) = - serde_json::from_str::(rest.trim()) - { - maybe_show_sidecar_notification(&app_handle, parsed); - } + let line = String::from_utf8_lossy(&bytes); + if let Some(rest) = line.strip_prefix(SIDECAR_NOTIFY_PREFIX) { + if let Ok(parsed) = + serde_json::from_str::(rest.trim()) + { + maybe_show_sidecar_notification(&app_handle, parsed); } - - stdout_buffer.drain(..=newline_index); } } CommandEvent::Error(error) => { @@ -2293,9 +2629,12 @@ fn desktop_new_window(app: tauri::AppHandle) -> Result<(), String> { /// Open a new window pointed at a specific URL (used by the host switcher UI). /// -/// IMPORTANT: Must remain synchronous -- see `desktop_new_window` doc comment. +/// For remote URLs (not matching local origin), probes the host and only opens +/// the window if the probe returns `ok` or `auth`. Falls back to local if the +/// remote is non-navigable. Window creation is dispatched to the main thread +/// and its result is propagated back to the caller. #[tauri::command] -fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), String> { +async fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), String> { // Validate scheme to prevent file://, data:, javascript: etc. let parsed = url::Url::parse(&url).map_err(|e| format!("Invalid URL: {e}"))?; match parsed.scheme() { @@ -2314,7 +2653,68 @@ fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), S }) .ok_or_else(|| "Local origin not yet known (sidecar may still be starting)".to_string())?; - create_window(&app, &url, &local_origin, false).map_err(|e| e.to_string()) + // If the URL is local, create the window directly. + if same_server_url(&url, &local_origin) { + let boot_outcome = DesktopBootOutcome { + target: Some("local".to_string()), + status: "ok".to_string(), + host_id: None, + url: None, + }; + let (tx, rx) = tokio::sync::oneshot::channel(); + let handle = app.clone(); + app.run_on_main_thread(move || { + let result = create_window(&handle, &url, &local_origin, Some(&boot_outcome), false) + .map_err(|e| e.to_string()); + let _ = tx.send(result); + }) + .map_err(|e| e.to_string())?; + return rx.await.map_err(|_| "Window creation cancelled".to_string())?; + } + + // Remote URL: probe with shared retry policy before opening. + let result = probe_with_retry(&url).await; + + let (final_url, boot_outcome) = if result.navigable { + let outcome = DesktopBootOutcome { + target: Some("remote".to_string()), + status: "ok".to_string(), + host_id: Some(DIRECT_URL_HOST_ID.to_string()), + url: Some(url.clone()), + }; + (url, outcome) + } else { + log::info!( + "[desktop] new_window_at_url: remote ({}) probe returned non-navigable status, falling back to local", + url + ); + let local_fallback = format!("{}/", local_origin); + let outcome = match &result.probe { + Some(probe) if probe.status == "wrong-service" => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "wrong-service".to_string(), + host_id: Some(DIRECT_URL_HOST_ID.to_string()), + url: Some(url), + }, + _ => DesktopBootOutcome { + target: Some("remote".to_string()), + status: "unreachable".to_string(), + host_id: Some(DIRECT_URL_HOST_ID.to_string()), + url: Some(url), + }, + }; + (local_fallback, outcome) + }; + + let (tx, rx) = tokio::sync::oneshot::channel(); + let handle = app.clone(); + app.run_on_main_thread(move || { + let result = create_window(&handle, &final_url, &local_origin, Some(&boot_outcome), false) + .map_err(|e| e.to_string()); + let _ = tx.send(result); + }) + .map_err(|e| e.to_string())?; + rx.await.map_err(|_| "Window creation cancelled".to_string())? } /// Read a file and return its content as base64 with mime type detection. @@ -2429,16 +2829,19 @@ fn macos_major_version() -> Option { /// Build the initialization script injected into every webview window. /// This is computed once and reused for all windows. -fn build_init_script(local_origin: &str) -> String { +fn build_init_script(local_origin: &str, boot_outcome: Option<&DesktopBootOutcome>) -> String { let home = std::env::var(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).unwrap_or_default(); let macos_major = macos_major_version().unwrap_or(0); let home_json = serde_json::to_string(&home).unwrap_or_else(|_| "\"\"".into()); let local_json = serde_json::to_string(local_origin).unwrap_or_else(|_| "\"\"".into()); + let boot_outcome_json = boot_outcome + .and_then(|o| serde_json::to_string(o).ok()) + .unwrap_or_else(|| "undefined".to_string()); let mut init_script = format!( - "(function(){{try{{window.__OPENCHAMBER_HOME__={home_json};window.__OPENCHAMBER_MACOS_MAJOR__={macos_major};window.__OPENCHAMBER_LOCAL_ORIGIN__={local_json};}}catch(_e){{}}}})();" + "(function(){{try{{window.__OPENCHAMBER_HOME__={home_json};window.__OPENCHAMBER_MACOS_MAJOR__={macos_major};window.__OPENCHAMBER_LOCAL_ORIGIN__={local_json};window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__={boot_outcome_json};}}catch(_e){{}}}})();" ); // Cleanup: older builds injected a native-ish Instance switcher button into pages. @@ -2646,16 +3049,22 @@ fn create_window( app: &tauri::AppHandle, url: &str, local_origin: &str, + boot_outcome: Option<&DesktopBootOutcome>, restore_geometry: bool, ) -> Result<()> { let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; let label = next_window_label(app); - let init_script = build_init_script(local_origin); + let init_script = build_init_script(local_origin, boot_outcome); - // Store the init script and local origin so new windows and page reloads can reuse it. + // Store the init script under this window's label so page reloads + // re-inject the correct boot outcome for this window. if let Some(state) = app.try_state::() { - *state.script.lock().expect("desktop ui injection mutex") = Some(init_script.clone()); + state + .scripts + .lock() + .expect("desktop ui injection mutex") + .insert(label.clone(), init_script.clone()); *state .local_origin .lock() @@ -2842,12 +3251,21 @@ fn build_startup_splash_script() -> String { ) } -fn activate_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> { +fn activate_main_window( + app: &tauri::AppHandle, + url: &str, + local_origin: &str, + boot_outcome: Option<&DesktopBootOutcome>, +) -> Result<()> { let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; - let init_script = build_init_script(local_origin); + let init_script = build_init_script(local_origin, boot_outcome); if let Some(state) = app.try_state::() { - *state.script.lock().expect("desktop ui injection mutex") = Some(init_script); + state + .scripts + .lock() + .expect("desktop ui injection mutex") + .insert("main".to_string(), init_script); *state .local_origin .lock() @@ -2860,7 +3278,7 @@ fn activate_main_window(app: &tauri::AppHandle, url: &str, local_origin: &str) - return Ok(()); } - create_window(app, url, local_origin, true) + create_window(app, url, local_origin, boot_outcome, true) } /// Open a new window pointed at the default host (local or configured default). @@ -2899,7 +3317,7 @@ fn open_new_window(app: &tauri::AppHandle) { return; }; - // Resolve the URL the same way as initial setup: default host or local. + // Resolve the URL the same way as initial setup: env override, then default host, else local. let local_url = app .try_state::() .and_then(|state| state.url.lock().expect("sidecar url mutex").clone()) @@ -2912,42 +3330,83 @@ fn open_new_window(app: &tauri::AppHandle) { local_url.clone() }; - let mut target_url = local_ui_url.clone(); + let env_target = std::env::var("OPENCHAMBER_SERVER_URL") + .ok() + .and_then(|raw| normalize_server_url(&raw)) + .filter(|url| !same_server_url(url, &local_ui_url)); let cfg = read_desktop_hosts_config_from_disk(); - if let Some(default_id) = cfg.default_host_id { + + let target_url = if let Some(ref env_url) = env_target { + env_url.clone() + } else if let Some(default_id) = cfg.default_host_id.as_deref() { if default_id == LOCAL_HOST_ID { - target_url = local_ui_url.clone(); - } else if let Some(host) = cfg.hosts.into_iter().find(|h| h.id == default_id) { - target_url = host.url; + local_ui_url.clone() + } else { + cfg.hosts + .iter() + .find(|h| h.id == default_id) + .map(|h| h.url.clone()) + .unwrap_or(local_ui_url.clone()) } + } else { + local_ui_url.clone() + }; + + // Compute boot outcome for the new window (no probe yet for sync local case). + let boot_outcome = resolve_boot_outcome( + &cfg, + None, + true, + env_target.as_deref(), + ); + + // If the target is local, create the window immediately on this (main) thread. + if same_server_url(&target_url, &local_ui_url) { + if let Err(err) = create_window(app, &target_url, &local_origin, Some(&boot_outcome), false) { + log::error!("[desktop] failed to create new window: {err}"); + } + return; } - // If this host was previously probed unreachable (e.g. at startup), fall back to local. - if target_url != local_ui_url { - let is_cached_unreachable = app - .try_state::() - .map(|state| { - state - .unreachable_hosts - .lock() - .expect("unreachable hosts mutex") - .contains(&target_url) - }) - .unwrap_or(false); + // For remote hosts, probe asynchronously then dispatch window creation + // back to the main thread via run_on_main_thread (required on macOS). + // Uses the same probe_with_retry policy as startup (soft + hard). + let handle = app.clone(); + let cfg_snapshot = cfg.clone(); + let env_target_snapshot = env_target.clone(); + tauri::async_runtime::spawn(async move { + let result = probe_with_retry(&target_url).await; - if is_cached_unreachable { + let final_url = if result.navigable { + target_url + } else { log::info!( - "[desktop] new window: default host ({}) cached as unreachable, using local", + "[desktop] new window: default host ({}) probe returned non-navigable status, using local", target_url ); - target_url = local_ui_url; - } - } + local_ui_url + }; - if let Err(err) = create_window(app, &target_url, &local_origin, false) { - log::error!("[desktop] failed to create new window: {err}"); - } + // Recompute boot outcome with actual probe result, using the + // same config/env snapshot that chose this window's target. + let final_boot_outcome = resolve_boot_outcome( + &cfg_snapshot, + result.probe.as_ref(), + true, + env_target_snapshot.as_deref(), + ); + + let local = local_origin; + let handle_clone = handle.clone(); + if let Err(err) = handle.run_on_main_thread(move || { + if let Err(err) = create_window(&handle_clone, &final_url, &local, Some(&final_boot_outcome), false) { + log::error!("[desktop] failed to create new window: {err}"); + } + }) { + log::error!("[desktop] failed to dispatch window creation to main thread: {err}"); + } + }); } fn main() { @@ -2998,8 +3457,9 @@ fn main() { .plugin(log_builder.build()) .on_page_load(|window, _payload| { if let Some(state) = window.app_handle().try_state::() { - if let Ok(guard) = state.script.lock() { - if let Some(script) = guard.as_ref() { + let label = window.label().to_string(); + if let Ok(guard) = state.scripts.lock() { + if let Some(script) = guard.get(&label) { let _ = window.eval(script); } } @@ -3169,6 +3629,15 @@ fn main() { state.remove_window(&label); } + // Remove stale per-window init script. + if let Some(state) = app.try_state::() { + state + .scripts + .lock() + .expect("desktop ui injection mutex") + .remove(&label); + } + // If this was the last window, kill the sidecar and exit. let remaining = app.webview_windows().len(); if remaining == 0 { @@ -3223,6 +3692,28 @@ fn main() { } tauri::async_runtime::spawn(async move { + // Helper: inject a fallback boot outcome when the local server + // cannot start, so the UI leaves the splash and shows + // chooser/recovery instead of hanging on a white screen. + let handle_for_fallback = handle.clone(); + let inject_startup_failure = |err: String| { + log::error!("[desktop] failed to start local server: {err}"); + let cfg = read_desktop_hosts_config_from_disk(); + let boot_outcome = compute_local_startup_failure_boot_outcome(&cfg); + let init_script = build_startup_failure_init_script(&boot_outcome); + if let Some(state) = handle_for_fallback.try_state::() + { + state + .scripts + .lock() + .expect("desktop ui injection mutex") + .insert("main".to_string(), init_script.clone()); + } + if let Some(window) = handle_for_fallback.get_webview_window("main") { + let _ = window.eval(&init_script); + } + }; + let local_url = if cfg!(debug_assertions) { let dev_url = "http://127.0.0.1:3901".to_string(); if wait_for_health(&dev_url).await { @@ -3231,7 +3722,7 @@ fn main() { match spawn_local_server(&handle).await { Ok(local) => local, Err(err) => { - log::error!("[desktop] failed to start local server: {err}"); + inject_startup_failure(err.to_string()); return; } } @@ -3240,7 +3731,7 @@ fn main() { match spawn_local_server(&handle).await { Ok(local) => local, Err(err) => { - log::error!("[desktop] failed to start local server: {err}"); + inject_startup_failure(err.to_string()); return; } } @@ -3270,66 +3761,88 @@ fn main() { .unwrap_or_else(|| local_ui_url.clone()); // Selected host: env override first, then desktop default host, else local. + // If env override points to the local server, ignore it and use + // config-based resolution instead. let env_target = std::env::var("OPENCHAMBER_SERVER_URL") .ok() - .and_then(|raw| normalize_server_url(&raw)); + .and_then(|raw| normalize_server_url(&raw)) + .filter(|url| !same_server_url(url, &local_ui_url)); - let mut initial_url = env_target.unwrap_or_else(|| local_ui_url.clone()); + let mut initial_url = env_target.as_deref().unwrap_or(&local_ui_url).to_string(); - if initial_url == local_ui_url { - let cfg = read_desktop_hosts_config_from_disk(); - if let Some(default_id) = cfg.default_host_id { - if default_id == LOCAL_HOST_ID { - initial_url = local_ui_url.clone(); - } else if let Some(host) = cfg.hosts.into_iter().find(|h| h.id == default_id) { - initial_url = host.url; + // Compute boot outcome and legacy-upgrade if needed. + let cfg = read_desktop_hosts_config_from_disk(); + + if env_target.is_none() { + if let Some(default_id) = cfg.default_host_id.as_deref() { + if default_id != LOCAL_HOST_ID { + if let Some(host) = cfg.hosts.iter().find(|h| h.id == default_id) { + initial_url = host.url.clone(); + } } } } - if initial_url != local_ui_url { - let failed_url = initial_url.clone(); - let soft_probe = - probe_host_with_timeout(&initial_url, STARTUP_REMOTE_PROBE_SOFT_TIMEOUT).await; + // If remote, probe and fall back to local if unreachable. + // Use the shared probe_with_retry policy (soft + hard). + let final_probe: Option = if !same_server_url(&initial_url, &local_ui_url) { + let result = probe_with_retry(&initial_url).await; - let remote_reachable = match soft_probe { - Ok(probe) if probe.status != "unreachable" => true, - Ok(_) | Err(_) => { - log::warn!( - "[desktop] startup host slow/unreachable ({}), retrying with extended timeout", - initial_url - ); - - match probe_host_with_timeout( - &initial_url, - STARTUP_REMOTE_PROBE_HARD_TIMEOUT, - ) - .await - { - Ok(probe) if probe.status != "unreachable" => true, - Ok(_) | Err(_) => false, - } - } - }; - - if !remote_reachable { + if !result.navigable { log::warn!( "[desktop] startup host unreachable after retries ({}), falling back to local ({})", initial_url, local_ui_url ); initial_url = local_ui_url.clone(); - if let Some(state) = handle.try_state::() { - state - .unreachable_hosts - .lock() - .expect("unreachable hosts mutex") - .insert(failed_url); - } } - } - if let Err(err) = activate_main_window(&handle, &initial_url, &local_origin) { + result.probe + } else { + None + }; + + // Probe the local server to verify opencode is actually running. + // spawn_local_server only confirms the sidecar web server responded + // HTTP 200 — it does not check whether opencode CLI is ready. + let local_available = match wait_for_local_opencode_ready_with( + &local_url, + LOCAL_SIDECAR_HEALTH_TIMEOUT, + LOCAL_SIDECAR_HEALTH_POLL_INITIAL_INTERVAL, + LOCAL_SIDECAR_HEALTH_POLL_MAX_INTERVAL, + ) + .await + { + Some(probe) if matches!(probe.status.as_str(), "ok" | "auth") => { + log::info!("[desktop] local opencode verified (status={})", probe.status); + true + } + Some(probe) => { + log::warn!( + "[desktop] local server up but opencode not ready (status={}), treating as unavailable", + probe.status + ); + false + } + None => { + log::warn!("[desktop] local opencode probe failed, treating as unavailable"); + false + } + }; + + let boot_outcome = resolve_boot_outcome( + &cfg, + final_probe.as_ref(), + local_available, + env_target.as_deref(), + ); + + if let Err(err) = activate_main_window( + &handle, + &initial_url, + &local_origin, + Some(&boot_outcome), + ) { log::error!("[desktop] failed to activate main window: {err}"); } }); @@ -3375,6 +3888,10 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; use std::time::{SystemTime, UNIX_EPOCH}; fn unique_settings_path(test_name: &str) -> PathBuf { @@ -3412,6 +3929,7 @@ mod tests { url: "https://example.com?coder_session_token=xxxxxx".to_string(), }], default_host_id: Some("remote-1".to_string()), + initial_host_choice_completed: false, }; write_desktop_hosts_config_to_path(&path, &config).expect("write config"); @@ -3425,4 +3943,412 @@ mod tests { ); assert_eq!(read_back.default_host_id.as_deref(), Some("remote-1")); } + + #[test] + fn read_hosts_config_defaults_initial_choice_flag_to_false() { + let path = unique_settings_path("desktop-hosts-default-flag"); + std::fs::write(&path, r#"{"desktopHosts":[],"desktopDefaultHostId":null}"#).unwrap(); + + let cfg = read_desktop_hosts_config_from_path(&path); + let _ = fs::remove_file(&path); + assert_eq!(cfg.initial_host_choice_completed, false); + } + + #[test] + fn write_and_read_hosts_config_preserves_initial_choice_flag() { + let path = unique_settings_path("desktop-hosts-preserve-flag"); + let cfg = DesktopHostsConfig { + hosts: vec![], + default_host_id: Some(LOCAL_HOST_ID.to_string()), + initial_host_choice_completed: true, + }; + + write_desktop_hosts_config_to_path(&path, &cfg).unwrap(); + let reread = read_desktop_hosts_config_from_path(&path); + let _ = fs::remove_file(&path); + + assert_eq!(reread.default_host_id.as_deref(), Some(LOCAL_HOST_ID)); + assert!(reread.initial_host_choice_completed); + } + + #[test] + fn omitted_initial_choice_flag_preserves_stored_true() { + let path = unique_settings_path("desktop-hosts-omit-preserves"); + + // Seed: write config with initialHostChoiceCompleted = true + let seed = DesktopHostsConfig { + hosts: vec![DesktopHost { + id: "remote-1".to_string(), + label: "Remote".to_string(), + url: "https://example.com".to_string(), + }], + default_host_id: Some("remote-1".to_string()), + initial_host_choice_completed: true, + }; + write_desktop_hosts_config_to_path(&path, &seed).unwrap(); + + // Call the production merge-and-write path with omitted field + let input = DesktopHostsConfigInput { + hosts: vec![], + default_host_id: Some("local".to_string()), + initial_host_choice_completed: None, + }; + write_desktop_hosts_config_input_to_path(&path, &input).unwrap(); + + let reread = read_desktop_hosts_config_from_path(&path); + let _ = fs::remove_file(&path); + + // The stored true must be preserved, not reset to false + assert!(reread.initial_host_choice_completed); + } + + // --- Task 2: probe validation tests --- + + /// Spawn a tiny HTTP server on a random port that responds with `status_code` + /// and `body`. Returns the base URL (e.g. `http://127.0.0.1:{port}`). + async fn spawn_test_http_server(status_code: u16, body: &str) -> String { + use tokio::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); + let port = listener.local_addr().unwrap().port(); + let body_owned = body.to_string(); + + tokio::spawn(async move { + loop { + let (mut stream, _) = tokio::select! { + res = listener.accept() => { res.expect("accept") } + else => break, + }; + use tokio::io::AsyncWriteExt; + let response = format!( + "HTTP/1.1 {status_code} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body_owned}", + body_owned.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + } + }); + + format!("http://127.0.0.1:{port}") + } + + #[tokio::test] + async fn probe_returns_wrong_service_for_generic_http_200_health() { + let url = spawn_test_http_server(200, r#"{"status":"ok","uptime":42}"#).await; + // Give the server a moment to start listening + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let result = probe_host_with_timeout(&url, Duration::from_secs(2)) + .await + .expect("probe should not error"); + assert_eq!(result.status, "wrong-service"); + } + + #[tokio::test] + async fn probe_returns_ok_for_valid_openchamber_health_payload() { + let url = spawn_test_http_server(200, r#"{"openCodeRunning":true}"#).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let result = probe_host_with_timeout(&url, Duration::from_secs(2)) + .await + .expect("probe should not error"); + assert_eq!(result.status, "ok"); + } + + #[tokio::test] + async fn probe_returns_auth_for_401_health() { + let url = spawn_test_http_server(401, r#"{"message":"unauthorized"}"#).await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let result = probe_host_with_timeout(&url, Duration::from_secs(2)) + .await + .expect("probe should not error"); + assert_eq!(result.status, "auth"); + } + + async fn spawn_flaky_openchamber_health_server() -> String { + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind flaky test server"); + let port = listener.local_addr().unwrap().port(); + let request_count = Arc::new(AtomicUsize::new(0)); + + tokio::spawn({ + let request_count = Arc::clone(&request_count); + async move { + loop { + let (mut stream, _) = tokio::select! { + res = listener.accept() => { res.expect("accept") } + else => break, + }; + + let count = request_count.fetch_add(1, Ordering::SeqCst); + let body = if count == 0 { + r#"{"status":"ok","openCodeRunning":false,"isOpenCodeReady":false}"# + } else { + r#"{"status":"ok","openCodeRunning":true,"isOpenCodeReady":true}"# + }; + + use tokio::io::AsyncWriteExt; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + } + } + }); + + format!("http://127.0.0.1:{port}") + } + + #[tokio::test] + async fn wait_for_local_opencode_ready_retries_until_health_payload_is_ready() { + let url = spawn_flaky_openchamber_health_server().await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let result = wait_for_local_opencode_ready_with( + &url, + Duration::from_millis(200), + Duration::from_millis(10), + Duration::from_millis(20), + ) + .await + .expect("probe result"); + + assert_eq!(result.status, "ok"); + } + + #[tokio::test] + async fn wait_for_local_opencode_ready_returns_last_probe_when_server_never_becomes_ready() { + let url = spawn_test_http_server( + 200, + r#"{"status":"ok","openCodeRunning":false,"isOpenCodeReady":false}"#, + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let result = wait_for_local_opencode_ready_with( + &url, + Duration::from_millis(120), + Duration::from_millis(10), + Duration::from_millis(20), + ) + .await + .expect("probe result"); + + assert_eq!(result.status, "wrong-service"); + } + + // --- Task 3: boot outcome resolution tests --- + + fn make_config( + hosts: Vec<(&str, &str, &str)>, + default_host_id: Option<&str>, + initial_host_choice_completed: bool, + ) -> DesktopHostsConfig { + DesktopHostsConfig { + hosts: hosts + .into_iter() + .map(|(id, label, url)| DesktopHost { + id: id.to_string(), + label: label.to_string(), + url: url.to_string(), + }) + .collect(), + default_host_id: default_host_id.map(|s| s.to_string()), + initial_host_choice_completed, + } + } + + #[test] + fn resolve_boot_outcome_returns_first_launch_when_no_default_and_choice_not_completed() { + let cfg = make_config(vec![], None, false); + let probe: Option<&HostProbeResult> = None; + let outcome = resolve_boot_outcome(&cfg, probe, true, None); + assert_eq!(outcome.target, None); + assert_eq!(outcome.status, "not-configured"); + } + + #[test] + fn resolve_boot_outcome_returns_recovery_no_default_host_when_choice_completed_but_default_missing() { + let cfg = make_config( + vec![("remote-a", "Remote A", "https://a.test")], + None, + true, + ); + let probe: Option<&HostProbeResult> = None; + let outcome = resolve_boot_outcome(&cfg, probe, true, None); + assert_eq!(outcome.target, None); + assert_eq!(outcome.status, "not-configured"); + } + + #[test] + fn resolve_boot_outcome_returns_recovery_missing_default_host_when_default_id_has_no_matching_host() { + let cfg = make_config(vec![], Some("gone-1"), true); + let probe: Option<&HostProbeResult> = None; + let outcome = resolve_boot_outcome(&cfg, probe, true, None); + assert_eq!(outcome.target, Some("remote".to_string())); + assert_eq!(outcome.status, "missing"); + assert_eq!(outcome.host_id.as_deref(), Some("gone-1")); + } + + #[test] + fn resolve_boot_outcome_returns_main_local_when_default_is_local_and_available() { + let cfg = make_config(vec![], Some("local"), true); + let probe: Option<&HostProbeResult> = None; + let outcome = resolve_boot_outcome(&cfg, probe, true, None); + assert_eq!(outcome.target, Some("local".to_string())); + assert_eq!(outcome.status, "ok"); + } + + #[test] + fn resolve_boot_outcome_returns_recovery_local_unavailable_when_local_is_default_but_unavailable() { + let cfg = make_config(vec![], Some("local"), true); + let probe: Option<&HostProbeResult> = None; + let outcome = resolve_boot_outcome(&cfg, probe, false, None); + assert_eq!(outcome.target, Some("local".to_string())); + assert_eq!(outcome.status, "unreachable"); + } + + #[test] + fn resolve_boot_outcome_returns_main_remote_when_probe_is_ok() { + let cfg = make_config( + vec![("remote-a", "Remote A", "https://a.test")], + Some("remote-a"), + true, + ); + let probe = HostProbeResult { + status: "ok".to_string(), + latency_ms: 10, + }; + let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); + assert_eq!(outcome.target, Some("remote".to_string())); + assert_eq!(outcome.status, "ok"); + assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); + assert_eq!(outcome.url.as_deref(), Some("https://a.test")); + } + + #[test] + fn resolve_boot_outcome_returns_main_remote_when_probe_is_auth() { + let cfg = make_config( + vec![("remote-a", "Remote A", "https://a.test")], + Some("remote-a"), + true, + ); + let probe = HostProbeResult { + status: "auth".to_string(), + latency_ms: 10, + }; + let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); + assert_eq!(outcome.target, Some("remote".to_string())); + assert_eq!(outcome.status, "ok"); + } + + #[test] + fn resolve_boot_outcome_returns_recovery_remote_unreachable_when_probe_fails() { + let cfg = make_config( + vec![("remote-a", "Remote A", "https://a.test")], + Some("remote-a"), + true, + ); + let probe = HostProbeResult { + status: "unreachable".to_string(), + latency_ms: 2000, + }; + let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); + assert_eq!(outcome.target, Some("remote".to_string())); + assert_eq!(outcome.status, "unreachable"); + assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); + } + + #[test] + fn resolve_boot_outcome_returns_recovery_remote_wrong_service_when_probe_says_wrong_service() { + let cfg = make_config( + vec![("remote-a", "Remote A", "https://a.test")], + Some("remote-a"), + true, + ); + let probe = HostProbeResult { + status: "wrong-service".to_string(), + latency_ms: 50, + }; + let outcome = resolve_boot_outcome(&cfg, Some(&probe), true, None); + assert_eq!(outcome.target, Some("remote".to_string())); + assert_eq!(outcome.status, "wrong-service"); + assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); + } + + #[test] + fn resolve_boot_outcome_no_probe_but_remote_default_returns_unreachable() { + // Remote default but no probe result yet — treat as unreachable + // (probe hasn't happened yet, but user has already chosen a remote) + let cfg = make_config( + vec![("remote-a", "Remote A", "https://a.test")], + Some("remote-a"), + false, + ); + let probe: Option<&HostProbeResult> = None; + let outcome = resolve_boot_outcome(&cfg, probe, true, None); + assert_eq!(outcome.target, Some("remote".to_string())); + assert_eq!(outcome.status, "unreachable"); + assert_eq!(outcome.host_id.as_deref(), Some("remote-a")); + } + + // --- Startup failure fallback boot outcome tests --- + + #[test] + fn startup_failure_returns_recovery_local_unavailable_when_default_is_local() { + let cfg = make_config(vec![], Some("local"), true); + let outcome = compute_local_startup_failure_boot_outcome(&cfg); + assert_eq!(outcome.target, Some("local".to_string())); + assert_eq!(outcome.status, "unreachable"); + } + + #[test] + fn startup_failure_returns_first_launch_when_no_default_and_choice_not_completed() { + let cfg = make_config(vec![], None, false); + let outcome = compute_local_startup_failure_boot_outcome(&cfg); + assert_eq!(outcome.target, None); + assert_eq!(outcome.status, "not-configured"); + } + + #[test] + fn startup_failure_returns_recovery_no_default_host_when_choice_completed_but_no_default() { + let cfg = make_config(vec![], None, true); + let outcome = compute_local_startup_failure_boot_outcome(&cfg); + assert_eq!(outcome.target, None); + assert_eq!(outcome.status, "not-configured"); + } + + #[test] + fn startup_failure_never_returns_main_outcome() { + // When the local server fails to start, the fallback outcome must + // never be a "main-*" variant because the startup-failure path + // only injects globals into the already-open startup window — it + // does NOT navigate to a remote URL. A "main-*" outcome would + // gate splash dismissal on initialization and hang. + let cfg = make_config(vec![], None, false); + let outcome = compute_local_startup_failure_boot_outcome(&cfg); + assert!( + outcome.status != "ok", + "startup failure fallback must not return main-* outcome, got: {:?}", + outcome + ); + } + + #[test] + fn startup_failure_init_script_contains_boot_outcome_json() { + let cfg = make_config(vec![], Some("local"), true); + let outcome = compute_local_startup_failure_boot_outcome(&cfg); + let script = build_startup_failure_init_script(&outcome); + // The script must contain the serialized boot outcome JSON. + assert!( + script.contains(r#""target":"local""#) && script.contains(r#""status":"unreachable""#), + "init script should embed the structured boot outcome" + ); + // It must also set __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ + assert!( + script.contains("__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__"), + "init script must set __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__" + ); + } } diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 6563214a..93668033 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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(() => 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(() => { + return getBootInjectionStatus(); + }); + const [bootView, setBootView] = React.useState(() => { + const outcome = getInjectedBootOutcome(); + return outcome !== null + ? resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome }) + : null; + }); const appReadyDispatchedRef = React.useRef(false); const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; - const recentDesktopNotificationTagsRef = React.useRef>(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) => { - 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 ( + +
+ { + // Switch to remote tab - handled internally by OnboardingScreen + }} + /> +
+
+ ); + } + + // Recovery screens + const recoveryVariant = mapBootViewToRecoveryVariant(bootView); + const hostUrl = bootView.screen === 'recovery' && 'url' in bootView ? bootView.url : undefined; + return ( -
- +
+
); @@ -652,7 +699,7 @@ function App({ apis }: AppProps) { -
+
diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx index 226bba8a..a9d560c0 100644 --- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx +++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx @@ -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 ; if (status === 'auth') return ; + if (status === 'wrong-service') return ; if (status === 'unreachable') return ; return ; }; @@ -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 ? : } @@ -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" > - {statusKind === 'unreachable' ? 'Instance unreachable' : 'Open in new window'} + {(statusKind === 'unreachable' || statusKind === 'wrong-service') ? 'Instance unreachable' : 'Open in new window'}
diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx new file mode 100644 index 00000000..23576e1c --- /dev/null +++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx @@ -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 ( +
+ + curl + -fsSL + https://opencode.ai/install + | + bash + + +
+ ); +} + +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(null); + const [opencodeBinary, setOpencodeBinary] = React.useState(''); + const [platform, setPlatform] = React.useState('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 => { + 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) => Promise } } }).__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 ( +
+
+
+

+ Welcome to OpenChamber +

+

+ Choose how you want to connect to get started. +

+
+ + {isDesktopApp && isTauriShell() && ( +
+ + +
+ )} + + {isDesktopApp && isTauriShell() && activeTab === 'remote' ? ( + setActiveTab('local')} + showBackButton={false} + onSwitchToLocal={() => setActiveTab('local')} + /> + ) : ( + <> + {(!isDesktopApp || !isTauriShell() || activeTab === 'local') && ( + <> + {platform === 'windows' && ( +
+
Windows setup (WSL recommended)
+
    +
  1. Install WSL (if needed) with wsl --install in PowerShell.
  2. +
  3. Run the install command below inside your WSL terminal.
  4. +
  5. If OpenChamber does not detect OpenCode automatically, set the binary path below.
  6. +
+
+ )} + +
+
+ {copied ? ( +
+ + Copied to clipboard +
+ ) : ( + + )} +
+
+ + + {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'} + + + + {checkError && ( +
+ {checkError} +
+ )} + +
+ + +

+ Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen. +

+
+ +
+
+
Already installed? Set the OpenCode CLI path:
+
+ setOpencodeBinary(e.target.value)} + placeholder={binaryPlaceholder} + disabled={isRetrying} + className="flex-1 font-mono text-xs" + /> + + +
+
Saves to OpenChamber settings and reloads OpenCode configuration.
+
+
+ + )} + + )} +
+ + {showHint && activeTab === 'local' && ( +
+ {platform === 'windows' ? ( + <> +

+ On Windows, install and run OpenCode in WSL for best compatibility. +

+

+ If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode. +

+ + ) : ( + <> +

+ Already installed? Make sure opencode is in your PATH +

+

+ or set OPENCODE_BINARY environment variable. +

+

+ If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH. +

+ + )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx new file mode 100644 index 00000000..5a72d06e --- /dev/null +++ b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx @@ -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 ; + case 'remote': + return ; + } +} + +export function DesktopConnectionRecovery({ + variant, + hostLabel, + hostUrl, + onRetry, + onUseLocal, + onUseRemote, + isRetrying = false, +}: DesktopConnectionRecoveryProps) { + const config = getDesktopRecoveryConfig(variant, hostLabel, hostUrl); + + return ( +
+
+ {/* Icon and title */} +
+
+
+ {getRecoveryIcon(config.iconKey)} +
+
+

+ {config.title} +

+

+ {config.description} +

+
+ + {/* Host info if available */} + {hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && ( +
+
Server Address
+
{redactSensitiveUrl(hostUrl)}
+
+ )} + + {/* Action buttons */} +
+ {config.showRetry && onRetry && ( + + )} + +
+ {config.showUseLocal && onUseLocal && ( + + )} + + {config.showUseRemote && onUseRemote && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx new file mode 100644 index 00000000..ae011489 --- /dev/null +++ b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx @@ -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 ( +
+ + curl + -fsSL + https://opencode.ai/install + | + bash + + +
+ ); +} + +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(null); + const [opencodeBinary, setOpencodeBinary] = React.useState(''); + const [platform, setPlatform] = React.useState('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 => { + 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) => Promise } } }).__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 ( +
+
+
+ +
+ +
+

+ Setting Up OpenCode +

+

+ Install OpenCode CLI to continue. +

+
+ + {platform === 'windows' && ( +
+
Windows setup (WSL recommended)
+
    +
  1. Install WSL (if needed) with wsl --install in PowerShell.
  2. +
  3. Run the install command below inside your WSL terminal.
  4. +
  5. If OpenChamber does not detect OpenCode automatically, set the binary path below.
  6. +
+
+ )} + +
+
+ {copied ? ( +
+ + Copied to clipboard +
+ ) : ( + + )} +
+
+ + + {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'} + + + + {checkError && ( +
+ {checkError} +
+ )} + +
+ + +

+ Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen. +

+
+ +
+
+
Already installed? Set the OpenCode CLI path:
+
+ setOpencodeBinary(e.target.value)} + placeholder={binaryPlaceholder} + disabled={isRetrying} + className="flex-1 font-mono text-xs" + /> + + +
+
Saves to OpenChamber settings and reloads OpenCode configuration.
+
+
+ + {isFromRecovery && onSwitchToRemote && ( +
+

+ Prefer to use a remote server? +

+ +
+ )} +
+ + {showHint && ( +
+ {platform === 'windows' ? ( + <> +

+ On Windows, install and run OpenCode in WSL for best compatibility. +

+

+ If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode. +

+ + ) : ( + <> +

+ Already installed? Make sure opencode is in your PATH +

+

+ or set OPENCODE_BINARY environment variable. +

+

+ If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH. +

+ + )} +
+ )} +
+ ); +} diff --git a/packages/ui/src/components/onboarding/OnboardingScreen.tsx b/packages/ui/src/components/onboarding/OnboardingScreen.tsx index d07c3166..95db4a08 100644 --- a/packages/ui/src/components/onboarding/OnboardingScreen.tsx +++ b/packages/ui/src/components/onboarding/OnboardingScreen.tsx @@ -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 ( + setShowRecoveryRemoteForm(false)} + onSwitchToLocalFromRemote={() => { + setShowRecoveryRemoteForm(false); + setRecoveryEnteredLocalSetup(true); + }} + onEnterLocalSetup={() => { + setRecoveryEnteredLocalSetup(true); + onEnterLocalSetup?.(); + }} + /> + ); + } + + // Local-setup mode + if (effectiveMode === 'local-setup') { + return ( + { + if (recoveryEnteredLocalSetup) { + setRecoveryEnteredLocalSetup(false); + } else { + onBack?.(); + } + }} + onCliAvailable={onCliAvailable} + isFromRecovery={recoveryEnteredLocalSetup} + onSwitchToRemote={() => setShowRecoveryRemoteForm(true)} + /> + ); + } + + // First-launch mode (default) return ( -
- - curl - -fsSL - https://opencode.ai/install - | - bash - - -
- ); -} - -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('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 => { - 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) => Promise } } }).__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 ( -
-
-
-

- Welcome to OpenChamber -

-

- - OpenCode CLI - - - {' '}is required to continue. -

-
- - {platform === 'windows' && ( -
-
Windows setup (WSL recommended)
-
    -
  1. Install WSL (if needed) with wsl --install in PowerShell.
  2. -
  3. Run the install command below inside your WSL terminal.
  4. -
  5. If OpenChamber does not detect OpenCode automatically, set the binary path below.
  6. -
-
- )} - -
-
- {copied ? ( -
- - Copied to clipboard -
- ) : ( - - )} -
-
- - - {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'} - - - -

- Waiting for OpenCode installation... -

- -
- -
- -
-
-
Already installed? Set the OpenCode CLI path:
-
- setOpencodeBinary(e.target.value)} - placeholder={binaryPlaceholder} - disabled={isRetrying} - className="flex-1 font-mono text-xs" - /> - - -
-
Saves to OpenChamber settings and reloads OpenCode configuration.
-
-
-
- - {showHint && ( -
- {platform === 'windows' ? ( - <> -

- On Windows, install and run OpenCode in WSL for best compatibility. -

-

- If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode. -

- - ) : ( - <> -

- Already installed? Make sure opencode is in your PATH -

-

- or set OPENCODE_BINARY environment variable. -

-

- If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH. -

- - )} -
- )} -
+ ); } diff --git a/packages/ui/src/components/onboarding/RecoveryScreen.tsx b/packages/ui/src/components/onboarding/RecoveryScreen.tsx new file mode 100644 index 00000000..fc9b0c96 --- /dev/null +++ b/packages/ui/src/components/onboarding/RecoveryScreen.tsx @@ -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 ( + onChooseRemote?.())} + initialUrl={prefillUrl} + initialLabel={prefillLabel} + isRecoveryMode={true} + onSwitchToLocal={onSwitchToLocalFromRemote || (() => { + persistFirstChoice('local').then(() => { + if (isTauriShell()) { + restartDesktopApp(); + } else { + onEnterLocalSetup?.(); + } + }); + })} + /> + ); + } + + return ( + + ); +} diff --git a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx new file mode 100644 index 00000000..ae6677d2 --- /dev/null +++ b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx @@ -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('idle'); + const [probeResult, setProbeResult] = useState(null); + const [error, setError] = useState(''); + + const normalizedUrl = normalizeHostUrl(url); + + const handleUrlChange = useCallback((e: React.ChangeEvent) => { + setUrl(e.target.value); + setState('idle'); + setProbeResult(null); + setError(''); + }, []); + + const handleLabelChange = useCallback((e: React.ChangeEvent) => { + 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) => Promise } } }).__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 ( +
+
+ {showBackButton && ( +
+ +
+ )} + +
+

+ {isRecoveryMode ? 'Connect to a Different Server' : 'Connect to Remote Server'} +

+

+ {isRecoveryMode + ? 'Enter the address of an OpenChamber server to connect to.' + : 'Enter the address of an OpenChamber server to connect to.'} +

+
+ +
+
+ + +
+ +
+ + +
+
+ + {/* Success message */} + {probeResult && isSuccess && ( +
+ Connected successfully ({probeResult.latencyMs}ms) +
+ )} + + {/* Auth warning (non-blocking) */} + {probeResult && isAuth && ( +
+ Server requires authentication. You can still connect. +
+ )} + + {/* Blocking errors */} + {probeResult && isBlocking && ( +
+
+
Connection Failed
+
{probeMessage}
+
+
+ {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.'} +
+
+ )} + + {/* Generic error */} + {error && ( +
+ {error} +
+ )} + +
+ + +
+ + {/* Suggested actions when connection is blocked */} + {isBlocking && ( +
+
What would you like to do?
+
+ + {!isRecoveryMode && onSwitchToLocal && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts b/packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts new file mode 100644 index 00000000..f963446f --- /dev/null +++ b/packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts new file mode 100644 index 00000000..61f53325 --- /dev/null +++ b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts @@ -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}`); + } + } +} diff --git a/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts new file mode 100644 index 00000000..b270f56c --- /dev/null +++ b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts @@ -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> = { + '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, + ][]) { + 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 }); + }); + } + } +}); diff --git a/packages/ui/src/components/onboarding/desktopRecoveryRouting.ts b/packages/ui/src/components/onboarding/desktopRecoveryRouting.ts new file mode 100644 index 00000000..5788150a --- /dev/null +++ b/packages/ui/src/components/onboarding/desktopRecoveryRouting.ts @@ -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}`); + } + } +} diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8c26bf85..5701e842 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -458,12 +458,20 @@ export const restartToApplyUpdate = async (): Promise => { return false; } + return restartDesktopApp(); +}; + +export const restartDesktopApp = async (): Promise => { + 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; } }; diff --git a/packages/ui/src/lib/desktopBoot.test.ts b/packages/ui/src/lib/desktopBoot.test.ts new file mode 100644 index 00000000..5621da74 --- /dev/null +++ b/packages/ui/src/lib/desktopBoot.test.ts @@ -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 = {}; + (globalThis as Record).window = w; + return w; + }; + const restoreWindow = () => { + delete (globalThis as Record).window; + }; + + test('returns null when window global is undefined', () => { + delete (globalThis as Record).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 = {}; + (globalThis as Record).window = w; + return w; + }; + const restoreWindow = () => { + delete (globalThis as Record).window; + }; + + test('returns "not-injected" when window is undefined', () => { + delete (globalThis as Record).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); + }); +}); diff --git a/packages/ui/src/lib/desktopBoot.ts b/packages/ui/src/lib/desktopBoot.ts new file mode 100644 index 00000000..adb074f1 --- /dev/null +++ b/packages/ui/src/lib/desktopBoot.ts @@ -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; + 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'; +} diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index f74ef703..280ba08f 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -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 => { 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 => { 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 => { +export const desktopHostsSet = async (config: DesktopHostsConfigInput): Promise => { 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 => 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'; diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts new file mode 100644 index 00000000..429350a7 --- /dev/null +++ b/packages/ui/src/types/bun-test.d.ts @@ -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; + 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; + }; + }; +} diff --git a/packages/ui/src/types/desktop.d.ts b/packages/ui/src/types/desktop.d.ts index 361cd17a..10410225 100644 --- a/packages/ui/src/types/desktop.d.ts +++ b/packages/ui/src/types/desktop.d.ts @@ -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; } } diff --git a/packages/web/index.html b/packages/web/index.html index e0b7f4ea..79d23e01 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -501,9 +501,14 @@