Przeglądaj źródła

desktop: add loading window and restructure rust (#12176)

Brendan Allan 1 tydzień temu
rodzic
commit
b7ad8e459c

+ 1 - 0
bun.lock

@@ -188,6 +188,7 @@
         "@opencode-ai/ui": "workspace:*",
         "@solid-primitives/i18n": "2.2.1",
         "@solid-primitives/storage": "catalog:",
+        "@solidjs/meta": "catalog:",
         "@tauri-apps/api": "^2",
         "@tauri-apps/plugin-deep-link": "~2",
         "@tauri-apps/plugin-dialog": "~2",

+ 2 - 1
packages/app/package.json

@@ -5,7 +5,8 @@
   "type": "module",
   "exports": {
     ".": "./src/index.ts",
-    "./vite": "./vite.js"
+    "./vite": "./vite.js",
+    "./index.css": "./src/index.css"
   },
   "scripts": {
     "typecheck": "tsgo -b",

+ 1 - 1
packages/app/src/components/titlebar.tsx

@@ -152,6 +152,7 @@ export function Titlebar() {
     <header
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
       style={{ "min-height": minHeight() }}
+      onMouseDown={drag}
       onDblClick={maximize}
     >
       <div
@@ -159,7 +160,6 @@ export function Titlebar() {
           "flex items-center min-w-0": true,
           "pl-2": !mac(),
         }}
-        onMouseDown={drag}
       >
         <Show when={mac()}>
           <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />

+ 1 - 1
packages/desktop/index.html

@@ -19,6 +19,6 @@
     <noscript>You need to enable JavaScript to run this app.</noscript>
     <div id="root" class="flex flex-col h-dvh"></div>
     <div data-tauri-decorum-tb class="w-0 h-0 hidden" />
-    <script src="/src/index.tsx" type="module"></script>
+    <script src="/src/entry.tsx" type="module"></script>
   </body>
 </html>

+ 2 - 1
packages/desktop/package.json

@@ -29,7 +29,8 @@
     "@tauri-apps/plugin-updater": "~2",
     "@tauri-apps/plugin-http": "~2",
     "@tauri-apps/plugin-window-state": "~2",
-    "solid-js": "catalog:"
+    "solid-js": "catalog:",
+    "@solidjs/meta": "catalog:"
   },
   "devDependencies": {
     "@actions/artifact": "4.0.0",

+ 5 - 4
packages/desktop/src-tauri/Cargo.lock

@@ -3066,6 +3066,7 @@ name = "opencode-desktop"
 version = "0.0.0"
 dependencies = [
  "comrak",
+ "dirs",
  "futures",
  "gtk",
  "listeners",
@@ -4549,7 +4550,7 @@ dependencies = [
 [[package]]
 name = "specta"
 version = "2.0.0-rc.22"
-source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+source = "git+https://github.com/specta-rs/specta?rev=591a5f3ddc78348abf4cbb541d599d65306d92b9#591a5f3ddc78348abf4cbb541d599d65306d92b9"
 dependencies = [
  "paste",
  "rustc_version",
@@ -4559,7 +4560,7 @@ dependencies = [
 [[package]]
 name = "specta-macros"
 version = "2.0.0-rc.18"
-source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+source = "git+https://github.com/specta-rs/specta?rev=591a5f3ddc78348abf4cbb541d599d65306d92b9#591a5f3ddc78348abf4cbb541d599d65306d92b9"
 dependencies = [
  "Inflector",
  "proc-macro2",
@@ -4570,7 +4571,7 @@ dependencies = [
 [[package]]
 name = "specta-serde"
 version = "0.0.9"
-source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+source = "git+https://github.com/specta-rs/specta?rev=591a5f3ddc78348abf4cbb541d599d65306d92b9#591a5f3ddc78348abf4cbb541d599d65306d92b9"
 dependencies = [
  "specta",
 ]
@@ -4578,7 +4579,7 @@ dependencies = [
 [[package]]
 name = "specta-typescript"
 version = "0.0.9"
-source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+source = "git+https://github.com/specta-rs/specta?rev=591a5f3ddc78348abf4cbb541d599d65306d92b9#591a5f3ddc78348abf4cbb541d599d65306d92b9"
 dependencies = [
  "specta",
  "specta-serde",

+ 3 - 2
packages/desktop/src-tauri/Cargo.toml

@@ -46,6 +46,7 @@ comrak = { version = "0.50", default-features = false }
 specta = "=2.0.0-rc.22"
 specta-typescript = "0.0.9"
 tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
+dirs = "6.0.0"
 
 [target.'cfg(target_os = "linux")'.dependencies]
 gtk = "0.18.2"
@@ -64,8 +65,8 @@ windows = { version = "0.61", features = [
 ] }
 
 [patch.crates-io]
-specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
-specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
+specta = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" }
+specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" }
 tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" }
 # TODO: https://github.com/tauri-apps/tauri/pull/14812
 tauri  = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" }

+ 7 - 0
packages/desktop/src-tauri/build.rs

@@ -1,3 +1,10 @@
 fn main() {
+    if let Ok(git_ref) = std::env::var("GITHUB_REF") {
+        let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref);
+        if branch == "beta" {
+            println!("cargo:rustc-env=OPENCODE_SQLITE=1");
+        }
+    }
+
     tauri_build::build()
 }

+ 1 - 1
packages/desktop/src-tauri/capabilities/default.json

@@ -2,7 +2,7 @@
   "$schema": "../gen/schemas/desktop-schema.json",
   "identifier": "default",
   "description": "Capability for the main window",
-  "windows": ["main"],
+  "windows": ["*"],
   "permissions": [
     "core:default",
     "opener:default",

+ 58 - 1
packages/desktop/src-tauri/src/cli.rs

@@ -1,5 +1,10 @@
 use tauri::{AppHandle, Manager, path::BaseDirectory};
-use tauri_plugin_shell::{ShellExt, process::Command};
+use tauri_plugin_shell::{
+    ShellExt,
+    process::{Command, CommandChild, CommandEvent},
+};
+
+use crate::{LogState, constants::MAX_LOG_ENTRIES};
 
 const CLI_INSTALL_DIR: &str = ".opencode/bin";
 const CLI_BINARY_NAME: &str = "opencode";
@@ -182,3 +187,55 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
             .args(["-il", "-c", &cmd])
     };
 }
+
+pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
+    let log_state = app.state::<LogState>();
+    let log_state_clone = log_state.inner().clone();
+
+    println!("spawning sidecar on port {port}");
+
+    let (mut rx, child) = create_command(
+        app,
+        format!("serve --hostname {hostname} --port {port}").as_str(),
+    )
+    .env("OPENCODE_SERVER_USERNAME", "opencode")
+    .env("OPENCODE_SERVER_PASSWORD", password)
+    .spawn()
+    .expect("Failed to spawn opencode");
+
+    tokio::spawn(async move {
+        while let Some(event) = rx.recv().await {
+            match event {
+                CommandEvent::Stdout(line_bytes) => {
+                    let line = String::from_utf8_lossy(&line_bytes);
+                    print!("{line}");
+
+                    // Store log in shared state
+                    if let Ok(mut logs) = log_state_clone.0.lock() {
+                        logs.push_back(format!("[STDOUT] {}", line));
+                        // Keep only the last MAX_LOG_ENTRIES
+                        while logs.len() > MAX_LOG_ENTRIES {
+                            logs.pop_front();
+                        }
+                    }
+                }
+                CommandEvent::Stderr(line_bytes) => {
+                    let line = String::from_utf8_lossy(&line_bytes);
+                    eprint!("{line}");
+
+                    // Store log in shared state
+                    if let Ok(mut logs) = log_state_clone.0.lock() {
+                        logs.push_back(format!("[STDERR] {}", line));
+                        // Keep only the last MAX_LOG_ENTRIES
+                        while logs.len() > MAX_LOG_ENTRIES {
+                            logs.pop_front();
+                        }
+                    }
+                }
+                _ => {}
+            }
+        }
+    });
+
+    child
+}

+ 10 - 0
packages/desktop/src-tauri/src/constants.rs

@@ -0,0 +1,10 @@
+use tauri_plugin_window_state::StateFlags;
+
+pub const SETTINGS_STORE: &str = "opencode.settings.dat";
+pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
+pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
+pub const MAX_LOG_ENTRIES: usize = 200;
+
+pub fn window_state_flags() -> StateFlags {
+    StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE
+}

+ 291 - 402
packages/desktop/src-tauri/src/lib.rs

@@ -1,46 +1,58 @@
 mod cli;
+mod constants;
 #[cfg(windows)]
 mod job_object;
 mod markdown;
+mod server;
 mod window_customizer;
+mod windows;
 
-use cli::{install_cli, sync_cli};
-use futures::FutureExt;
-use futures::future;
+use futures::{
+    FutureExt, TryFutureExt,
+    future::{self, Shared},
+};
 #[cfg(windows)]
 use job_object::*;
 use std::{
     collections::VecDeque,
+    env,
     net::TcpListener,
+    path::PathBuf,
     sync::{Arc, Mutex},
-    time::{Duration, Instant},
+    time::Duration,
 };
-use tauri::{AppHandle, Manager, RunEvent, State, WebviewWindowBuilder};
-#[cfg(windows)]
-use tauri_plugin_decorum::WebviewWindowExt;
+use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
 #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
 use tauri_plugin_deep_link::DeepLinkExt;
-use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
-use tauri_plugin_shell::process::{CommandChild, CommandEvent};
-use tauri_plugin_store::StoreExt;
-use tauri_plugin_window_state::{AppHandleExt, StateFlags};
-use tokio::sync::{mpsc, oneshot};
-
-use crate::window_customizer::PinchZoomDisablePlugin;
-
-const SETTINGS_STORE: &str = "opencode.settings.dat";
-const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
+use tauri_plugin_shell::process::CommandChild;
+use tokio::{
+    sync::{oneshot, watch},
+    time::{sleep, timeout},
+};
 
-fn window_state_flags() -> StateFlags {
-    StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE
-}
+use crate::cli::sync_cli;
+use crate::constants::*;
+use crate::server::get_saved_server_url;
+use crate::windows::{LoadingWindow, MainWindow};
 
-#[derive(Clone, serde::Serialize, specta::Type)]
+#[derive(Clone, serde::Serialize, specta::Type, Debug)]
 struct ServerReadyData {
     url: String,
     password: Option<String>,
 }
 
+#[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
+#[serde(tag = "phase", rename_all = "snake_case")]
+enum InitStep {
+    ServerWaiting,
+    SqliteWaiting,
+    Done,
+}
+
+struct InitState {
+    current: watch::Receiver<InitStep>,
+}
+
 #[derive(Clone)]
 struct ServerState {
     child: Arc<Mutex<Option<CommandChild>>>,
@@ -50,11 +62,11 @@ struct ServerState {
 impl ServerState {
     pub fn new(
         child: Option<CommandChild>,
-        status: oneshot::Receiver<Result<ServerReadyData, String>>,
+        status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
     ) -> Self {
         Self {
             child: Arc::new(Mutex::new(child)),
-            status: status.shared(),
+            status,
         }
     }
 
@@ -66,8 +78,6 @@ impl ServerState {
 #[derive(Clone)]
 struct LogState(Arc<Mutex<VecDeque<String>>>);
 
-const MAX_LOG_ENTRIES: usize = 200;
-
 #[tauri::command]
 #[specta::specta]
 fn kill_sidecar(app: AppHandle) {
@@ -104,173 +114,47 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
 
 #[tauri::command]
 #[specta::specta]
-async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
-    state
-        .status
-        .clone()
-        .await
-        .map_err(|_| "Failed to get server status".to_string())?
-}
-
-#[tauri::command]
-#[specta::specta]
-fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
-    let store = app
-        .store(SETTINGS_STORE)
-        .map_err(|e| format!("Failed to open settings store: {}", e))?;
-
-    let value = store.get(DEFAULT_SERVER_URL_KEY);
-    match value {
-        Some(v) => Ok(v.as_str().map(String::from)),
-        None => Ok(None),
-    }
-}
-
-#[tauri::command]
-#[specta::specta]
-async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
-    let store = app
-        .store(SETTINGS_STORE)
-        .map_err(|e| format!("Failed to open settings store: {}", e))?;
-
-    match url {
-        Some(u) => {
-            store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
-        }
-        None => {
-            store.delete(DEFAULT_SERVER_URL_KEY);
-        }
-    }
+async fn await_initialization(
+    state: State<'_, ServerState>,
+    init_state: State<'_, InitState>,
+    events: Channel<InitStep>,
+) -> Result<ServerReadyData, String> {
+    let mut rx = init_state.current.clone();
 
-    store
-        .save()
-        .map_err(|e| format!("Failed to save settings: {}", e))?;
+    let events = async {
+        let e = (*rx.borrow()).clone();
+        let _ = events.send(e).unwrap();
 
-    Ok(())
-}
+        while rx.changed().await.is_ok() {
+            let step = *rx.borrow_and_update();
 
-fn get_sidecar_port() -> u32 {
-    option_env!("OPENCODE_PORT")
-        .map(|s| s.to_string())
-        .or_else(|| std::env::var("OPENCODE_PORT").ok())
-        .and_then(|port_str| port_str.parse().ok())
-        .unwrap_or_else(|| {
-            TcpListener::bind("127.0.0.1:0")
-                .expect("Failed to bind to find free port")
-                .local_addr()
-                .expect("Failed to get local address")
-                .port()
-        }) as u32
-}
+            let _ = events.send(step);
 
-fn spawn_sidecar(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
-    let log_state = app.state::<LogState>();
-    let log_state_clone = log_state.inner().clone();
-
-    println!("spawning sidecar on port {port}");
-
-    let (mut rx, child) = cli::create_command(
-        app,
-        format!("serve --hostname {hostname} --port {port}").as_str(),
-    )
-    .env("OPENCODE_SERVER_USERNAME", "opencode")
-    .env("OPENCODE_SERVER_PASSWORD", password)
-    .spawn()
-    .expect("Failed to spawn opencode");
-
-    tauri::async_runtime::spawn(async move {
-        while let Some(event) = rx.recv().await {
-            match event {
-                CommandEvent::Stdout(line_bytes) => {
-                    let line = String::from_utf8_lossy(&line_bytes);
-                    print!("{line}");
-
-                    // Store log in shared state
-                    if let Ok(mut logs) = log_state_clone.0.lock() {
-                        logs.push_back(format!("[STDOUT] {}", line));
-                        // Keep only the last MAX_LOG_ENTRIES
-                        while logs.len() > MAX_LOG_ENTRIES {
-                            logs.pop_front();
-                        }
-                    }
-                }
-                CommandEvent::Stderr(line_bytes) => {
-                    let line = String::from_utf8_lossy(&line_bytes);
-                    eprint!("{line}");
-
-                    // Store log in shared state
-                    if let Ok(mut logs) = log_state_clone.0.lock() {
-                        logs.push_back(format!("[STDERR] {}", line));
-                        // Keep only the last MAX_LOG_ENTRIES
-                        while logs.len() > MAX_LOG_ENTRIES {
-                            logs.pop_front();
-                        }
-                    }
-                }
-                _ => {}
+            if matches!(step, InitStep::Done) {
+                break;
             }
         }
-    });
-
-    child
-}
-
-fn url_is_localhost(url: &reqwest::Url) -> bool {
-    url.host_str().is_some_and(|host| {
-        host.eq_ignore_ascii_case("localhost")
-            || host
-                .parse::<std::net::IpAddr>()
-                .is_ok_and(|ip| ip.is_loopback())
-    })
-}
-
-async fn check_server_health(url: &str, password: Option<&str>) -> bool {
-    let Ok(url) = reqwest::Url::parse(url) else {
-        return false;
-    };
-
-    let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
-
-    if url_is_localhost(&url) {
-        // Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
-        // excluding loopback. reqwest respects these by default, which can prevent the desktop
-        // app from reaching its own local sidecar server.
-        builder = builder.no_proxy();
-    };
-
-    let Ok(client) = builder.build() else {
-        return false;
     };
-    let Ok(health_url) = url.join("/global/health") else {
-        return false;
-    };
-
-    let mut req = client.get(health_url);
 
-    if let Some(password) = password {
-        req = req.basic_auth("opencode", Some(password));
-    }
-
-    req.send()
+    future::join(state.status.clone(), events)
         .await
-        .map(|r| r.status().is_success())
-        .unwrap_or(false)
+        .0
+        .map_err(|_| "Failed to get server status".to_string())?
 }
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
-    let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
-
     let builder = tauri_specta::Builder::<tauri::Wry>::new()
         // Then register them (separated by a comma)
         .commands(tauri_specta::collect_commands![
             kill_sidecar,
-            install_cli,
-            ensure_server_ready,
-            get_default_server_url,
-            set_default_server_url,
+            cli::install_cli,
+            await_initialization,
+            server::get_default_server_url,
+            server::set_default_server_url,
             markdown::parse_markdown_command
         ])
+        .events(tauri_specta::collect_events![LoadingWindowComplete])
         .error_handling(tauri_specta::ErrorHandlingMode::Throw);
 
     #[cfg(debug_assertions)] // <- Only export on non-release builds
@@ -289,7 +173,7 @@ pub fn run() {
     let mut builder = tauri::Builder::default()
         .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
             // Focus existing window when another instance is launched
-            if let Some(window) = app.get_webview_window("main") {
+            if let Some(window) = app.get_webview_window(MainWindow::LABEL) {
                 let _ = window.set_focus();
                 let _ = window.unminimize();
             }
@@ -299,6 +183,7 @@ pub fn run() {
         .plugin(
             tauri_plugin_window_state::Builder::new()
                 .with_state_flags(window_state_flags())
+                .with_denylist(&[LoadingWindow::LABEL])
                 .build(),
         )
         .plugin(tauri_plugin_store::Builder::new().build())
@@ -309,117 +194,19 @@ pub fn run() {
         .plugin(tauri_plugin_clipboard_manager::init())
         .plugin(tauri_plugin_http::init())
         .plugin(tauri_plugin_notification::init())
-        .plugin(PinchZoomDisablePlugin)
+        .plugin(crate::window_customizer::PinchZoomDisablePlugin)
         .plugin(tauri_plugin_decorum::init())
         .invoke_handler(builder.invoke_handler())
         .setup(move |app| {
-            builder.mount_events(app);
-
-            #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
-            app.deep_link().register_all().ok();
-
             let app = app.handle().clone();
 
-            // Initialize log state
-            app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
-
-            #[cfg(windows)]
-            app.manage(JobObjectState::new());
-
-            let config = app
-                .config()
-                .app
-                .windows
-                .iter()
-                .find(|w| w.label == "main")
-                .expect("main window config missing");
-
-            let window_builder = WebviewWindowBuilder::from_config(&app, config)
-                .expect("Failed to create window builder from config")
-                .maximized(true)
-                .initialization_script(format!(
-                    r#"
-                      window.__OPENCODE__ ??= {{}};
-                      window.__OPENCODE__.updaterEnabled = {updater_enabled};
-                    "#
-                ));
-
-            #[cfg(target_os = "macos")]
-            let window_builder = window_builder
-                .title_bar_style(tauri::TitleBarStyle::Overlay)
-                .hidden_title(true);
-
-            #[cfg(windows)]
-            let window_builder = window_builder
-                // Some VPNs set a global/system proxy that WebView2 applies even for loopback
-                // connections, which breaks the app's localhost sidecar server.
-                // Note: when setting additional args, we must re-apply wry's default
-                // `--disable-features=...` flags.
-                .additional_browser_args(
-                    "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection",
-                )
-                .decorations(false);
-
-            let window = window_builder.build().expect("Failed to create window");
-
-            setup_window_state_listener(&app, &window);
-
-            #[cfg(windows)]
-            let _ = window.create_overlay_titlebar();
-
-            let (tx, rx) = oneshot::channel();
-            app.manage(ServerState::new(None, rx));
-
-            {
-                let app = app.clone();
-                tauri::async_runtime::spawn(async move {
-                    let mut custom_url = None;
-
-                    if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
-                        println!("Using desktop-specific custom URL: {url}");
-                        custom_url = Some(url);
-                    }
-
-                    if custom_url.is_none()
-                        && let Some(cli_config) = cli::get_config(&app).await
-                        && let Some(url) = get_server_url_from_config(&cli_config)
-                    {
-                        println!("Using custom server URL from config: {url}");
-                        custom_url = Some(url);
-                    }
-
-                    let res = match setup_server_connection(&app, custom_url).await {
-                        Ok((child, url)) => {
-                            #[cfg(windows)]
-                            if let Some(child) = &child {
-                                let job_state = app.state::<JobObjectState>();
-                                job_state.assign_pid(child.pid());
-                            }
-
-                            app.state::<ServerState>().set_child(child);
-
-                            Ok(url)
-                        }
-                        Err(e) => Err(e),
-                    };
-
-                    let _ = tx.send(res);
-                });
-            }
-
-            {
-                let app = app.clone();
-                tauri::async_runtime::spawn(async move {
-                    if let Err(e) = sync_cli(app) {
-                        eprintln!("Failed to sync CLI: {e}");
-                    }
-                });
-            }
+            builder.mount_events(&app);
+            tauri::async_runtime::spawn(initialize(app));
 
             Ok(())
         });
 
-    if updater_enabled {
+    if UPDATER_ENABLED {
         builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
     }
 
@@ -435,160 +222,262 @@ pub fn run() {
         });
 }
 
-/// Converts a bind address hostname to a valid URL hostname for connection.
-/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
-/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
-fn normalize_hostname_for_url(hostname: &str) -> String {
-    // Wildcard bind addresses -> localhost equivalents
-    if hostname == "0.0.0.0" {
-        return "127.0.0.1".to_string();
-    }
-    if hostname == "::" {
-        return "[::1]".to_string();
-    }
+#[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
+struct LoadingWindowComplete;
 
-    // IPv6 addresses need brackets in URLs
-    if hostname.contains(':') && !hostname.starts_with('[') {
-        return format!("[{}]", hostname);
-    }
+// #[tracing::instrument(skip_all)]
+async fn initialize(app: AppHandle) {
+    println!("Initializing app");
 
-    hostname.to_string()
-}
+    let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
 
-fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
-    let server = config.server.as_ref()?;
-    let port = server.port?;
-    println!("server.port found in OC config: {port}");
-    let hostname = server
-        .hostname
-        .as_ref()
-        .map(|v| normalize_hostname_for_url(v))
-        .unwrap_or_else(|| "127.0.0.1".to_string());
-
-    Some(format!("http://{}:{}", hostname, port))
-}
+    setup_app(&app, init_rx);
+    spawn_cli_sync_task(app.clone());
 
-async fn setup_server_connection(
-    app: &AppHandle,
-    custom_url: Option<String>,
-) -> Result<(Option<CommandChild>, ServerReadyData), String> {
-    if let Some(url) = custom_url {
-        loop {
-            if check_server_health(&url, None).await {
-                println!("Connected to custom server: {}", url);
-                return Ok((
-                    None,
-                    ServerReadyData {
-                        url: url.clone(),
-                        password: None,
-                    },
-                ));
-            }
+    let (server_ready_tx, server_ready_rx) = oneshot::channel();
+    let server_ready_rx = server_ready_rx.shared();
+    app.manage(ServerState::new(None, server_ready_rx.clone()));
+
+    let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
+
+    println!("Main and loading windows created");
+
+    let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
 
-            const RETRY: &str = "Retry";
+    let loading_task = tokio::spawn({
+        let init_tx = init_tx.clone();
+        let app = app.clone();
+
+        async move {
+            let mut sqlite_exists = sqlite_file_exists();
+
+            println!("Setting up server connection");
+            let server_connection = setup_server_connection(app.clone()).await;
+
+            // we delay spawning this future so that the timeout is created lazily
+            let cli_health_check = match server_connection {
+                ServerConnection::CLI {
+                    child,
+                    health_check,
+                    url,
+                    password,
+                } => {
+                    let app = app.clone();
+                    Some(
+                        async move {
+                            let Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await
+                            else {
+                                let _ = child.kill();
+                                return Err(format!(
+                                    "Failed to spawn OpenCode Server. Logs:\n{}",
+                                    get_logs(app.clone()).await.unwrap()
+                                ));
+                            };
+
+                            println!("CLI health check OK");
+
+                            #[cfg(windows)]
+                            {
+                                let job_state = app.state::<JobObjectState>();
+                                job_state.assign_pid(child.pid());
+                            }
 
-            let res = app.dialog()
-              .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
-              .title("Connection Failed")
-              .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
-              .blocking_show_with_result();
+                            app.state::<ServerState>().set_child(Some(child));
 
-            match res {
-                MessageDialogResult::Custom(name) if name == RETRY => {
-                    continue;
+                            Ok(ServerReadyData { url, password })
+                        }
+                        .map(move |res| {
+                            let _ = server_ready_tx.send(res);
+                        }),
+                    )
                 }
-                _ => {
-                    break;
+                ServerConnection::Existing { url } => {
+                    let _ = server_ready_tx.send(Ok(ServerReadyData {
+                        url: url.to_string(),
+                        password: None,
+                    }));
+                    None
                 }
+            };
+
+            if let Some(cli_health_check) = cli_health_check {
+                if sqlite_enabled {
+                    println!("Does sqlite file exist: {sqlite_exists}");
+                    if !sqlite_exists {
+                        println!(
+                            "Sqlite file not found at {}, waiting for it to be generated",
+                            opencode_db_path().expect("failed to get db path").display()
+                        );
+                        let _ = init_tx.send(InitStep::SqliteWaiting);
+
+                        while !sqlite_exists {
+                            sleep(Duration::from_secs(1)).await;
+                            sqlite_exists = sqlite_file_exists();
+                        }
+                    }
+                }
+
+                tokio::spawn(cli_health_check);
             }
+
+            let _ = server_ready_rx.await;
         }
+    })
+    .map_err(|_| ())
+    .shared();
+
+    let loading_window = if sqlite_enabled
+        && timeout(Duration::from_secs(1), loading_task.clone())
+            .await
+            .is_err()
+    {
+        println!("Loading task timed out, showing loading window");
+        let app = app.clone();
+        let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
+        sleep(Duration::from_secs(1)).await;
+        Some(loading_window)
+    } else {
+        MainWindow::create(&app).expect("Failed to create main window");
+
+        None
+    };
+
+    let _ = loading_task.await;
+
+    println!("Loading done, completing initialisation");
+
+    let _ = init_tx.send(InitStep::Done);
+
+    if loading_window.is_some() {
+        loading_window_complete.await;
+
+        println!("Loading window completed");
+    }
+
+    MainWindow::create(&app).expect("Failed to create main window");
+
+    if let Some(loading_window) = loading_window {
+        let _ = loading_window.close();
+    }
+}
+
+fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
+    #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
+    app.deep_link().register_all().ok();
+
+    // Initialize log state
+    app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
+
+    #[cfg(windows)]
+    app.manage(JobObjectState::new());
+
+    app.manage(InitState { current: init_rx });
+}
+
+fn spawn_cli_sync_task(app: AppHandle) {
+    tokio::spawn(async move {
+        if let Err(e) = sync_cli(app) {
+            eprintln!("Failed to sync CLI: {e}");
+        }
+    });
+}
+
+enum ServerConnection {
+    Existing {
+        url: String,
+    },
+    CLI {
+        url: String,
+        password: Option<String>,
+        child: CommandChild,
+        health_check: server::HealthCheck,
+    },
+}
+
+async fn setup_server_connection(app: AppHandle) -> ServerConnection {
+    let custom_url = get_saved_server_url(&app).await;
+
+    println!("Attempting server connection to custom url: {custom_url:?}");
+
+    if let Some(url) = custom_url
+        && server::check_health_or_ask_retry(&app, &url).await
+    {
+        println!("Connected to custom server: {}", url);
+        return ServerConnection::Existing { url: url.clone() };
     }
 
     let local_port = get_sidecar_port();
     let hostname = "127.0.0.1";
     let local_url = format!("http://{hostname}:{local_port}");
 
-    if !check_server_health(&local_url, None).await {
-        let password = uuid::Uuid::new_v4().to_string();
-
-        match spawn_local_server(app, hostname, local_port, &password).await {
-            Ok(child) => Ok((
-                Some(child),
-                ServerReadyData {
-                    url: local_url,
-                    password: Some(password),
-                },
-            )),
-            Err(err) => Err(err),
-        }
-    } else {
-        Ok((
-            None,
-            ServerReadyData {
-                url: local_url,
-                password: None,
-            },
-        ))
+    println!("Checking health of server '{}'", local_url);
+    if server::check_health(&local_url, None).await {
+        println!("Health check OK, using existing server");
+        return ServerConnection::Existing { url: local_url };
     }
-}
 
-async fn spawn_local_server(
-    app: &AppHandle,
-    hostname: &str,
-    port: u32,
-    password: &str,
-) -> Result<CommandChild, String> {
-    let child = spawn_sidecar(app, hostname, port, password);
-    let url = format!("http://{hostname}:{port}");
-
-    let timestamp = Instant::now();
-    loop {
-        if timestamp.elapsed() > Duration::from_secs(30) {
-            let _ = child.kill();
-            break Err(format!(
-                "Failed to spawn OpenCode Server. Logs:\n{}",
-                get_logs(app.clone()).await.unwrap()
-            ));
-        }
+    let password = uuid::Uuid::new_v4().to_string();
 
-        tokio::time::sleep(Duration::from_millis(10)).await;
+    println!("Spawning new local server");
+    let (child, health_check) =
+        server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
 
-        if check_server_health(&url, Some(password)).await {
-            println!("Server ready after {:?}", timestamp.elapsed());
-            break Ok(child);
-        }
+    ServerConnection::CLI {
+        url: local_url,
+        password: Some(password),
+        child,
+        health_check,
     }
 }
 
-fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) {
-    let (tx, mut rx) = mpsc::channel::<()>(1);
-
-    window.on_window_event(move |event| {
-        use tauri::WindowEvent;
-        if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
-            return;
-        }
-        let _ = tx.try_send(());
-    });
+fn get_sidecar_port() -> u32 {
+    option_env!("OPENCODE_PORT")
+        .map(|s| s.to_string())
+        .or_else(|| std::env::var("OPENCODE_PORT").ok())
+        .and_then(|port_str| port_str.parse().ok())
+        .unwrap_or_else(|| {
+            TcpListener::bind("127.0.0.1:0")
+                .expect("Failed to bind to find free port")
+                .local_addr()
+                .expect("Failed to get local address")
+                .port()
+        }) as u32
+}
 
-    tauri::async_runtime::spawn({
-        let app = app.clone();
+fn sqlite_file_exists() -> bool {
+    let Ok(path) = opencode_db_path() else {
+        return true;
+    };
 
-        async move {
-            let save = || {
-                let handle = app.clone();
-                let app = app.clone();
-                let _ = handle.run_on_main_thread(move || {
-                    println!("saving window state");
-                    let _ = app.save_window_state(window_state_flags());
-                });
-            };
+    path.exists()
+}
 
-            while rx.recv().await.is_some() {
-                tokio::time::sleep(Duration::from_millis(200)).await;
+fn opencode_db_path() -> Result<PathBuf, &'static str> {
+    let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
 
-                save();
-            }
+    let data_home = match xdg_data_home {
+        Some(v) => PathBuf::from(v),
+        None => {
+            let home = dirs::home_dir().ok_or("cannot determine home directory")?;
+            home.join(".local").join("share")
         }
+    };
+
+    Ok(data_home.join("opencode").join("opencode.db"))
+}
+
+// Creates a `once` listener for the specified event and returns a future that resolves
+// when the listener is fired.
+// Since the future creation and awaiting can be done separately, it's possible to create the listener
+// synchronously before doing something, then awaiting afterwards.
+fn event_once_fut<T: tauri_specta::Event + serde::de::DeserializeOwned>(
+    app: &AppHandle,
+) -> impl Future<Output = ()> {
+    let (tx, rx) = oneshot::channel();
+    T::once(app, |_| {
+        let _ = tx.send(());
     });
+    async {
+        let _ = rx.await;
+    }
 }

+ 195 - 0
packages/desktop/src-tauri/src/server.rs

@@ -0,0 +1,195 @@
+use std::time::{Duration, Instant};
+
+use tauri::AppHandle;
+use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
+use tauri_plugin_shell::process::CommandChild;
+use tauri_plugin_store::StoreExt;
+use tokio::task::JoinHandle;
+
+use crate::{
+    cli,
+    constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE},
+};
+
+#[tauri::command]
+#[specta::specta]
+pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
+    let store = app
+        .store(SETTINGS_STORE)
+        .map_err(|e| format!("Failed to open settings store: {}", e))?;
+
+    let value = store.get(DEFAULT_SERVER_URL_KEY);
+    match value {
+        Some(v) => Ok(v.as_str().map(String::from)),
+        None => Ok(None),
+    }
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
+    let store = app
+        .store(SETTINGS_STORE)
+        .map_err(|e| format!("Failed to open settings store: {}", e))?;
+
+    match url {
+        Some(u) => {
+            store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
+        }
+        None => {
+            store.delete(DEFAULT_SERVER_URL_KEY);
+        }
+    }
+
+    store
+        .save()
+        .map_err(|e| format!("Failed to save settings: {}", e))?;
+
+    Ok(())
+}
+
+pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
+    if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
+        println!("Using desktop-specific custom URL: {url}");
+        return Some(url);
+    }
+
+    if let Some(cli_config) = cli::get_config(app).await
+        && let Some(url) = get_server_url_from_config(&cli_config)
+    {
+        println!("Using custom server URL from config: {url}");
+        return Some(url);
+    }
+
+    None
+}
+
+pub fn spawn_local_server(
+    app: AppHandle,
+    hostname: String,
+    port: u32,
+    password: String,
+) -> (CommandChild, HealthCheck) {
+    let child = cli::serve(&app, &hostname, port, &password);
+
+    let health_check = HealthCheck(tokio::spawn(async move {
+        let url = format!("http://{hostname}:{port}");
+
+        let timestamp = Instant::now();
+        loop {
+            tokio::time::sleep(Duration::from_millis(100)).await;
+
+            if check_health(&url, Some(&password)).await {
+                println!("Server ready after {:?}", timestamp.elapsed());
+                break;
+            }
+        }
+    }));
+
+    (child, health_check)
+}
+
+pub struct HealthCheck(pub JoinHandle<()>);
+
+pub async fn check_health(url: &str, password: Option<&str>) -> bool {
+    let Ok(url) = reqwest::Url::parse(url) else {
+        return false;
+    };
+
+    let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
+
+    if url_is_localhost(&url) {
+        // Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
+        // excluding loopback. reqwest respects these by default, which can prevent the desktop
+        // app from reaching its own local sidecar server.
+        builder = builder.no_proxy();
+    };
+
+    let Ok(client) = builder.build() else {
+        return false;
+    };
+    let Ok(health_url) = url.join("/global/health") else {
+        return false;
+    };
+
+    let mut req = client.get(health_url);
+
+    if let Some(password) = password {
+        req = req.basic_auth("opencode", Some(password));
+    }
+
+    req.send()
+        .await
+        .map(|r| r.status().is_success())
+        .unwrap_or(false)
+}
+
+fn url_is_localhost(url: &reqwest::Url) -> bool {
+    url.host_str().is_some_and(|host| {
+        host.eq_ignore_ascii_case("localhost")
+            || host
+                .parse::<std::net::IpAddr>()
+                .is_ok_and(|ip| ip.is_loopback())
+    })
+}
+
+/// Converts a bind address hostname to a valid URL hostname for connection.
+/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
+/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
+fn normalize_hostname_for_url(hostname: &str) -> String {
+    // Wildcard bind addresses -> localhost equivalents
+    if hostname == "0.0.0.0" {
+        return "127.0.0.1".to_string();
+    }
+    if hostname == "::" {
+        return "[::1]".to_string();
+    }
+
+    // IPv6 addresses need brackets in URLs
+    if hostname.contains(':') && !hostname.starts_with('[') {
+        return format!("[{}]", hostname);
+    }
+
+    hostname.to_string()
+}
+
+fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
+    let server = config.server.as_ref()?;
+    let port = server.port?;
+    println!("server.port found in OC config: {port}");
+    let hostname = server
+        .hostname
+        .as_ref()
+        .map(|v| normalize_hostname_for_url(v))
+        .unwrap_or_else(|| "127.0.0.1".to_string());
+
+    Some(format!("http://{}:{}", hostname, port))
+}
+
+pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
+    println!("Checking health for {url}");
+    loop {
+        if check_health(url, None).await {
+            return true;
+        }
+
+        const RETRY: &str = "Retry";
+
+        let res = app.dialog()
+    		  .message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
+    		  .title("Connection Failed")
+    		  .buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
+    		  .blocking_show_with_result();
+
+        match res {
+            MessageDialogResult::Custom(name) if name == RETRY => {
+                continue;
+            }
+            _ => {
+                break;
+            }
+        }
+    }
+
+    false
+}

+ 140 - 0
packages/desktop/src-tauri/src/windows.rs

@@ -0,0 +1,140 @@
+use crate::constants::{UPDATER_ENABLED, window_state_flags};
+use std::{ops::Deref, time::Duration};
+use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
+use tauri_plugin_window_state::AppHandleExt;
+use tokio::sync::mpsc;
+
+pub struct MainWindow(WebviewWindow);
+
+impl Deref for MainWindow {
+    type Target = WebviewWindow;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl MainWindow {
+    pub const LABEL: &str = "main";
+
+    pub fn create(app: &AppHandle) -> Result<Self, tauri::Error> {
+        if let Some(window) = app.get_webview_window(Self::LABEL) {
+            return Ok(Self(window));
+        }
+
+        let window_builder = base_window_config(
+            WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
+            app,
+        )
+        .title("OpenCode")
+        .decorations(true)
+        .disable_drag_drop_handler()
+        .zoom_hotkeys_enabled(false)
+        .visible(true)
+        .maximized(true)
+        .initialization_script(format!(
+            r#"
+            window.__OPENCODE__ ??= {{}};
+            window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
+          "#
+        ));
+
+        let window = window_builder.build()?;
+
+        setup_window_state_listener(app, &window);
+
+        #[cfg(windows)]
+        {
+            use tauri_plugin_decorum::WebviewWindowExt;
+            let _ = window.create_overlay_titlebar();
+        }
+
+        Ok(Self(window))
+    }
+}
+
+fn setup_window_state_listener(app: &AppHandle, window: &WebviewWindow) {
+    let (tx, mut rx) = mpsc::channel::<()>(1);
+
+    window.on_window_event(move |event| {
+        use tauri::WindowEvent;
+        if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
+            return;
+        }
+        let _ = tx.try_send(());
+    });
+
+    tokio::spawn({
+        let app = app.clone();
+
+        async move {
+            let save = || {
+                let handle = app.clone();
+                let app = app.clone();
+                let _ = handle.run_on_main_thread(move || {
+                    let _ = app.save_window_state(window_state_flags());
+                });
+            };
+
+            while rx.recv().await.is_some() {
+                tokio::time::sleep(Duration::from_millis(200)).await;
+
+                save();
+            }
+        }
+    });
+}
+
+pub struct LoadingWindow(WebviewWindow);
+
+impl Deref for LoadingWindow {
+    type Target = WebviewWindow;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl LoadingWindow {
+    pub const LABEL: &str = "loading";
+
+    pub fn create(app: &AppHandle) -> Result<Self, tauri::Error> {
+        let window_builder = base_window_config(
+            WebviewWindowBuilder::new(app, Self::LABEL, tauri::WebviewUrl::App("/loading".into())),
+            app,
+        )
+        .center()
+        .resizable(false)
+        .inner_size(640.0, 480.0)
+        .visible(true);
+
+        Ok(Self(window_builder.build()?))
+    }
+}
+
+fn base_window_config<'a, R: Runtime, M: Manager<R>>(
+    window_builder: WebviewWindowBuilder<'a, R, M>,
+    _app: &AppHandle,
+) -> WebviewWindowBuilder<'a, R, M> {
+    let window_builder = window_builder.decorations(true);
+
+    #[cfg(windows)]
+    let window_builder = window_builder
+        // Some VPNs set a global/system proxy that WebView2 applies even for loopback
+        // connections, which breaks the app's localhost sidecar server.
+        // Note: when setting additional args, we must re-apply wry's default
+        // `--disable-features=...` flags.
+        .additional_browser_args(
+            "--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection",
+        )
+        .data_directory(_app.path().config_dir().expect("Failed to get config dir").join(_app.config().product_name.clone().unwrap()))
+        .decorations(false);
+
+    #[cfg(target_os = "macos")]
+    let window_builder = window_builder
+        .title_bar_style(tauri::TitleBarStyle::Overlay)
+        .hidden_title(true)
+        .traffic_light_position(tauri::LogicalPosition::new(12.0, 18.0));
+
+    window_builder
+}

+ 1 - 9
packages/desktop/src-tauri/tauri.conf.json

@@ -14,15 +14,7 @@
     "windows": [
       {
         "label": "main",
-        "create": false,
-        "title": "OpenCode",
-        "url": "/",
-        "decorations": true,
-        "dragDropEnabled": false,
-        "zoomHotkeysEnabled": false,
-        "titleBarStyle": "Overlay",
-        "hiddenTitle": true,
-        "trafficLightPosition": { "x": 12.0, "y": 18.0 }
+        "create": false
       }
     ],
     "withGlobalTauri": true,

+ 30 - 2
packages/desktop/src/bindings.ts

@@ -1,20 +1,48 @@
 // This file has been generated by Tauri Specta. Do not edit this file manually.
 
-import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core"
+import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core';
+import * as __TAURI_EVENT from "@tauri-apps/api/event";
 
 /** Commands */
 export const commands = {
 	killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
 	installCli: () => __TAURI_INVOKE<string>("install_cli"),
-	ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
+	awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
+
 	getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
 	setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
 	parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
 };
 
+/** Events */
+export const events = {
+	loadingWindowComplete: makeEvent<LoadingWindowComplete>("loading-window-complete"),
+};
+
 /* Types */
+export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" };
+
+export type LoadingWindowComplete = null;
+
 export type ServerReadyData = {
 		url: string,
 		password: string | null,
 	};
 
+/* Tauri Specta runtime */
+function makeEvent<T>(name: string) {
+    const base = {
+        listen: (cb: __TAURI_EVENT.EventCallback<T>) => __TAURI_EVENT.listen(name, cb),
+        once: (cb: __TAURI_EVENT.EventCallback<T>) => __TAURI_EVENT.once(name, cb),
+        emit: (payload: T) => __TAURI_EVENT.emit(name, payload) as unknown as (T extends null ? () => Promise<void> : (payload: T) => Promise<void>)
+    };
+
+    const fn = (target: import("@tauri-apps/api/webview").Webview | import("@tauri-apps/api/window").Window) => ({
+        listen: (cb: __TAURI_EVENT.EventCallback<T>) => target.listen(name, cb),
+        once: (cb: __TAURI_EVENT.EventCallback<T>) => target.once(name, cb),
+        emit: (payload: T) => target.emit(name, payload) as unknown as (T extends null ? () => Promise<void> : (payload: T) => Promise<void>)
+    });
+
+    return Object.assign(fn, base);
+}
+

+ 5 - 0
packages/desktop/src/entry.tsx

@@ -0,0 +1,5 @@
+if (location.pathname === "/loading") {
+  import("./loading")
+} else {
+  import("./")
+}

+ 3 - 3
packages/desktop/src/index.tsx

@@ -21,7 +21,8 @@ import { UPDATER_ENABLED } from "./updater"
 import { initI18n, t } from "./i18n"
 import pkg from "../package.json"
 import "./styles.css"
-import { commands } from "./bindings"
+import { commands, InitStep } from "./bindings"
+import { Channel } from "@tauri-apps/api/core"
 import { createMenu } from "./menu"
 
 const root = document.getElementById("root")
@@ -307,7 +308,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
       .catch(() => undefined)
   },
 
-  // @ts-expect-error
   fetch: (input, init) => {
     const pw = password()
 
@@ -400,7 +400,7 @@ type ServerReadyData = { url: string; password: string | null }
 
 // Gate component that waits for the server to be ready
 function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
-  const [serverData] = createResource(() => commands.ensureServerReady())
+  const [serverData] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
 
   const errorMessage = () => {
     const error = serverData.error

+ 77 - 0
packages/desktop/src/loading.tsx

@@ -0,0 +1,77 @@
+import { render } from "solid-js/web"
+import { MetaProvider } from "@solidjs/meta"
+import "@opencode-ai/app/index.css"
+import { Font } from "@opencode-ai/ui/font"
+import { Splash } from "@opencode-ai/ui/logo"
+import "./styles.css"
+import { createSignal, Match, onMount } from "solid-js"
+import { commands, events, InitStep } from "./bindings"
+import { Channel } from "@tauri-apps/api/core"
+import { Switch } from "solid-js"
+
+const root = document.getElementById("root")!
+
+render(() => {
+  let splash!: SVGSVGElement
+  const [state, setState] = createSignal<InitStep | null>(null)
+
+  const channel = new Channel<InitStep>()
+  channel.onmessage = (e) => setState(e)
+  commands.awaitInitialization(channel as any).then(() => {
+    const currentOpacity = getComputedStyle(splash).opacity
+
+    splash.style.animation = "none"
+    splash.style.animationPlayState = "paused"
+    splash.style.opacity = currentOpacity
+
+    requestAnimationFrame(() => {
+      splash.style.transition = "opacity 0.3s ease"
+      requestAnimationFrame(() => {
+        splash.style.opacity = "1"
+      })
+    })
+  })
+
+  return (
+    <MetaProvider>
+      <div class="w-screen h-screen bg-background-base flex items-center justify-center">
+        <Font />
+        <div class="flex flex-col items-center gap-10">
+          <Splash ref={splash} class="h-25 animate-[pulse-splash_2s_ease-in-out_infinite]" />
+          <span class="text-text-base">
+            <Switch fallback="Just a moment...">
+              <Match when={state()?.phase === "done"}>
+                {(_) => {
+                  onMount(() => {
+                    setTimeout(() => events.loadingWindowComplete.emit(null), 1000)
+                  })
+
+                  return "All done"
+                }}
+              </Match>
+              <Match when={state()?.phase === "sqlite_waiting"}>
+                {(_) => {
+                  const textItems = [
+                    "Just a moment...",
+                    "Migrating your database",
+                    "This could take a couple of minutes",
+                  ]
+                  const [textIndex, setTextIndex] = createSignal(0)
+
+                  onMount(async () => {
+                    await new Promise((res) => setTimeout(res, 3000))
+                    setTextIndex(1)
+                    await new Promise((res) => setTimeout(res, 6000))
+                    setTextIndex(2)
+                  })
+
+                  return <>{textItems[textIndex()]}</>
+                }}
+              </Match>
+            </Switch>
+          </span>
+        </div>
+      </div>
+    </MetaProvider>
+  )
+}, root)

+ 10 - 0
packages/desktop/src/styles.css

@@ -5,3 +5,13 @@ button#decorum-tb-close,
 div[data-tauri-decorum-tb] {
   height: calc(var(--spacing) * 10) !important;
 }
+
+@keyframes pulse-splash {
+  0%,
+  100% {
+    opacity: 0.1;
+  }
+  50% {
+    opacity: 0.3;
+  }
+}

+ 2 - 1
packages/desktop/tsconfig.json

@@ -14,7 +14,8 @@
     "isolatedModules": true,
     "noEmit": true,
     "emitDeclarationOnly": false,
-    "outDir": "node_modules/.ts-dist"
+    "outDir": "node_modules/.ts-dist",
+    "types": ["vite/client"]
   },
   "references": [{ "path": "../app" }],
   "include": ["src", "package.json"]

+ 4 - 1
packages/ui/src/components/logo.tsx

@@ -1,3 +1,5 @@
+import { ComponentProps } from "solid-js"
+
 export const Mark = (props: { class?: string }) => {
   return (
     <svg
@@ -13,9 +15,10 @@ export const Mark = (props: { class?: string }) => {
   )
 }
 
-export const Splash = (props: { class?: string }) => {
+export const Splash = (props: Pick<ComponentProps<"svg">, "ref" | "class">) => {
   return (
     <svg
+      ref={props.ref}
       data-component="logo-splash"
       classList={{ [props.class ?? ""]: !!props.class }}
       viewBox="0 0 80 100"