summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-02-06 23:03:07 +0800
committerGitHub <[email protected]>2026-02-06 23:03:07 +0800
commitb7ad8e459cae11ca74d976f6bd2e02559912716a (patch)
tree6a14053372260d23834349a6fae4023722cdfbbb
parent5a1bf3a9687bd08ffb9c3cab3ffd4ed2346c19da (diff)
downloadopencode-b7ad8e459cae11ca74d976f6bd2e02559912716a.tar.gz
opencode-b7ad8e459cae11ca74d976f6bd2e02559912716a.zip
desktop: add loading window and restructure rust (#12176)
-rw-r--r--bun.lock1
-rw-r--r--packages/app/package.json3
-rw-r--r--packages/app/src/components/titlebar.tsx2
-rw-r--r--packages/desktop/index.html2
-rw-r--r--packages/desktop/package.json3
-rw-r--r--packages/desktop/src-tauri/Cargo.lock9
-rw-r--r--packages/desktop/src-tauri/Cargo.toml5
-rw-r--r--packages/desktop/src-tauri/build.rs7
-rw-r--r--packages/desktop/src-tauri/capabilities/default.json2
-rw-r--r--packages/desktop/src-tauri/src/cli.rs59
-rw-r--r--packages/desktop/src-tauri/src/constants.rs10
-rw-r--r--packages/desktop/src-tauri/src/lib.rs693
-rw-r--r--packages/desktop/src-tauri/src/server.rs195
-rw-r--r--packages/desktop/src-tauri/src/windows.rs140
-rw-r--r--packages/desktop/src-tauri/tauri.conf.json10
-rw-r--r--packages/desktop/src/bindings.ts32
-rw-r--r--packages/desktop/src/entry.tsx5
-rw-r--r--packages/desktop/src/index.tsx6
-rw-r--r--packages/desktop/src/loading.tsx77
-rw-r--r--packages/desktop/src/styles.css10
-rw-r--r--packages/desktop/tsconfig.json3
-rw-r--r--packages/ui/src/components/logo.tsx5
22 files changed, 849 insertions, 430 deletions
diff --git a/bun.lock b/bun.lock
index dd9790f4b..a3534d259 100644
--- a/bun.lock
+++ b/bun.lock
@@ -188,6 +188,7 @@
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/storage": "catalog:",
+ "@solidjs/meta": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "~2",
"@tauri-apps/plugin-dialog": "~2",
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"