diff options
22 files changed, 849 insertions, 430 deletions
@@ -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", diff --git a/packages/app/package.json b/packages/app/package.json index abef97e81..bcdcece3a 100644 --- a/packages/app/package.json +++ b/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", diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 2e22dc633..4a43a855c 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/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` }} /> diff --git a/packages/desktop/index.html b/packages/desktop/index.html index 6a81ef4a5..ce2775a70 100644 --- a/packages/desktop/index.html +++ b/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> diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2ef89fd4b..36bbe7bc2 100644 --- a/packages/desktop/package.json +++ b/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", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 14030218e..537a7c9c5 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/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", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 2efa484e8..62fc7979c 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/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" } diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs index d860e1e6a..85c91f55a 100644 --- a/packages/desktop/src-tauri/build.rs +++ b/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() } diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index e895cdf78..2d38d49a9 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/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", diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 16e4bfec9..98c38677b 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/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 +} diff --git a/packages/desktop/src-tauri/src/constants.rs b/packages/desktop/src-tauri/src/constants.rs new file mode 100644 index 000000000..ac3e1d02a --- /dev/null +++ b/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 +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index e1cb89cd7..135f7b072 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/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; + } } diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs new file mode 100644 index 000000000..2a78411a4 --- /dev/null +++ b/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 +} diff --git a/packages/desktop/src-tauri/src/windows.rs b/packages/desktop/src-tauri/src/windows.rs new file mode 100644 index 000000000..cf3e399e3 --- /dev/null +++ b/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 +} diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index b631e2876..d5ca15b8a 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/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, diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 64d0c113d..46dcb7f44 100644 --- a/packages/desktop/src/bindings.ts +++ b/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); +} + diff --git a/packages/desktop/src/entry.tsx b/packages/desktop/src/entry.tsx new file mode 100644 index 000000000..b1c9f13f9 --- /dev/null +++ b/packages/desktop/src/entry.tsx @@ -0,0 +1,5 @@ +if (location.pathname === "/loading") { + import("./loading") +} else { + import("./") +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 66e86bf52..2b74bbabd 100644 --- a/packages/desktop/src/index.tsx +++ b/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 diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx new file mode 100644 index 000000000..752cde893 --- /dev/null +++ b/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) diff --git a/packages/desktop/src/styles.css b/packages/desktop/src/styles.css index 143a21312..941fb95d7 100644 --- a/packages/desktop/src/styles.css +++ b/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; + } +} diff --git a/packages/desktop/tsconfig.json b/packages/desktop/tsconfig.json index 62cbe4ee4..47e63cc87 100644 --- a/packages/desktop/tsconfig.json +++ b/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"] diff --git a/packages/ui/src/components/logo.tsx b/packages/ui/src/components/logo.tsx index 26f312bda..20c2f3fbe 100644 --- a/packages/ui/src/components/logo.tsx +++ b/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" |
