feat: support configuring default server URL for desktop (#7363)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ target
|
|||||||
# Local dev files
|
# Local dev files
|
||||||
opencode-dev
|
opencode-dev
|
||||||
logs/
|
logs/
|
||||||
|
*.bun-build
|
||||||
|
|||||||
@@ -33,13 +33,14 @@ const Loading = () => <div class="size-full flex items-center justify-center tex
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean }
|
__OPENCODE__?: { updaterEnabled?: boolean; port?: number; serverReady?: boolean; serverUrl?: string }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultServerUrl = iife(() => {
|
const defaultServerUrl = iife(() => {
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
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)
|
if (import.meta.env.DEV)
|
||||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createStore, reconcile } from "solid-js/store"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -35,6 +35,8 @@ export function DialogSelectServer() {
|
|||||||
error: "",
|
error: "",
|
||||||
status: {} as Record<string, ServerStatus | undefined>,
|
status: {} as Record<string, ServerStatus | undefined>,
|
||||||
})
|
})
|
||||||
|
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
|
||||||
|
const isDesktop = platform.platform === "desktop"
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const items = createMemo(() => {
|
||||||
const current = server.url
|
const current = server.url
|
||||||
@@ -173,6 +175,53 @@ export function DialogSelectServer() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export type Platform = {
|
|||||||
|
|
||||||
/** Fetch override */
|
/** Fetch override */
|
||||||
fetch?: typeof fetch
|
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({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|||||||
1
packages/desktop/src-tauri/Cargo.lock
generated
1
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2795,6 +2795,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"gtk",
|
"gtk",
|
||||||
"listeners",
|
"listeners",
|
||||||
|
"reqwest",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ listeners = "0.3"
|
|||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
semver = "1.0.27"
|
semver = "1.0.27"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
gtk = "0.18.2"
|
gtk = "0.18.2"
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ use tauri::{
|
|||||||
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
|
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
|
||||||
WebviewWindow,
|
WebviewWindow,
|
||||||
};
|
};
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use tokio::net::TcpSocket;
|
use tokio::net::TcpSocket;
|
||||||
|
|
||||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||||
|
|
||||||
|
const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||||
|
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ServerState {
|
struct ServerState {
|
||||||
child: Arc<Mutex<Option<CommandChild>>>,
|
child: Arc<Mutex<Option<CommandChild>>>,
|
||||||
@@ -88,6 +92,41 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
|
|||||||
.map_err(|_| "Failed to get server status".to_string())?
|
.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 {
|
fn get_sidecar_port() -> u32 {
|
||||||
option_env!("OPENCODE_PORT")
|
option_env!("OPENCODE_PORT")
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
@@ -193,6 +232,30 @@ async fn is_server_running(port: u32) -> bool {
|
|||||||
.is_ok()
|
.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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
||||||
@@ -219,7 +282,9 @@ pub fn run() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
kill_sidecar,
|
kill_sidecar,
|
||||||
install_cli,
|
install_cli,
|
||||||
ensure_server_started
|
ensure_server_started,
|
||||||
|
get_default_server_url,
|
||||||
|
set_default_server_url
|
||||||
])
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app = app.handle().clone();
|
let app = app.handle().clone();
|
||||||
@@ -266,41 +331,114 @@ pub fn run() {
|
|||||||
{
|
{
|
||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let should_spawn_sidecar = !is_server_running(port).await;
|
// Check for configured default server URL
|
||||||
|
let configured_url = get_configured_server_url(&app);
|
||||||
|
|
||||||
let (child, res) = if should_spawn_sidecar {
|
let (child, res, server_url) = if let Some(ref url) = configured_url {
|
||||||
let child = spawn_sidecar(&app, port);
|
println!("Configured default server URL: {}", url);
|
||||||
|
|
||||||
let timestamp = Instant::now();
|
// Try to connect to the configured server
|
||||||
let res = loop {
|
let mut healthy = false;
|
||||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
let mut should_fallback = false;
|
||||||
break Err(format!(
|
|
||||||
"Failed to spawn OpenCode Server. Logs:\n{}",
|
loop {
|
||||||
get_logs(app.clone()).await.unwrap()
|
if check_server_health(url).await {
|
||||||
));
|
healthy = true;
|
||||||
|
println!("Connected to configured server: {}", url);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
|
||||||
break Ok(());
|
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(()))
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("Server ready after {:?}", timestamp.elapsed());
|
(child, res, None)
|
||||||
|
|
||||||
(Some(child), res)
|
|
||||||
} else {
|
|
||||||
(None, Ok(()))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
app.state::<ServerState>().set_child(child);
|
app.state::<ServerState>().set_child(child);
|
||||||
|
|
||||||
if res.is_ok() {
|
if res.is_ok() {
|
||||||
let _ = window.eval("window.__OPENCODE__.serverReady = true;");
|
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);
|
let _ = tx.send(res);
|
||||||
|
|||||||
@@ -257,6 +257,15 @@ const platform: Platform = {
|
|||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
fetch: tauriFetch,
|
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()
|
createMenu()
|
||||||
|
|||||||
Reference in New Issue
Block a user