From 74a1f9aff7edd9e28bc7007de01c7a751b20b6a1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 12 Feb 2026 17:23:51 +0800 Subject: [PATCH 1/2] desktop: good logic but bad ui --- packages/desktop/src-tauri/Cargo.lock | 13 ++ packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/src/cli.rs | 163 ++++++++++++++++------- packages/desktop/src-tauri/src/lib.rs | 70 ++++++---- packages/desktop/src-tauri/src/server.rs | 8 +- packages/desktop/src/bindings.ts | 3 + packages/desktop/src/loading.tsx | 18 ++- 7 files changed, 196 insertions(+), 80 deletions(-) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 8ce97b2b724d..a2bb2532af7e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -3117,6 +3117,7 @@ dependencies = [ "tauri-plugin-window-state", "tauri-specta", "tokio", + "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -5631,6 +5632,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.17" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index e9ba55b039a5..67efd8d8c9b5 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" chrono = "0.4" +tokio-stream = { version = "0.1.18", features = ["sync"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index b9e1ed4bd504..dade1a28186e 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -1,35 +1,46 @@ +use futures::{FutureExt, Stream, StreamExt, future}; use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_plugin_shell::{ ShellExt, - process::{Command, CommandChild, CommandEvent, TerminatedPayload}, + process::{CommandChild, CommandEvent, TerminatedPayload}, }; use tauri_plugin_store::StoreExt; +use tauri_specta::Event; use tokio::sync::oneshot; +use tracing::Instrument; use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY}; const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] pub struct ServerConfig { pub hostname: Option, pub port: Option, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] pub struct Config { pub server: Option, } pub async fn get_config(app: &AppHandle) -> Option { - create_command(app, "debug config", &[]) - .output() + let (events, _) = spawn_command(app, "debug config", &[]).ok()?; + + events + .fold(String::new(), async |mut config_str, event| { + if let CommandEvent::Stdout(stdout) = event + && let Ok(s) = str::from_utf8(&stdout) + { + config_str += s + } + + config_str + }) + .map(|v| serde_json::from_str::(&v)) .await - .inspect_err(|e| tracing::warn!("Failed to read OC config: {e}")) .ok() - .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok()) - .and_then(|s| serde_json::from_str::(&s).ok()) } fn get_cli_install_path() -> Option { @@ -175,7 +186,11 @@ fn shell_escape(input: &str) -> String { escaped } -pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command { +pub fn spawn_command( + app: &tauri::AppHandle, + args: &str, + extra_env: &[(&str, String)], +) -> Result<(impl Stream + 'static, CommandChild), tauri_plugin_shell::Error> { let state_dir = app .path() .resolve("", BaseDirectory::AppLocalData) @@ -202,7 +217,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St .map(|(key, value)| (key.to_string(), value.clone())), ); - if cfg!(windows) { + let cmd = if cfg!(windows) { if is_wsl_enabled(app) { tracing::info!("WSL is enabled, spawning CLI server in WSL"); let version = app.package_info().version.to_string(); @@ -234,10 +249,9 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args)); - return app - .shell() + app.shell() .command("wsl") - .args(["-e", "bash", "-lc", &script.join("\n")]); + .args(["-e", "bash", "-lc", &script.join("\n")]) } else { let mut cmd = app .shell() @@ -249,7 +263,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St cmd = cmd.env(key, value); } - return cmd; + cmd } } else { let sidecar = get_sidecar_path(app); @@ -268,7 +282,13 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St } cmd - } + }; + + let (rx, child) = cmd.spawn()?; + let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx); + let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream); + + Ok((event_stream, child)) } pub fn serve( @@ -286,45 +306,96 @@ pub fn serve( ("OPENCODE_SERVER_PASSWORD", password.to_string()), ]; - let (mut rx, child) = create_command( + let (events, child) = spawn_command( app, format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(), &envs, ) - .spawn() .expect("Failed to spawn opencode"); - tokio::spawn(async move { - let mut exit_tx = Some(exit_tx); - while let Some(event) = rx.recv().await { - match event { - CommandEvent::Stdout(line_bytes) => { - let line = String::from_utf8_lossy(&line_bytes); - tracing::info!(target: "sidecar", "{line}"); - } - CommandEvent::Stderr(line_bytes) => { - let line = String::from_utf8_lossy(&line_bytes); - tracing::info!(target: "sidecar", "{line}"); - } - CommandEvent::Error(err) => { - tracing::error!(target: "sidecar", "{err}"); - } - CommandEvent::Terminated(payload) => { - tracing::info!( - target: "sidecar", - code = ?payload.code, - signal = ?payload.signal, - "Sidecar terminated" - ); - - if let Some(tx) = exit_tx.take() { - let _ = tx.send(payload); + let mut exit_tx = Some(exit_tx); + tokio::spawn( + events + .for_each(move |event| { + match event { + CommandEvent::Stdout(line_bytes) => { + let line = String::from_utf8_lossy(&line_bytes); + tracing::info!("{line}"); + } + CommandEvent::Stderr(line_bytes) => { + let line = String::from_utf8_lossy(&line_bytes); + tracing::info!("{line}"); } + CommandEvent::Error(err) => { + tracing::error!("{err}"); + } + CommandEvent::Terminated(payload) => { + tracing::info!( + code = ?payload.code, + signal = ?payload.signal, + "Sidecar terminated" + ); + + if let Some(tx) = exit_tx.take() { + let _ = tx.send(payload); + } + } + _ => {} } - _ => {} - } - } - }); + + future::ready(()) + }) + .instrument(tracing::info_span!("sidecar")), + ); (child, exit_rx) } + +pub mod sqlite_migration { + use super::*; + + #[derive( + tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type, + )] + #[serde(tag = "type", content = "value")] + pub enum SqliteMigrationProgress { + InProgress(u8), + Done, + } + + pub(super) fn logs_middleware( + app: AppHandle, + stream: impl Stream, + ) -> impl Stream { + let app = app.clone(); + let mut done = false; + + stream.filter_map(move |event| { + if done { + return future::ready(Some(event)); + } + + future::ready(match &event { + CommandEvent::Stdout(stdout) => { + let Ok(s) = str::from_utf8(stdout) else { + return future::ready(None); + }; + + if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) { + if let Ok(progress) = s.parse::() { + let _ = SqliteMigrationProgress::InProgress(progress).emit(&app); + } else if s == "done" { + done = true; + let _ = SqliteMigrationProgress::Done.emit(&app); + } + + None + } else { + Some(event) + } + } + _ => Some(event), + }) + }) + } +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index e0187a76bc35..d946fecb2e6d 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -24,16 +24,17 @@ use std::{ sync::{Arc, Mutex}, time::Duration, }; -use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel}; +use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_shell::process::CommandChild; +use tauri_specta::Event; use tokio::{ sync::{oneshot, watch}, time::{sleep, timeout}, }; -use crate::cli::sync_cli; +use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli}; use crate::constants::*; use crate::server::get_saved_server_url; use crate::windows::{LoadingWindow, MainWindow}; @@ -122,8 +123,8 @@ async fn await_initialization( let mut rx = init_state.current.clone(); let events = async { - let e = (*rx.borrow()).clone(); - let _ = events.send(e).unwrap(); + let e = *rx.borrow(); + let _ = events.send(e); while rx.changed().await.is_ok() { let step = *rx.borrow_and_update(); @@ -517,7 +518,10 @@ fn make_specta_builder() -> tauri_specta::Builder { wsl_path, resolve_app_path ]) - .events(tauri_specta::collect_events![LoadingWindowComplete]) + .events(tauri_specta::collect_events![ + LoadingWindowComplete, + SqliteMigrationProgress + ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) } @@ -557,16 +561,44 @@ async fn initialize(app: AppHandle) { tracing::info!("Main and loading windows created"); let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some(); + let sqlite_done_rx = (sqlite_enabled && !sqlite_file_exists()).then(|| { + let (sqlite_done_tx, sqlite_done_rx) = oneshot::channel::<()>(); + tracing::info!( + path = %opencode_db_path().expect("failed to get db path").display(), + "Sqlite file not found, waiting for it to be generated" + ); + + let (done_tx, done_rx) = oneshot::channel::<()>(); + let done_tx = Arc::new(Mutex::new(Some(done_tx))); - let loading_task = tokio::spawn({ let init_tx = init_tx.clone(); + let id = SqliteMigrationProgress::listen(&app, move |e| { + let _ = init_tx.send(InitStep::SqliteWaiting); + + if matches!(e.payload, SqliteMigrationProgress::Done) + && let Some(done_tx) = done_tx.lock().unwrap().take() + { + let _ = done_tx.send(()); + } + }); + let app = app.clone(); + tokio::spawn(done_rx.map(async move |_| { + let _ = sqlite_done_tx.send(()); - async move { - let mut sqlite_exists = sqlite_file_exists(); + app.unlisten(id); + })); + + sqlite_done_rx + }); + let loading_task = tokio::spawn({ + let app = app.clone(); + + async move { tracing::info!("Setting up server connection"); let server_connection = setup_server_connection(app.clone()).await; + tracing::info!("Server connection setup"); // we delay spawning this future so that the timeout is created lazily let cli_health_check = match server_connection { @@ -622,23 +654,12 @@ async fn initialize(app: AppHandle) { } }; + tracing::info!("server connection started"); + if let Some(cli_health_check) = cli_health_check { - if sqlite_enabled { - tracing::debug!(sqlite_exists, "Checking sqlite file existence"); - if !sqlite_exists { - tracing::info!( - path = %opencode_db_path().expect("failed to get db path").display(), - "Sqlite file not found, waiting for it to be generated" - ); - let _ = init_tx.send(InitStep::SqliteWaiting); - - while !sqlite_exists { - sleep(Duration::from_secs(1)).await; - sqlite_exists = sqlite_file_exists(); - } - } + if let Some(sqlite_done_rx) = sqlite_done_rx { + let _ = sqlite_done_rx.await; } - tokio::spawn(cli_health_check); } @@ -654,11 +675,11 @@ async fn initialize(app: AppHandle) { .is_err() { tracing::debug!("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 { + tracing::debug!("Showing main window without loading window"); MainWindow::create(&app).expect("Failed to create main window"); None @@ -667,7 +688,6 @@ async fn initialize(app: AppHandle) { let _ = loading_task.await; tracing::info!("Loading done, completing initialisation"); - let _ = init_tx.send(InitStep::Done); if loading_window.is_some() { diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 81e0595af714..6dcf0e5860ae 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -11,17 +11,11 @@ use crate::{ constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY}, }; -#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug, Default)] pub struct WslConfig { pub enabled: bool, } -impl Default for WslConfig { - fn default() -> Self { - Self { enabled: false } - } -} - #[tauri::command] #[specta::specta] pub fn get_default_server_url(app: AppHandle) -> Result, String> { diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 3d588a17155f..67816ad414ec 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -23,6 +23,7 @@ export const commands = { /** Events */ export const events = { loadingWindowComplete: makeEvent("loading-window-complete"), + sqliteMigrationProgress: makeEvent("sqlite-migration-progress"), }; /* Types */ @@ -37,6 +38,8 @@ export type ServerReadyData = { password: string | null, }; +export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }; + export type WslConfig = { enabled: boolean, }; diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx index 752cde893b7c..a1d537a00eb3 100644 --- a/packages/desktop/src/loading.tsx +++ b/packages/desktop/src/loading.tsx @@ -4,7 +4,7 @@ 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 { createSignal, Match, onCleanup, onMount } from "solid-js" import { commands, events, InitStep } from "./bindings" import { Channel } from "@tauri-apps/api/core" import { Switch } from "solid-js" @@ -57,15 +57,29 @@ render(() => { "This could take a couple of minutes", ] const [textIndex, setTextIndex] = createSignal(0) + const [progress, setProgress] = createSignal(0) onMount(async () => { + const listener = events.sqliteMigrationProgress.listen((e) => { + if (e.payload.type === "InProgress") setProgress(e.payload.value) + }) + onCleanup(() => listener.then((c) => c())) + await new Promise((res) => setTimeout(res, 3000)) setTextIndex(1) await new Promise((res) => setTimeout(res, 6000)) setTextIndex(2) }) - return <>{textItems[textIndex()]} + return ( +
+ {textItems[textIndex()]} + Progress: {progress()}% +
+
+
+
+ ) }} From 2ba2ac376702e3b001bedba4342a05402ed74c56 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 12 Feb 2026 17:38:16 +0800 Subject: [PATCH 2/2] add migration handling comment --- packages/desktop/src-tauri/src/lib.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index d946fecb2e6d..bec72c04fabc 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -560,9 +560,14 @@ async fn initialize(app: AppHandle) { tracing::info!("Main and loading windows created"); + // SQLite migration handling: + // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it + // First, we spawn a task that listens for SqliteMigrationProgress events that can + // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor. + // Then in the loading task, we wait for sqlite migration to complete before + // starting our health check against the server, otherwise long migrations could result in a timeout. let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some(); - let sqlite_done_rx = (sqlite_enabled && !sqlite_file_exists()).then(|| { - let (sqlite_done_tx, sqlite_done_rx) = oneshot::channel::<()>(); + let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| { tracing::info!( path = %opencode_db_path().expect("failed to get db path").display(), "Sqlite file not found, waiting for it to be generated" @@ -584,12 +589,8 @@ async fn initialize(app: AppHandle) { let app = app.clone(); tokio::spawn(done_rx.map(async move |_| { - let _ = sqlite_done_tx.send(()); - app.unlisten(id); - })); - - sqlite_done_rx + })) }); let loading_task = tokio::spawn({ @@ -657,7 +658,7 @@ async fn initialize(app: AppHandle) { tracing::info!("server connection started"); if let Some(cli_health_check) = cli_health_check { - if let Some(sqlite_done_rx) = sqlite_done_rx { + if let Some(sqlite_done_rx) = sqlite_done { let _ = sqlite_done_rx.await; } tokio::spawn(cli_health_check);