Skip to content

Commit 02b7eb5

Browse files
authored
feat: support configuring default server URL for desktop (#7363)
1 parent a8f23fb commit 02b7eb5

File tree

8 files changed

+232
-26
lines changed

8 files changed

+232
-26
lines changed

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ target
2424
# Local dev files
2525
opencode-dev
2626
logs/
27+
*.bun-build

‎packages/app/src/app.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
3333

3434
declare global {
3535
interface Window {
36-
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
36+
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
3737
}
3838
}
3939

4040
const defaultServerUrl = iife(() => {
4141
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
42-
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
42+
if (window.__OPENCODE__?.serverUrl) return window.__OPENCODE__.serverUrl
43+
if (window.__OPENCODE__?.port) return `http://127.0.0.1:${window.__OPENCODE__.port}`
4344
if (import.meta.env.DEV)
4445
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
4546

‎packages/app/src/components/dialog-select-server.tsx‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createEffect, createMemo, onCleanup } from "solid-js"
1+
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
22
import { createStore, reconcile } from "solid-js/store"
33
import { useDialog } from "@opencode-ai/ui/context/dialog"
44
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -35,6 +35,8 @@ export function DialogSelectServer() {
3535
error: "",
3636
status: {} as Record<string, ServerStatus | undefined>,
3737
})
38+
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
39+
const isDesktop = platform.platform === "desktop"
3840

3941
const items = createMemo(() => {
4042
const current = server.url
@@ -173,6 +175,53 @@ export function DialogSelectServer() {
173175
</div>
174176
</form>
175177
</div>
178+
179+
<Show when={isDesktop}>
180+
<div class="mt-6 px-3 flex flex-col gap-1.5">
181+
<div class="px-3">
182+
<h3 class="text-14-regular text-text-weak">Default server</h3>
183+
<p class="text-12-regular text-text-weak mt-1">
184+
Connect to this server on app launch instead of starting a local server. Requires restart.
185+
</p>
186+
</div>
187+
<div class="flex items-center gap-2 px-3 py-2">
188+
<Show
189+
when={defaultUrl()}
190+
fallback={
191+
<Show
192+
when={server.url}
193+
fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
194+
>
195+
<Button
196+
variant="secondary"
197+
size="small"
198+
onClick={async () => {
199+
await platform.setDefaultServerUrl?.(server.url)
200+
defaultUrlActions.refetch(server.url)
201+
}}
202+
>
203+
Set current server as default
204+
</Button>
205+
</Show>
206+
}
207+
>
208+
<div class="flex items-center gap-2 flex-1 min-w-0">
209+
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
210+
</div>
211+
<Button
212+
variant="ghost"
213+
size="small"
214+
onClick={async () => {
215+
await platform.setDefaultServerUrl?.(null)
216+
defaultUrlActions.refetch()
217+
}}
218+
>
219+
Clear
220+
</Button>
221+
</Show>
222+
</div>
223+
</div>
224+
</Show>
176225
</div>
177226
</Dialog>
178227
)

‎packages/app/src/context/platform.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export type Platform = {
3737

3838
/** Fetch override */
3939
fetch?: typeof fetch
40+
41+
/** Get the configured default server URL (desktop only) */
42+
getDefaultServerUrl?(): Promise<string | null>
43+
44+
/** Set the default server URL to use on app startup (desktop only) */
45+
setDefaultServerUrl?(url: string | null): Promise<void>
4046
}
4147

4248
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

‎packages/desktop/src-tauri/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/desktop/src-tauri/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ listeners = "0.3"
3838
tauri-plugin-os = "2"
3939
futures = "0.3.31"
4040
semver = "1.0.27"
41+
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
4142

4243
[target.'cfg(target_os = "linux")'.dependencies]
4344
gtk = "0.18.2"

‎packages/desktop/src-tauri/src/lib.rs‎

Lines changed: 161 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ use tauri::{
1313
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
1414
WebviewWindow,
1515
};
16+
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
1617
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
1718
use tauri_plugin_shell::ShellExt;
1819
use tokio::net::TcpSocket;
1920

2021
use crate::window_customizer::PinchZoomDisablePlugin;
2122

23+
const SETTINGS_STORE: &str = "opencode.settings.dat";
24+
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
25+
2226
#[derive(Clone)]
2327
struct ServerState {
2428
child: Arc<Mutex<Option<CommandChild>>>,
@@ -88,6 +92,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
8892
.map_err(|_| "Failed to get server status".to_string())?
8993
}
9094

95+
#[tauri::command]
96+
async fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
97+
let store = app
98+
.store(SETTINGS_STORE)
99+
.map_err(|e| format!("Failed to open settings store: {}", e))?;
100+
101+
let value = store.get(DEFAULT_SERVER_URL_KEY);
102+
match value {
103+
Some(v) => Ok(v.as_str().map(String::from)),
104+
None => Ok(None),
105+
}
106+
}
107+
108+
#[tauri::command]
109+
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
110+
let store = app
111+
.store(SETTINGS_STORE)
112+
.map_err(|e| format!("Failed to open settings store: {}", e))?;
113+
114+
match url {
115+
Some(u) => {
116+
store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
117+
}
118+
None => {
119+
store.delete(DEFAULT_SERVER_URL_KEY);
120+
}
121+
}
122+
123+
store
124+
.save()
125+
.map_err(|e| format!("Failed to save settings: {}", e))?;
126+
127+
Ok(())
128+
}
129+
91130
fn get_sidecar_port() -> u32 {
92131
option_env!("OPENCODE_PORT")
93132
.map(|s| s.to_string())
@@ -193,6 +232,30 @@ async fn is_server_running(port: u32) -> bool {
193232
.is_ok()
194233
}
195234

235+
async fn check_server_health(url: &str) -> bool {
236+
let health_url = format!("{}/health", url.trim_end_matches('/'));
237+
let client = reqwest::Client::builder()
238+
.timeout(Duration::from_secs(3))
239+
.build();
240+
241+
let Ok(client) = client else {
242+
return false;
243+
};
244+
245+
client
246+
.get(&health_url)
247+
.send()
248+
.await
249+
.map(|r| r.status().is_success())
250+
.unwrap_or(false)
251+
}
252+
253+
fn get_configured_server_url(app: &AppHandle) -> Option<String> {
254+
let store = app.store(SETTINGS_STORE).ok()?;
255+
let value = store.get(DEFAULT_SERVER_URL_KEY)?;
256+
value.as_str().map(String::from)
257+
}
258+
196259
#[cfg_attr(mobile, tauri::mobile_entry_point)]
197260
pub fn run() {
198261
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
@@ -219,7 +282,9 @@ pub fn run() {
219282
.invoke_handler(tauri::generate_handler![
220283
kill_sidecar,
221284
install_cli,
222-
ensure_server_started
285+
ensure_server_started,
286+
get_default_server_url,
287+
set_default_server_url
223288
])
224289
.setup(move |app| {
225290
let app = app.handle().clone();
@@ -266,41 +331,114 @@ pub fn run() {
266331
{
267332
let app = app.clone();
268333
tauri::async_runtime::spawn(async move {
269-
let should_spawn_sidecar = !is_server_running(port).await;
270-
271-
let (child, res) = if should_spawn_sidecar {
272-
let child = spawn_sidecar(&app, port);
273-
274-
let timestamp = Instant::now();
275-
let res = loop {
276-
if timestamp.elapsed() > Duration::from_secs(7) {
277-
break Err(format!(
278-
"Failed to spawn OpenCode Server. Logs:\n{}",
279-
get_logs(app.clone()).await.unwrap()
280-
));
281-
}
334+
// Check for configured default server URL
335+
let configured_url = get_configured_server_url(&app);
282336

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

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

289-
break Ok(());
344+
loop {
345+
if check_server_health(url).await {
346+
healthy = true;
347+
println!("Connected to configured server: {}", url);
348+
break;
290349
}
291-
};
292350

293-
println!("Server ready after {:?}", timestamp.elapsed());
351+
let res = app.dialog()
352+
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
353+
.title("Connection Failed")
354+
.buttons(MessageDialogButtons::OkCancelCustom("Retry".to_string(), "Start Local".to_string()))
355+
.blocking_show_with_result();
356+
357+
match res {
358+
MessageDialogResult::Custom(name) if name == "Retry" => {
359+
continue;
360+
},
361+
_ => {
362+
should_fallback = true;
363+
break;
364+
}
365+
}
366+
}
367+
368+
if healthy {
369+
(None, Ok(()), Some(url.clone()))
370+
} else if should_fallback {
371+
// Fall back to spawning local sidecar
372+
let child = spawn_sidecar(&app, port);
373+
374+
let timestamp = Instant::now();
375+
let res = loop {
376+
if timestamp.elapsed() > Duration::from_secs(7) {
377+
break Err(format!(
378+
"Failed to spawn OpenCode Server. Logs:\n{}",
379+
get_logs(app.clone()).await.unwrap()
380+
));
381+
}
294382

295-
(Some(child), res)
383+
tokio::time::sleep(Duration::from_millis(10)).await;
384+
385+
if is_server_running(port).await {
386+
tokio::time::sleep(Duration::from_millis(10)).await;
387+
break Ok(());
388+
}
389+
};
390+
391+
println!("Server ready after {:?}", timestamp.elapsed());
392+
(Some(child), res, None)
393+
} else {
394+
(None, Err("User cancelled".to_string()), None)
395+
}
296396
} else {
297-
(None, Ok(()))
397+
// No configured URL, spawn local sidecar as before
398+
let should_spawn_sidecar = !is_server_running(port).await;
399+
400+
let (child, res) = if should_spawn_sidecar {
401+
let child = spawn_sidecar(&app, port);
402+
403+
let timestamp = Instant::now();
404+
let res = loop {
405+
if timestamp.elapsed() > Duration::from_secs(7) {
406+
break Err(format!(
407+
"Failed to spawn OpenCode Server. Logs:\n{}",
408+
get_logs(app.clone()).await.unwrap()
409+
));
410+
}
411+
412+
tokio::time::sleep(Duration::from_millis(10)).await;
413+
414+
if is_server_running(port).await {
415+
tokio::time::sleep(Duration::from_millis(10)).await;
416+
break Ok(());
417+
}
418+
};
419+
420+
println!("Server ready after {:?}", timestamp.elapsed());
421+
422+
(Some(child), res)
423+
} else {
424+
(None, Ok(()))
425+
};
426+
427+
(child, res, None)
298428
};
299429

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

302432
if res.is_ok() {
303433
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
434+
435+
// If using a configured server URL, inject it
436+
if let Some(url) = server_url {
437+
let escaped_url = url.replace('\\', "\\\\").replace('"', "\\\"");
438+
let _ = window.eval(format!(
439+
"window.__OPENCODE__.serverUrl = \"{escaped_url}\";",
440+
));
441+
}
304442
}
305443

306444
let _ = tx.send(res);

‎packages/desktop/src/index.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ const platform: Platform = {
257257

258258
// @ts-expect-error
259259
fetch: tauriFetch,
260+
261+
getDefaultServerUrl: async () => {
262+
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
263+
return result
264+
},
265+
266+
setDefaultServerUrl: async (url: string | null) => {
267+
await invoke("set_default_server_url", { url })
268+
},
260269
}
261270

262271
createMenu()

0 commit comments

Comments
 (0)