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 @@