diff --git a/desktop/app/loading.html b/desktop/app/loading.html index c4825d15..b8ed2157 100644 --- a/desktop/app/loading.html +++ b/desktop/app/loading.html @@ -34,7 +34,8 @@ .foot .warn { color:var(--warn); } .diag { margin-top:10px; font-size:.72rem; color:var(--muted); font-family: ui-monospace, Menlo, monospace; background: rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.08); border-radius:8px; - padding:8px 10px; user-select:text; display:none; white-space:pre-wrap; text-align:left; } + padding:8px 10px; user-select:text; display:none; white-space:pre-wrap; text-align:left; + max-height:260px; overflow:auto; } .actions { margin-top:18px; display:flex; gap:12px; align-items:center; justify-content:center; min-height:34px; } .btn { font: inherit; font-size:.85rem; font-weight:600; padding:7px 16px; border-radius:9px; cursor:pointer; border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:var(--fg); } @@ -56,7 +57,6 @@
-
@@ -100,6 +100,20 @@ function stepUrl(s){ return 'http://127.0.0.1:' + PORTS[s.portKey] + s.path; } var t0 = Date.now(), quipIdx = 0, quipStep = null, doneRedirect = false, failed = false, curStartedAt = {}, cur = null; + var STARTUP_LOG = ''; + // Pull the teed backend logs (/startup.log) from the Tauri host so the + // failure screen can show WHY the services didn't come up. + function fetchStartupLog(){ + try { + if (window.__TAURI__ && window.__TAURI__.core) { + window.__TAURI__.core.invoke('read_startup_log') + .then(function(t){ STARTUP_LOG = (t && t.trim()) ? t : '(startup log is empty)'; render(); }) + .catch(function(){ STARTUP_LOG = '(could not read startup log)'; render(); }); + return; + } + } catch (e) {} + STARTUP_LOG = '(startup log unavailable)'; + } var $ = function(id){ return document.getElementById(id); }; STEPS.forEach(function(s){ var li = document.createElement('li'); li.id = 'li-'+s.id; @@ -136,10 +150,15 @@ $('stage').textContent = "Orcabot couldn't finish starting up"; $('quip').textContent = 'The background services didn’t come up. A previous instance may still be running, or a port is in use.'; $('hint').textContent = ''; - $('diag').style.display = 'block'; $('diag').textContent = diagText(); + $('diag').style.display = 'block'; + // Show the port/health summary plus the actual backend logs (workerd/D1), + // which explain WHY startup failed (e.g. a port bind error) — otherwise + // invisible in a Finder-launched .app. + $('diag').textContent = diagText() + + '\n\n─── background server logs ───\n' + + (STARTUP_LOG || '(fetching…)'); $('retryBtn').classList.remove('hidden'); $('quitBtn').classList.remove('hidden'); - $('openAnyway').classList.remove('hidden'); return; } cur = current(); @@ -149,7 +168,6 @@ $('quip').textContent = cur.quips[quipIdx % cur.quips.length]; var waited = (Date.now() - (curStartedAt[cur.id]||t0))/1000; $('hint').innerHTML = waited > (cur.slow||12) ? ' · '+cur.label+' is taking longer than usual…' : ''; - if (elapsed() > MANUAL_AFTER){ $('openAnyway').classList.remove('hidden'); } } function tick(){ @@ -158,12 +176,11 @@ .then(function(){ var ready = STEPS.every(function(s){ return !s.required || s.up; }); if (ready && !doneRedirect){ doneRedirect = true; render(); setTimeout(function(){ window.location.replace(redirectUrl()); }, 500); return; } - if (!ready && elapsed() >= FAIL_AFTER){ failed = true; } + if (!ready && elapsed() >= FAIL_AFTER){ failed = true; fetchStartupLog(); } render(); }); } - $('openAnyway').addEventListener('click', function(){ window.location.replace(redirectUrl()); }); $('retryBtn').addEventListener('click', function(){ window.location.reload(); }); $('quitBtn').addEventListener('click', function(){ try { window.__TAURI__.core.invoke('quit_app'); } catch(e) { try { window.close(); } catch(_){} } diff --git a/desktop/app/src-tauri/Cargo.lock b/desktop/app/src-tauri/Cargo.lock index 520af626..32708a09 100644 --- a/desktop/app/src-tauri/Cargo.lock +++ b/desktop/app/src-tauri/Cargo.lock @@ -2305,7 +2305,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orcabot-desktop" -version = "0.6.0" +version = "0.8.0" dependencies = [ "ctrlc", "filetime", diff --git a/desktop/app/src-tauri/Cargo.toml b/desktop/app/src-tauri/Cargo.toml index 318e462b..e1352467 100644 --- a/desktop/app/src-tauri/Cargo.toml +++ b/desktop/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orcabot-desktop" -version = "0.6.0" +version = "0.8.0" edition = "2021" description = "Orcabot Desktop shell" license = "UNLICENSED" diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index f26fbc22..fa53f03a 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -616,6 +616,20 @@ pub fn get_app_version(app: tauri::AppHandle) -> String { app.package_info().version.to_string() } +/// Read this boot's startup log (`/startup.log`) — the teed workerd / D1 +/// output plus the chosen ports — so the loading screen can show WHY the backend +/// failed to start (a Finder-launched .app has no console). Empty string if none. +#[tauri::command] +pub fn read_startup_log(app: tauri::AppHandle) -> String { + use tauri::Manager; + app.path() + .app_data_dir() + .ok() + .map(|d| d.join("startup.log")) + .and_then(|p| std::fs::read_to_string(p).ok()) + .unwrap_or_default() +} + #[derive(Serialize, Clone)] pub struct OrcabotAccount { pub email: String, diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index d2fb38de..34ec695a 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -26,6 +26,16 @@ fn pid_file_path(data_dir: &Path) -> PathBuf { data_dir.join("desktop-services.pid") } +/// Set once the user accepts an auto-update ("Update & restart"). The whole stack is +/// about to be torn down and relaunched, so we must NOT keep spinning up heavy +/// processes (especially the sandbox VM boot / image download) during the ~minutes +/// download — `start_sandbox_vm` checks this and bails. +static UPDATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +fn is_updating() -> bool { + UPDATING.load(std::sync::atomic::Ordering::SeqCst) +} + /// Progress of an in-flight auto-update, emitted to the GUI as `update-progress` /// so the frontend can show a download bar (the native "Update available" dialog /// otherwise gives no feedback between "Update & restart" and the relaunch). @@ -201,6 +211,34 @@ fn ensure_port_env(var: &str, preferred: u16, used: &[u16]) -> u16 { port } +/// Tee a child process stream line-by-line to the console AND (if available) the +/// per-boot startup log, each line prefixed with the service label. Runs on its own +/// thread so it drains the pipe continuously (never blocking the child on a full buffer). +fn tee_child_stream( + stream: R, + label: String, + log_path: Option, + is_err: bool, +) { + use std::io::{BufRead, BufReader, Write}; + std::thread::spawn(move || { + let mut logf = + log_path.and_then(|p| std::fs::OpenOptions::new().create(true).append(true).open(p).ok()); + for line in BufReader::new(stream).lines() { + let Ok(line) = line else { break }; + let out = format!("[{}] {}", label, line); + if is_err { + eprintln!("{}", out); + } else { + println!("{}", out); + } + if let Some(ref mut f) = logf { + let _ = writeln!(f, "{}", out); + } + } + }); +} + /// Per-boot token that gates dev-auth to the trusted host frontend. Generated /// once at startup, passed to the control-plane worker (`SURFACE_TOKEN`) and /// handed to the GUI webview via the `get_surface_token` command. The sandbox VM @@ -461,6 +499,13 @@ impl DesktopServices { ); } + // Start the per-boot startup log with the chosen ports — the first thing to check + // when startup fails (was a default busy? did allocation move a service?). + self.reset_startup_log(&format!( + "ports: control-plane={} frontend={} d1-shim={} sandbox={}", + cp_port, fe_port, d1_port, sandbox_host_port + )); + if cp_port != 8787 || fe_port != 8788 || d1_port != 9001 || sandbox_host_port != 8080 { eprintln!( "[ports] a default port was busy — using control-plane={} frontend={} d1-shim={} sandbox={}", @@ -665,6 +710,13 @@ impl DesktopServices { data_dir: &Path, resource_root: &Path, ) -> Result<(), vm::VMError> { + // The user accepted an app update → don't spin the VM up (or download its image) + // just to tear it all down on the imminent relaunch. + if is_updating() { + eprintln!("[vm] app update accepted — skipping sandbox VM startup"); + return Ok(()); + } + // Check if VM resources exist let vm_resource_paths = vm::image::VMResourcePaths::from_resource_root(resource_root); @@ -775,9 +827,20 @@ impl DesktopServices { }; config = config.with_cmdline(cmdline); - // Create and start VM + // Create and start VM — unless an update was accepted while we were staging. + if is_updating() { + eprintln!("[vm] app update accepted — not booting sandbox VM"); + return Ok(()); + } let mut vm = create_platform_vm(); vm.start(&config)?; + // If the update landed during boot, stop the VM we just started rather than + // waiting 120s for health only to tear it down on relaunch. + if is_updating() { + eprintln!("[vm] app update accepted mid-boot — stopping sandbox VM"); + let _ = vm.stop(); + return Ok(()); + } // Wait for sandbox to be healthy eprintln!("Waiting for sandbox VM to become healthy..."); @@ -818,24 +881,76 @@ impl DesktopServices { let mut command = Command::new(binary_path); command.args(args); - command.stdout(Stdio::inherit()); - command.stderr(Stdio::inherit()); + // Tee stdout+stderr to the console AND /startup.log. A Finder-launched + // .app has no attached terminal, so inherited output vanishes — this keeps a + // per-boot record of WHY a service failed (surfaced in the loading screen and + // recoverable as a file). + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); for (key, value) in envs { command.env(key, value); } match command.spawn() { - Ok(child) => { + Ok(mut child) => { + let log_path = self.startup_log_path(); + if let Some(out) = child.stdout.take() { + tee_child_stream(out, label.to_string(), log_path.clone(), false); + } + if let Some(err) = child.stderr.take() { + tee_child_stream(err, label.to_string(), log_path, true); + } if let Ok(mut children) = self.children.lock() { children.push(child); } } Err(err) => { eprintln!("Failed to start {}: {}", label, err); + self.append_startup_log(&format!("[{}] FAILED TO START: {}", label, err)); } } } + /// `/startup.log` — where service output is teed for post-mortem. + fn startup_log_path(&self) -> Option { + self + .data_dir + .lock() + .ok() + .and_then(|dd| dd.clone()) + .map(|d| d.join("startup.log")) + } + + fn append_startup_log(&self, line: &str) { + if let Some(p) = self.startup_log_path() { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(p) { + let _ = writeln!(f, "{}", line); + } + } + } + + /// Truncate the startup log at the start of a boot and write a header, so it only + /// ever reflects the CURRENT startup attempt (not accumulated across launches). + fn reset_startup_log(&self, header: &str) { + if let Some(p) = self.startup_log_path() { + let _ = std::fs::write(p, format!("=== Orcabot desktop startup ===\n{}\n", header)); + } + } + + /// Stop ONLY the sandbox VM (leave workerd/frontend running). Used when the user + /// accepts an update: the heavy VM shouldn't keep running/booting during the + /// download, but the frontend must stay up so the update-progress bar keeps working. + fn stop_sandbox_vm(&self) { + if let Ok(mut vm_lock) = self.sandbox_vm.lock() { + if let Some(ref mut vm) = *vm_lock { + eprintln!("Stopping sandbox VM (app update in progress)..."); + let _ = vm.stop(); + } + *vm_lock = None; + } + } + fn shutdown(&self) { // Stop sandbox VM first if let Ok(mut vm_lock) = self.sandbox_vm.lock() { @@ -1012,6 +1127,7 @@ fn main() { commands::reveal_workspace, commands::get_ports, commands::get_app_version, + commands::read_startup_log, commands::verify_orcabot_account, commands::set_cloud_credential, commands::sign_in_google_loopback, @@ -1130,6 +1246,7 @@ fn main() { use tauri_plugin_dialog::{DialogExt, MessageDialogButtons}; use tauri_plugin_updater::UpdaterExt; let handle = app.handle().clone(); + let upd_services = Arc::clone(&services); tauri::async_runtime::spawn(async move { let updater = match handle.updater() { Ok(u) => u, @@ -1158,6 +1275,13 @@ fn main() { return; } + // The user chose update+restart. Everything is about to be torn down and + // relaunched, so stop spinning up the heavy sandbox VM during the ~minutes + // download: flag it so start_sandbox_vm won't (re)boot, and stop any VM that + // already came up. Leave workerd/frontend running so this progress UI works. + UPDATING.store(true, std::sync::atomic::Ordering::SeqCst); + upd_services.stop_sandbox_vm(); + // Tell the GUI a download is starting so it can show a progress bar, // then stream progress from the download closure. Emits are throttled // to once per MB so a ~190MB download doesn't flood IPC. diff --git a/desktop/app/src-tauri/tauri.conf.json b/desktop/app/src-tauri/tauri.conf.json index 96494676..5bcc87fe 100644 --- a/desktop/app/src-tauri/tauri.conf.json +++ b/desktop/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Orcabot", - "version": "0.6.0", + "version": "0.8.0", "identifier": "com.orcabot.desktop", "build": { "beforeDevCommand": "sh -c \"cd ../../frontend && NEXT_PUBLIC_API_URL=http://localhost:8787 NEXT_PUBLIC_SITE_URL=http://localhost:8788 NEXT_PUBLIC_DEV_MODE_ENABLED=true NEXT_PUBLIC_DESKTOP_MODE=true npx wrangler dev -c wrangler.toml --port 8788\"", @@ -48,7 +48,7 @@ "macOS": { "entitlements": "entitlements.plist", "signingIdentity": "Developer ID Application: Robert Macrae (3927MKQNPA)", - "minimumSystemVersion": "13.0" + "minimumSystemVersion": "13.5" } }, "plugins": { diff --git a/desktop/app/src-tauri/vm-image.json b/desktop/app/src-tauri/vm-image.json index 306a2f06..d2732ae3 100644 --- a/desktop/app/src-tauri/vm-image.json +++ b/desktop/app/src-tauri/vm-image.json @@ -1,5 +1,5 @@ { - "version": "v2", - "sha256": "eba0a9e5c17778f5de08371dd9bcb46aa3697a827d8c0ed7c801a465c907f633", - "url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v2/sandbox.img.gz" + "version": "v3", + "sha256": "a040638617597b660ea8baef915d4de70267fa48632f910fc892d9115313daa4", + "url": "https://github.com/Hyper-Int/OrcaBot/releases/download/vm-image-v3/sandbox.img.gz" } diff --git a/frontend/src/app/download/download.css b/frontend/src/app/download/download.css index 00ec9301..039aacf1 100644 --- a/frontend/src/app/download/download.css +++ b/frontend/src/app/download/download.css @@ -47,6 +47,11 @@ margin-bottom: 1rem; } +.dl-req { + margin: -0.5rem 0 1rem; + font-size: 0.85rem; +} + .dl-btn { display: inline-flex; flex-direction: column; diff --git a/frontend/src/app/download/page.tsx b/frontend/src/app/download/page.tsx index 677f5a3f..173026b9 100644 --- a/frontend/src/app/download/page.tsx +++ b/frontend/src/app/download/page.tsx @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: download-page-v1 +// REVISION: download-page-v2-min-os-warn "use client"; import "./download.css"; @@ -17,10 +17,54 @@ import { const GITHUB_RELEASES = "https://github.com/Hyper-Int/OrcaBot/releases/latest"; +// Minimum macOS the bundled workerd runtime supports (built with minos 13.5). +// Older Macs crash at startup (dyld: __libcpp_verbose_abort not found), so we +// surface the requirement before the download rather than after a broken launch. +const MIN_MACOS = "13.5"; + +const MODULE_REVISION = "download-page-v2-min-os-warn"; +if (typeof window !== "undefined") { + // eslint-disable-next-line no-console + console.log(`[download] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}`); +} + +// Best-effort check to save pre-13.5 users a wasted download. Only Chromium +// exposes the real macOS version (UA Client Hints); Safari/Firefox don't, and +// navigator.userAgent is privacy-frozen at "10_15_7" on macOS. So this is +// advisory only — it never disables the button (a wrong guess must not block a +// valid download). Returns the detected version string when confidently < 13.5. +async function detectOldMacOS(): Promise { + try { + const uaData = (navigator as unknown as { + userAgentData?: { + platform?: string; + getHighEntropyValues?: (hints: string[]) => Promise<{ platformVersion?: string }>; + }; + }).userAgentData; + if (!uaData?.getHighEntropyValues || uaData.platform !== "macOS") return null; + const hints = await uaData.getHighEntropyValues(["platformVersion"]); + const raw = String(hints.platformVersion || ""); + const parts = raw.split(".").map((n) => parseInt(n, 10) || 0); + const [maj, min] = parts; + // major 10 (or 0) = privacy-frozen "10.15.7" or a pre-ARM Mac — can't trust + // it as a real version, so don't warn (avoid false positives). + if (!maj || maj === 10) return null; + if (maj < 13 || (maj === 13 && min < 5)) return raw; + return null; + } catch { + return null; + } +} + export default function DownloadPage() { const [release, setRelease] = React.useState(null); const [error, setError] = React.useState(null); const [loading, setLoading] = React.useState(true); + const [oldMac, setOldMac] = React.useState(null); + + React.useEffect(() => { + detectOldMacOS().then(setOldMac); + }, []); React.useEffect(() => { let cancelled = false; @@ -56,6 +100,14 @@ export default function DownloadPage() { Run Claude Code, Codex, and a full sandboxed stack locally — no setup.

+ {oldMac && ( +

+ You appear to be on macOS {oldMac}. Orcabot requires macOS {MIN_MACOS} or + later and won’t start on older versions — updating macOS first will save + you a wasted download. +

+ )} + {loading &&

Loading the latest release…

} {error && ( @@ -81,6 +133,9 @@ export default function DownloadPage() { )} +

+ Requires macOS {MIN_MACOS} or later · Apple Silicon +

v{release.version} Latest @@ -127,7 +182,7 @@ export default function DownloadPage() { )}

- macOS 13+ · Apple Silicon. Existing installs update automatically. + macOS {MIN_MACOS}+ · Apple Silicon. Existing installs update automatically.