Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ target
# Local dev files
opencode-dev
logs/
*.bun-build
5 changes: 3 additions & 2 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
}
}

Expand All @@ -42,7 +42,8 @@ const defaultServerUrl = iife(() => {
if (param) return param

if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
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"}`

Expand Down
51 changes: 50 additions & 1 deletion packages/app/src/components/dialog-select-server.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
Expand Down Expand Up @@ -35,6 +35,8 @@ export function DialogSelectServer() {
error: "",
status: {} as Record<string, ServerStatus | undefined>,
})
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
const isDesktop = platform.platform === "desktop"

const items = createMemo(() => {
const current = server.url
Expand Down Expand Up @@ -173,6 +175,53 @@ export function DialogSelectServer() {
</div>
</form>
</div>

<Show when={isDesktop}>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Default server</h3>
<p class="text-12-regular text-text-weak mt-1">
Connect to this server on app launch instead of starting a local server. Requires restart.
</p>
</div>
<div class="flex items-center gap-2 px-3 py-2">
<Show
when={defaultUrl()}
fallback={
<Show
when={server.url}
fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
>
<Button
variant="secondary"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(server.url)
defaultUrlActions.refetch(server.url)
}}
>
Set current server as default
</Button>
</Show>
}
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
</div>
<Button
variant="ghost"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.refetch()
}}
>
Clear
</Button>
</Show>
</div>
</div>
</Show>
</div>
</Dialog>
)
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export type Platform = {

/** Fetch override */
fetch?: typeof fetch

/** Get the configured default server URL (desktop only) */
getDefaultServerUrl?(): Promise<string | null>

/** Set the default server URL to use on app startup (desktop only) */
setDefaultServerUrl?(url: string | null): Promise<void>
}

export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ listeners = "0.3"
tauri-plugin-os = "2"
futures = "0.3.31"
semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }

[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
Expand Down
184 changes: 161 additions & 23 deletions packages/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@ use tauri::{
path::BaseDirectory, 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;

const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";

#[derive(Clone)]
struct ServerState {
child: Arc<Mutex<Option<CommandChild>>>,
Expand Down Expand Up @@ -148,6 +152,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
.map_err(|_| "Failed to get server status".to_string())?
}

#[tauri::command]
async 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]
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(())
}

fn get_sidecar_port() -> u32 {
option_env!("OPENCODE_PORT")
.map(|s| s.to_string())
Expand Down Expand Up @@ -253,6 +292,30 @@ async fn is_server_running(port: u32) -> bool {
.is_ok()
}

async fn check_server_health(url: &str) -> bool {
let health_url = format!("{}/health", url.trim_end_matches('/'));
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(3))
.build();

let Ok(client) = client else {
return false;
};

client
.get(&health_url)
.send()
.await
.map(|r| r.status().is_success())
.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();
Expand All @@ -279,7 +342,9 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_started
ensure_server_started,
get_default_server_url,
set_default_server_url
])
.setup(move |app| {
let app = app.handle().clone();
Expand Down Expand Up @@ -343,41 +408,114 @@ pub fn run() {
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
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()
));
}
// Check for configured default server URL
let configured_url = get_configured_server_url(&app);

tokio::time::sleep(Duration::from_millis(10)).await;
let (child, res, server_url) = if let Some(ref url) = configured_url {
println!("Configured default server URL: {}", url);

if is_server_running(port).await {
// give the server a little bit more time to warm up
tokio::time::sleep(Duration::from_millis(10)).await;
// Try to connect to the configured server
let mut healthy = false;
let mut should_fallback = false;

break Ok(());
loop {
if check_server_health(url).await {
healthy = true;
println!("Connected to configured server: {}", url);
break;
}
};

println!("Server ready after {:?}", timestamp.elapsed());
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()
));
}

(Some(child), res)
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 {
(None, Ok(()))
// 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)
};

app.state::<ServerState>().set_child(child);

if res.is_ok() {
let _ = window.eval("window.__OPENCODE__.serverReady = true;");

// 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 _ = tx.send(res);
Expand Down
9 changes: 9 additions & 0 deletions packages/desktop/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ const platform: Platform = {

// @ts-expect-error
fetch: tauriFetch,

getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
return result
},

setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
},
}

createMenu()
Expand Down