summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-02-12 17:44:06 +0800
committerGitHub <[email protected]>2026-02-12 09:44:06 +0000
commit1413d77b1ff36ed030c179b3bc59dc6a9b9679b3 (patch)
tree9d9d59cd34d1bc4a1c091200cdbad841123ec327
parent624dd94b5dd8dca03aa3b246312f8b54fd3331f1 (diff)
downloadopencode-1413d77b1ff36ed030c179b3bc59dc6a9b9679b3.tar.gz
opencode-1413d77b1ff36ed030c179b3bc59dc6a9b9679b3.zip
desktop: sqlite migration progress bar (#13294)
-rw-r--r--packages/desktop/src-tauri/Cargo.lock13
-rw-r--r--packages/desktop/src-tauri/Cargo.toml1
-rw-r--r--packages/desktop/src-tauri/src/cli.rs163
-rw-r--r--packages/desktop/src-tauri/src/lib.rs71
-rw-r--r--packages/desktop/src-tauri/src/server.rs8
-rw-r--r--packages/desktop/src/bindings.ts3
-rw-r--r--packages/desktop/src/loading.tsx18
7 files changed, 197 insertions, 80 deletions
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index 8ce97b2b7..a2bb2532a 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",
@@ -5632,6 +5633,18 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index e9ba55b03..67efd8d8c 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 b9e1ed4bd..dade1a281 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<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),
+ })
+ })
+ }
+}
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index e0187a76b..bec72c04f 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<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() {
diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs
index 81e0595af..6dcf0e586 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<Option<String>, String> {
diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts
index 3d588a171..67816ad41 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<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,
};
diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx
index 752cde893..a1d537a00 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 (
+ <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>