desktop: add loading window and restructure rust (#12176)

This commit is contained in:
Brendan Allan
2026-02-06 23:03:07 +08:00
committed by GitHub
parent 5a1bf3a968
commit b7ad8e459c
22 changed files with 858 additions and 439 deletions

View File

@@ -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",

View File

@@ -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" }

View File

@@ -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()
}

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 tauri_plugin_shell::process::CommandChild;
use tokio::{
sync::{oneshot, watch},
time::{sleep, timeout},
};
use crate::window_customizer::PinchZoomDisablePlugin;
use crate::cli::sync_cli;
use crate::constants::*;
use crate::server::get_saved_server_url;
use crate::windows::{LoadingWindow, MainWindow};
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
fn window_state_flags() -> StateFlags {
StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE
}
#[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())?
}
async fn await_initialization(
state: State<'_, ServerState>,
init_state: State<'_, InitState>,
events: Channel<InitStep>,
) -> Result<ServerReadyData, String> {
let mut rx = init_state.current.clone();
#[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 events = async {
let e = (*rx.borrow()).clone();
let _ = events.send(e).unwrap();
let value = store.get(DEFAULT_SERVER_URL_KEY);
match value {
Some(v) => Ok(v.as_str().map(String::from)),
None => Ok(None),
}
}
while rx.changed().await.is_ok() {
let step = *rx.borrow_and_update();
#[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))?;
let _ = events.send(step);
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(())
}
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
}
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());
setup_app(&app, init_rx);
spawn_cli_sync_task(app.clone());
Some(format!("http://{}:{}", hostname, port))
}
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()));
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(),
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
println!("Main and loading windows created");
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
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());
}
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData { url, password })
}
.map(move |res| {
let _ = server_ready_tx.send(res);
}),
)
}
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);
}
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;
}
}
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();
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 };
}
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,
},
))
let password = uuid::Uuid::new_v4().to_string();
println!("Spawning new local server");
let (child, health_check) =
server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
ServerConnection::CLI {
url: local_url,
password: Some(password),
child,
health_check,
}
}
async fn spawn_local_server(
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
}
fn sqlite_file_exists() -> bool {
let Ok(path) = opencode_db_path() else {
return true;
};
path.exists()
}
fn opencode_db_path() -> Result<PathBuf, &'static str> {
let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
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,
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()
));
}
tokio::time::sleep(Duration::from_millis(10)).await;
if check_server_health(&url, Some(password)).await {
println!("Server ready after {:?}", timestamp.elapsed());
break Ok(child);
}
) -> impl Future<Output = ()> {
let (tx, rx) = oneshot::channel();
T::once(app, |_| {
let _ = tx.send(());
});
async {
let _ = rx.await;
}
}
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(());
});
tauri::async_runtime::spawn({
let app = app.clone();
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());
});
};
while rx.recv().await.is_some() {
tokio::time::sleep(Duration::from_millis(200)).await;
save();
}
}
});
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,