Просмотр исходного кода

desktop: sqlite migration progress bar (#13294)

Brendan Allan 1 неделя назад
Родитель
Сommit
1413d77b1f

+ 13 - 0
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"

+ 1 - 0
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"

+ 117 - 46
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<String>,
     pub port: Option<u32>,
 }
 
-#[derive(serde::Deserialize)]
+#[derive(serde::Deserialize, Debug)]
 pub struct Config {
     pub server: Option<ServerConfig>,
 }
 
 pub async fn get_config(app: &AppHandle) -> Option<Config> {
-    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::<Config>(&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::<Config>(&s).ok())
 }
 
 fn get_cli_install_path() -> Option<std::path::PathBuf> {
@@ -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<Item = CommandEvent> + '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<Item = CommandEvent>,
+    ) -> impl Stream<Item = CommandEvent> {
+        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::<u8>() {
+                            let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
+                        } else if s == "done" {
+                            done = true;
+                            let _ = SqliteMigrationProgress::Done.emit(&app);
+                        }
+
+                        None
+                    } else {
+                        Some(event)
+                    }
+                }
+                _ => Some(event),
+            })
+        })
+    }
+}

+ 46 - 25
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<tauri::Wry> {
             wsl_path,
             resolve_app_path
         ])
-        .events(tauri_specta::collect_events![LoadingWindowComplete])
+        .events(tauri_specta::collect_events![
+            LoadingWindowComplete,
+            SqliteMigrationProgress
+        ])
         .error_handling(tauri_specta::ErrorHandlingMode::Throw)
 }
 
@@ -556,17 +560,46 @@ 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 = (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"
+        );
+
+        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 |_| {
+            app.unlisten(id);
+        }))
+    });
 
-        async move {
-            let mut sqlite_exists = sqlite_file_exists();
+    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 +655,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 {
+                    let _ = sqlite_done_rx.await;
                 }
-
                 tokio::spawn(cli_health_check);
             }
 
@@ -654,11 +676,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 +689,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() {

+ 1 - 7
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<Option<String>, String> {

+ 3 - 0
packages/desktop/src/bindings.ts

@@ -23,6 +23,7 @@ export const commands = {
 /** Events */
 export const events = {
 	loadingWindowComplete: makeEvent<LoadingWindowComplete>("loading-window-complete"),
+	sqliteMigrationProgress: makeEvent<SqliteMigrationProgress>("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,
 	};

+ 16 - 2
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 (
+                    <div class="flex flex-col items-center gap-1">
+                      <span>{textItems[textIndex()]}</span>
+                      <span>Progress: {progress()}%</span>
+                      <div class="h-2 w-48 rounded-full border border-white relative">
+                        <div class="bg-[#fff] h-full absolute left-0 inset-y-0" style={{ width: `${progress()}%` }} />
+                      </div>
+                    </div>
+                  )
                 }}
               </Match>
             </Switch>