fix(desktop): improve server detection & connection logic (#7962)
This commit is contained in:
@@ -33,7 +33,7 @@ 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; serverUrl?: string }
|
__OPENCODE__?: { updaterEnabled?: boolean; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,19 +65,18 @@ function ServerKey(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppInterface() {
|
export function AppInterface(props: { defaultUrl?: string }) {
|
||||||
const defaultServerUrl = iife(() => {
|
const defaultServerUrl = () => {
|
||||||
|
if (props.defaultUrl) return props.defaultUrl;
|
||||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
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)
|
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"}`
|
||||||
|
|
||||||
return window.location.origin
|
return window.location.origin
|
||||||
})
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultUrl={defaultServerUrl}>
|
<ServerProvider defaultUrl={defaultServerUrl()}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name = "opencode-desktop"
|
|||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "The open source AI coding agent"
|
description = "The open source AI coding agent"
|
||||||
authors = ["Anomaly Innovations"]
|
authors = ["Anomaly Innovations"]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
|||||||
@@ -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_INSTALL_DIR: &str = ".opencode/bin";
|
||||||
const CLI_BINARY_NAME: &str = "opencode";
|
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> {
|
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||||
std::env::var("HOME").ok().map(|home| {
|
std::env::var("HOME").ok().map(|home| {
|
||||||
std::path::PathBuf::from(home)
|
std::path::PathBuf::from(home)
|
||||||
@@ -117,3 +139,35 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
|
|
||||||
Ok(())
|
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)])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod window_customizer;
|
mod window_customizer;
|
||||||
|
|
||||||
use cli::{get_sidecar_path, install_cli, sync_cli};
|
use cli::{install_cli, sync_cli};
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
collections::VecDeque,
|
||||||
net::{SocketAddr, TcpListener},
|
net::TcpListener,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use tauri::{
|
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
|
||||||
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl,
|
|
||||||
WebviewWindow,
|
|
||||||
};
|
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
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_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::net::TcpSocket;
|
|
||||||
|
|
||||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||||
|
|
||||||
@@ -27,13 +22,13 @@ 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>>>,
|
||||||
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<(), String>>>,
|
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerState {
|
impl ServerState {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
child: Option<CommandChild>,
|
child: Option<CommandChild>,
|
||||||
status: tokio::sync::oneshot::Receiver<Result<(), String>>,
|
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
child: Arc::new(Mutex::new(child)),
|
child: Arc::new(Mutex::new(child)),
|
||||||
@@ -85,7 +80,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), String> {
|
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
|
||||||
state
|
state
|
||||||
.status
|
.status
|
||||||
.clone()
|
.clone()
|
||||||
@@ -94,7 +89,7 @@ async fn ensure_server_started(state: State<'_, ServerState>) -> Result<(), Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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
|
let store = app
|
||||||
.store(SETTINGS_STORE)
|
.store(SETTINGS_STORE)
|
||||||
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||||
@@ -142,49 +137,16 @@ fn get_sidecar_port() -> u32 {
|
|||||||
}) as 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 {
|
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||||
let log_state = app.state::<LogState>();
|
let log_state = app.state::<LogState>();
|
||||||
let log_state_clone = log_state.inner().clone();
|
let log_state_clone = log_state.inner().clone();
|
||||||
|
|
||||||
let state_dir = app
|
println!("spawning sidecar on port {port}");
|
||||||
.path()
|
|
||||||
.resolve("", BaseDirectory::AppLocalData)
|
|
||||||
.expect("Failed to resolve app local data dir");
|
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
|
||||||
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}")])
|
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to spawn opencode");
|
.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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
while let Some(event) = rx.recv().await {
|
while let Some(event) = rx.recv().await {
|
||||||
match event {
|
match event {
|
||||||
@@ -222,17 +184,6 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
|||||||
child
|
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 {
|
async fn check_server_health(url: &str) -> bool {
|
||||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
@@ -251,12 +202,6 @@ async fn check_server_health(url: &str) -> bool {
|
|||||||
.unwrap_or(false)
|
.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();
|
||||||
@@ -283,7 +228,7 @@ 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_ready,
|
||||||
get_default_server_url,
|
get_default_server_url,
|
||||||
set_default_server_url
|
set_default_server_url
|
||||||
])
|
])
|
||||||
@@ -293,15 +238,11 @@ pub fn run() {
|
|||||||
// Initialize log state
|
// Initialize log state
|
||||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
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 primary_monitor = app.primary_monitor().ok().flatten();
|
||||||
let size = primary_monitor
|
let size = primary_monitor
|
||||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||||
|
|
||||||
// Create window immediately with serverReady = false
|
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut window_builder =
|
let mut window_builder =
|
||||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||||
@@ -314,7 +255,6 @@ pub fn run() {
|
|||||||
r#"
|
r#"
|
||||||
window.__OPENCODE__ ??= {{}};
|
window.__OPENCODE__ ??= {{}};
|
||||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||||
window.__OPENCODE__.port = {port};
|
|
||||||
"#
|
"#
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -325,7 +265,7 @@ pub fn run() {
|
|||||||
.hidden_title(true);
|
.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();
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
app.manage(ServerState::new(None, rx));
|
app.manage(ServerState::new(None, rx));
|
||||||
@@ -333,116 +273,29 @@ pub fn run() {
|
|||||||
{
|
{
|
||||||
let app = app.clone();
|
let app = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// Check for configured default server URL
|
let mut custom_url = None;
|
||||||
let configured_url = get_configured_server_url(&app);
|
|
||||||
|
|
||||||
let (child, res, server_url) = if let Some(ref url) = configured_url {
|
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
|
||||||
println!("Configured default server URL: {}", url);
|
println!("Using desktop-specific custom URL: {url}");
|
||||||
|
custom_url = Some(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)
|
|
||||||
};
|
|
||||||
|
|
||||||
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}\";",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)) => {
|
||||||
|
app.state::<ServerState>().set_child(child);
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
let _ = tx.send(res);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { AsyncStorage } from "@solid-primitives/storage"
|
|||||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"
|
||||||
import { Store } from "@tauri-apps/plugin-store"
|
import { Store } from "@tauri-apps/plugin-store"
|
||||||
import { Logo } from "@opencode-ai/ui/logo"
|
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 { UPDATER_ENABLED } from "./updater"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
@@ -283,7 +283,9 @@ render(() => {
|
|||||||
)}
|
)}
|
||||||
<AppBaseProviders>
|
<AppBaseProviders>
|
||||||
<ServerGate>
|
<ServerGate>
|
||||||
<AppInterface />
|
{serverUrl =>
|
||||||
|
<AppInterface defaultUrl={serverUrl()} />
|
||||||
|
}
|
||||||
</ServerGate>
|
</ServerGate>
|
||||||
</AppBaseProviders>
|
</AppBaseProviders>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
@@ -291,26 +293,21 @@ render(() => {
|
|||||||
}, root!)
|
}, root!)
|
||||||
|
|
||||||
// Gate component that waits for the server to be ready
|
// Gate component that waits for the server to be ready
|
||||||
function ServerGate(props: ParentProps) {
|
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
|
||||||
const [status] = createResource(async () => {
|
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
|
||||||
if (window.__OPENCODE__?.serverReady) return
|
|
||||||
return await invoke("ensure_server_started")
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||||
<Show
|
<Show
|
||||||
when={status.state !== "pending"}
|
when={serverUrl.state !== "pending" && serverUrl()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
<div class="h-screen w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
<Logo class="w-xl opacity-12 animate-pulse" />
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Trigger error boundary without rendering the returned value */}
|
{serverUrl => props.children(serverUrl)}
|
||||||
{(status(), null)}
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user