summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/src/app.tsx13
-rw-r--r--packages/desktop/src-tauri/Cargo.toml2
-rw-r--r--packages/desktop/src-tauri/src/cli.rs56
-rw-r--r--packages/desktop/src-tauri/src/lib.rs286
-rw-r--r--packages/desktop/src/index.tsx21
5 files changed, 180 insertions, 198 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 91e4ae39b..5dfc59a4e 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -33,7 +33,7 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
declare global {
interface Window {
- __OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
+ __OPENCODE__?: { updaterEnabled?: boolean; }
}
}
@@ -65,19 +65,18 @@ function ServerKey(props: ParentProps) {
)
}
-export function AppInterface() {
- const defaultServerUrl = iife(() => {
+export function AppInterface(props: { defaultUrl?: string }) {
+ const defaultServerUrl = () => {
+ if (props.defaultUrl) return props.defaultUrl;
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
- if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl
- if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
- })
+ };
return (
- <ServerProvider defaultUrl={defaultServerUrl}>
+ <ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index c8eb0846c..8033d4f14 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -3,7 +3,7 @@ name = "opencode-desktop"
version = "0.0.0"
description = "The open source AI coding agent"
authors = ["Anomaly Innovations"]
-edition = "2021"
+edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs
index 8b76d1a7f..87ecf4997 100644
--- a/packages/desktop/src-tauri/src/cli.rs
+++ b/packages/desktop/src-tauri/src/cli.rs
@@ -1,8 +1,30 @@
-use tauri::Manager;
+use tauri::{path::BaseDirectory, AppHandle, Manager};
+use tauri_plugin_shell::{process::Command, ShellExt};
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
+#[derive(serde::Deserialize)]
+pub struct ServerConfig {
+ pub hostname: Option<String>,
+ pub port: Option<u32>,
+}
+
+#[derive(serde::Deserialize)]
+pub struct Config {
+ pub server: Option<ServerConfig>,
+}
+
+pub async fn get_config(app: &AppHandle) -> Option<Config> {
+ create_command(app, "debug config")
+ .output()
+ .await
+ .inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
+ .ok()
+ .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
+ .and_then(|s| serde_json::from_str::<Config>(&s).ok())
+}
+
fn get_cli_install_path() -> Option<std::path::PathBuf> {
std::env::var("HOME").ok().map(|home| {
std::path::PathBuf::from(home)
@@ -117,3 +139,35 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
+
+fn get_user_shell() -> String {
+ std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
+}
+
+pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
+ let state_dir = app
+ .path()
+ .resolve("", BaseDirectory::AppLocalData)
+ .expect("Failed to resolve app local data dir");
+
+ #[cfg(target_os = "windows")]
+ return app
+ .shell()
+ .sidecar("opencode-cli")
+ .unwrap()
+ .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
+ .env("OPENCODE_CLIENT", "desktop")
+ .env("XDG_STATE_HOME", &state_dir);
+
+ #[cfg(not(target_os = "windows"))]
+ return {
+ let sidecar = get_sidecar_path(app);
+ let shell = get_user_shell();
+ app.shell()
+ .command(&shell)
+ .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
+ .env("OPENCODE_CLIENT", "desktop")
+ .env("XDG_STATE_HOME", &state_dir)
+ .args(["-il", "-c", &format!("\"{}\" {}", sidecar.display(), args)])
+ };
+}
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index 5ed03fc66..b479ed0b6 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -1,23 +1,18 @@
mod cli;
mod window_customizer;
-use cli::{get_sidecar_path, install_cli, sync_cli};
+use cli::{install_cli, sync_cli};
use futures::FutureExt;
use std::{
collections::VecDeque,
- net::{SocketAddr, TcpListener},
+ net::TcpListener,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
-use tauri::{
- path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
- WebviewWindow,
-};
+use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
-use tauri_plugin_shell::ShellExt;
use tauri_plugin_store::StoreExt;
-use tokio::net::TcpSocket;
use crate::window_customizer::PinchZoomDisablePlugin;
@@ -27,13 +22,13 @@ const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
- status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
+ status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
}
impl ServerState {
pub fn new(
child: Option<CommandChild>,
- status: tokio::sync::oneshot::Receiver<Result<(), String>>,
+ status: tokio::sync::oneshot::Receiver<Result<String, String>>,
) -> Self {
Self {
child: Arc::new(Mutex::new(child)),
@@ -85,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
-async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
+async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
state
.status
.clone()
@@ -94,7 +89,7 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
}
#[tauri::command]
-async fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
+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))?;
@@ -142,49 +137,16 @@ fn get_sidecar_port() -> u32 {
}) as u32
}
-fn get_user_shell() -> String {
- std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
-}
-
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
- let state_dir = app
- .path()
- .resolve("", BaseDirectory::AppLocalData)
- .expect("Failed to resolve app local data dir");
-
- #[cfg(target_os = "windows")]
- let (mut rx, child) = app
- .shell()
- .sidecar("opencode-cli")
- .unwrap()
- .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
- .env("OPENCODE_CLIENT", "desktop")
- .env("XDG_STATE_HOME", &state_dir)
- .args(["serve", &format!("--port={port}")])
+ println!("spawning sidecar on port {port}");
+
+ let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
.spawn()
.expect("Failed to spawn opencode");
- #[cfg(not(target_os = "windows"))]
- let (mut rx, child) = {
- let sidecar = get_sidecar_path(app);
- let shell = get_user_shell();
- app.shell()
- .command(&shell)
- .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
- .env("OPENCODE_CLIENT", "desktop")
- .env("XDG_STATE_HOME", &state_dir)
- .args([
- "-il",
- "-c",
- &format!("\"{}\" serve --port={}", sidecar.display(), port),
- ])
- .spawn()
- .expect("Failed to spawn opencode")
- };
-
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
@@ -222,17 +184,6 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
child
}
-async fn is_server_running(port: u32) -> bool {
- TcpSocket::new_v4()
- .unwrap()
- .connect(SocketAddr::new(
- "127.0.0.1".parse().expect("Failed to parse IP"),
- port as u16,
- ))
- .await
- .is_ok()
-}
-
async fn check_server_health(url: &str) -> bool {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let client = reqwest::Client::builder()
@@ -251,12 +202,6 @@ async fn check_server_health(url: &str) -> bool {
.unwrap_or(false)
}
-fn get_configured_server_url(app: &AppHandle) -> Option<String> {
- let store = app.store(SETTINGS_STORE).ok()?;
- let value = store.get(DEFAULT_SERVER_URL_KEY)?;
- value.as_str().map(String::from)
-}
-
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
@@ -283,7 +228,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
- ensure_server_started,
+ ensure_server_ready,
get_default_server_url,
set_default_server_url
])
@@ -293,15 +238,11 @@ pub fn run() {
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
- // Get port and create window immediately for faster perceived startup
- let port = get_sidecar_port();
-
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
- // Create window immediately with serverReady = false
#[allow(unused_mut)]
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
@@ -314,7 +255,6 @@ pub fn run() {
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
- window.__OPENCODE__.port = {port};
"#
));
@@ -325,7 +265,7 @@ pub fn run() {
.hidden_title(true);
}
- let window = window_builder.build().expect("Failed to create window");
+ window_builder.build().expect("Failed to create window");
let (tx, rx) = tokio::sync::oneshot::channel();
app.manage(ServerState::new(None, rx));
@@ -333,115 +273,28 @@ pub fn run() {
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
- // Check for configured default server URL
- let configured_url = get_configured_server_url(&app);
-
- let (child, res, server_url) = if let Some(ref url) = configured_url {
- println!("Configured default server URL: {}", url);
-
- // Try to connect to the configured server
- let mut healthy = false;
- let mut should_fallback = false;
-
- loop {
- if check_server_health(url).await {
- healthy = true;
- println!("Connected to configured server: {}", url);
- break;
- }
-
- 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;
- },
- _ => {
- should_fallback = true;
- break;
- }
- }
- }
-
- if healthy {
- (None, Ok(()), Some(url.clone()))
- } else if should_fallback {
- // Fall back to spawning local sidecar
- let child = spawn_sidecar(&app, port);
-
- let timestamp = Instant::now();
- let res = loop {
- if timestamp.elapsed() > Duration::from_secs(7) {
- 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 is_server_running(port).await {
- tokio::time::sleep(Duration::from_millis(10)).await;
- break Ok(());
- }
- };
-
- println!("Server ready after {:?}", timestamp.elapsed());
- (Some(child), res, None)
- } else {
- (None, Err("User cancelled".to_string()), None)
- }
- } else {
- // No configured URL, spawn local sidecar as before
- let should_spawn_sidecar = !is_server_running(port).await;
-
- let (child, res) = if should_spawn_sidecar {
- let child = spawn_sidecar(&app, port);
-
- let timestamp = Instant::now();
- let res = loop {
- if timestamp.elapsed() > Duration::from_secs(7) {
- 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 is_server_running(port).await {
- tokio::time::sleep(Duration::from_millis(10)).await;
- break Ok(());
- }
- };
-
- println!("Server ready after {:?}", timestamp.elapsed());
-
- (Some(child), res)
- } else {
- (None, Ok(()))
- };
-
- (child, res, None)
- };
+ let mut custom_url = None;
- app.state::<ServerState>().set_child(child);
+ if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
+ println!("Using desktop-specific custom URL: {url}");
+ custom_url = Some(url);
+ }
- if res.is_ok() {
- let _ = window.eval("window.__OPENCODE__.serverReady = true;");
+ 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);
+ }
- // If using a configured server URL, inject it
- if let Some(url) = server_url {
- let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\"");
- let _ = window.eval(format!(
- "window.__OPENCODE__.serverUrl = \"{escaped_url}\";",
- ));
+ let res = match setup_server_connection(&app, custom_url).await {
+ Ok((child, url)) => {
+ app.state::<ServerState>().set_child(child);
+ Ok(url)
}
- }
+ Err(e) => Err(e),
+ };
let _ = tx.send(res);
});
@@ -474,3 +327,82 @@ pub fn run() {
}
});
}
+
+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();
+
+ Some(format!(
+ "http://{}:{}",
+ hostname.map(|v| v.as_str()).unwrap_or("127.0.0.1"),
+ port
+ ))
+}
+
+async fn setup_server_connection(
+ app: &AppHandle,
+ custom_url: Option<String>,
+) -> Result<(Option<CommandChild>, String), String> {
+ if let Some(url) = custom_url {
+ loop {
+ if check_server_health(&url).await {
+ println!("Connected to custom server: {}", url);
+ return Ok((None, url.clone()));
+ }
+
+ 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 local_port = get_sidecar_port();
+ let local_url = format!("http://127.0.0.1:{local_port}");
+
+ if !check_server_health(&local_url).await {
+ match spawn_local_server(app, local_port).await {
+ Ok(child) => Ok(Some(child)),
+ Err(err) => Err(err),
+ }
+ } else {
+ Ok(None)
+ }
+ .map(|child| (child, local_url))
+}
+
+async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
+ let child = spawn_sidecar(app, port);
+ let url = format!("http://127.0.0.1:{port}");
+
+ let timestamp = Instant::now();
+ loop {
+ if timestamp.elapsed() > Duration::from_secs(7) {
+ 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).await {
+ println!("Server ready after {:?}", timestamp.elapsed());
+ break Ok(child);
+ }
+ }
+}
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index ffb178672..6393b8b45 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
import { Store } from "@tauri-apps/plugin-store"
import { Logo } from "@opencode-ai/ui/logo"
-import { Suspense, createResource, ParentProps } from "solid-js"
+import { Accessor, JSX, createResource } from "solid-js"
import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
@@ -283,7 +283,9 @@ render(() => {
)}
<AppBaseProviders>
<ServerGate>
- <AppInterface />
+ {serverUrl =>
+ <AppInterface defaultUrl={serverUrl()} />
+ }
</ServerGate>
</AppBaseProviders>
</PlatformProvider>
@@ -291,26 +293,21 @@ render(() => {
}, root!)
// Gate component that waits for the server to be ready
-function ServerGate(props: ParentProps) {
- const [status] = createResource(async () => {
- if (window.__OPENCODE__?.serverReady) return
- return await invoke("ensure_server_started")
- })
+function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
+ const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
return (
// Not using suspense as not all components are compatible with it (undefined refs)
<Show
- when={status.state !== "pending"}
+ when={serverUrl.state !== "pending" && serverUrl()}
fallback={
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
<Logo class="w-xl opacity-12 animate-pulse" />
- <div class="mt-8 text-14-regular text-text-weak">Starting server...</div>
+ <div class="mt-8 text-14-regular text-text-weak">Initializing...</div>
</div>
}
>
- {/* Trigger error boundary without rendering the returned value */}
- {(status(), null)}
- {props.children}
+ {serverUrl => props.children(serverUrl)}
</Show>
)
}