feat(desktop): spawn local server with password (#8139)
This commit is contained in:
@@ -9,11 +9,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
name: "GlobalSDK",
|
||||
init: () => {
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
const eventSdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
signal: abort.signal,
|
||||
fetch: platform.fetch,
|
||||
})
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: Event
|
||||
@@ -93,7 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
stop()
|
||||
})
|
||||
|
||||
const platform = usePlatform()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: server.url,
|
||||
fetch: platform.fetch,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ErrorPage, type InitError } from "../pages/error"
|
||||
import { batch, createContext, useContext, onCleanup, onMount, type ParentProps, Switch, Match } from "solid-js"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
@@ -64,6 +65,7 @@ type State = {
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
const [globalStore, setGlobalStore] = createStore<{
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
@@ -139,6 +141,7 @@ function createGlobalSync() {
|
||||
const [store, setStore] = child(directory)
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
@@ -396,6 +399,7 @@ function createGlobalSync() {
|
||||
case "lsp.updated": {
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { $ } from "bun"
|
||||
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
||||
|
||||
const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
|
||||
|
||||
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
|
||||
|
||||
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
|
||||
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
|
||||
|
||||
await $`cd ../opencode && bun run build --single`
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
||||
|
||||
const sidecarConfig = getCurrentSidecar()
|
||||
|
||||
@@ -10,6 +10,4 @@ const dir = "src-tauri/target/opencode-binaries"
|
||||
await $`mkdir -p ${dir}`
|
||||
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
|
||||
|
||||
await copyBinaryToSidecarFolder(
|
||||
`${dir}/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`,
|
||||
)
|
||||
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
|
||||
|
||||
@@ -41,8 +41,13 @@ export function getCurrentSidecar(target = RUST_TARGET) {
|
||||
|
||||
export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) {
|
||||
await $`mkdir -p src-tauri/sidecars`
|
||||
const dest = `src-tauri/sidecars/opencode-cli-${target}${process.platform === "win32" ? ".exe" : ""}`
|
||||
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
|
||||
await $`cp ${source} ${dest}`
|
||||
|
||||
console.log(`Copied ${source} to ${dest}`)
|
||||
}
|
||||
|
||||
export function windowsify(path: string) {
|
||||
if (path.endsWith(".exe")) return path
|
||||
return `${path}${process.platform === "win32" ? ".exe" : ""}`
|
||||
}
|
||||
|
||||
7
packages/desktop/src-tauri/Cargo.lock
generated
7
packages/desktop/src-tauri/Cargo.lock
generated
@@ -2814,6 +2814,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
]
|
||||
|
||||
@@ -5364,13 +5365,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ tauri-plugin-os = "2"
|
||||
futures = "0.3.31"
|
||||
semver = "1.0.27"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
uuid = { version = "1.19.0", features = ["v4"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -3,6 +3,7 @@ mod window_customizer;
|
||||
|
||||
use cli::{install_cli, sync_cli};
|
||||
use futures::FutureExt;
|
||||
use futures::future;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::TcpListener,
|
||||
@@ -13,22 +14,29 @@ use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, Webvie
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::window_customizer::PinchZoomDisablePlugin;
|
||||
|
||||
const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct ServerReadyData {
|
||||
url: String,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState {
|
||||
child: Arc<Mutex<Option<CommandChild>>>,
|
||||
status: futures::future::Shared<tokio::sync::oneshot::Receiver<Result<String, String>>>,
|
||||
status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new(
|
||||
child: Option<CommandChild>,
|
||||
status: tokio::sync::oneshot::Receiver<Result<String, String>>,
|
||||
status: oneshot::Receiver<Result<ServerReadyData, String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
@@ -80,7 +88,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<String, String> {
|
||||
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
|
||||
state
|
||||
.status
|
||||
.clone()
|
||||
@@ -137,13 +145,14 @@ fn get_sidecar_port() -> u32 {
|
||||
}) as u32
|
||||
}
|
||||
|
||||
fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild {
|
||||
let log_state = app.state::<LogState>();
|
||||
let log_state_clone = log_state.inner().clone();
|
||||
|
||||
println!("spawning sidecar on port {port}");
|
||||
|
||||
let (mut rx, child) = cli::create_command(app, format!("serve --port {port}").as_str())
|
||||
.env("OPENCODE_SERVER_PASSWORD", password)
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
@@ -184,7 +193,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
child
|
||||
}
|
||||
|
||||
async fn check_server_health(url: &str) -> bool {
|
||||
async fn check_server_health(url: &str, password: Option<&str>) -> bool {
|
||||
let health_url = format!("{}/health", url.trim_end_matches('/'));
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(3))
|
||||
@@ -194,9 +203,13 @@ async fn check_server_health(url: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
client
|
||||
.get(&health_url)
|
||||
.send()
|
||||
let mut req = client.get(&health_url);
|
||||
|
||||
if let Some(password) = password {
|
||||
req = req.basic_auth("opencode", Some(password));
|
||||
}
|
||||
|
||||
req.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
@@ -267,7 +280,7 @@ pub fn run() {
|
||||
|
||||
window_builder.build().expect("Failed to create window");
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
app.manage(ServerState::new(None, rx));
|
||||
|
||||
{
|
||||
@@ -344,12 +357,18 @@ fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
|
||||
async fn setup_server_connection(
|
||||
app: &AppHandle,
|
||||
custom_url: Option<String>,
|
||||
) -> Result<(Option<CommandChild>, String), String> {
|
||||
) -> Result<(Option<CommandChild>, ServerReadyData), String> {
|
||||
if let Some(url) = custom_url {
|
||||
loop {
|
||||
if check_server_health(&url).await {
|
||||
if check_server_health(&url, None).await {
|
||||
println!("Connected to custom server: {}", url);
|
||||
return Ok((None, url.clone()));
|
||||
return Ok((
|
||||
None,
|
||||
ServerReadyData {
|
||||
url: url.clone(),
|
||||
password: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
const RETRY: &str = "Retry";
|
||||
@@ -374,19 +393,36 @@ async fn setup_server_connection(
|
||||
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)),
|
||||
if !check_server_health(&local_url, None).await {
|
||||
let password = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
match spawn_local_server(app, local_port, &password).await {
|
||||
Ok(child) => Ok((
|
||||
Some(child),
|
||||
ServerReadyData {
|
||||
url: local_url,
|
||||
password: Some(password),
|
||||
},
|
||||
)),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
Ok((
|
||||
None,
|
||||
ServerReadyData {
|
||||
url: local_url,
|
||||
password: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
.map(|child| (child, local_url))
|
||||
}
|
||||
|
||||
async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild, String> {
|
||||
let child = spawn_sidecar(app, port);
|
||||
async fn spawn_local_server(
|
||||
app: &AppHandle,
|
||||
port: u32,
|
||||
password: &str,
|
||||
) -> Result<CommandChild, String> {
|
||||
let child = spawn_sidecar(app, port, password);
|
||||
let url = format!("http://127.0.0.1:{port}");
|
||||
|
||||
let timestamp = Instant::now();
|
||||
@@ -400,7 +436,7 @@ async fn spawn_local_server(app: &AppHandle, port: u32) -> Result<CommandChild,
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
if check_server_health(&url).await {
|
||||
if check_server_health(&url, Some(password)).await {
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
break Ok(child);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,11 @@ 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 { Accessor, JSX, createResource } from "solid-js"
|
||||
import { createSignal, Show, Accessor, JSX, createResource } from "solid-js"
|
||||
|
||||
import { UPDATER_ENABLED } from "./updater"
|
||||
import { createMenu } from "./menu"
|
||||
import pkg from "../package.json"
|
||||
import { Show } from "solid-js"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -29,7 +28,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
|
||||
let update: Update | null = null
|
||||
|
||||
const platform: Platform = {
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
platform: "desktop",
|
||||
version: pkg.version,
|
||||
|
||||
@@ -256,7 +255,25 @@ const platform: Platform = {
|
||||
},
|
||||
|
||||
// @ts-expect-error
|
||||
fetch: tauriFetch,
|
||||
fetch: (input, init) => {
|
||||
const pw = password()
|
||||
|
||||
const addHeader = (headers: Headers, password: string) => {
|
||||
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (pw) addHeader(input.headers, pw)
|
||||
return tauriFetch(input)
|
||||
} else {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (pw) addHeader(headers, pw)
|
||||
return tauriFetch(input, {
|
||||
...(init as any),
|
||||
headers: headers,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
getDefaultServerUrl: async () => {
|
||||
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
|
||||
@@ -266,7 +283,7 @@ const platform: Platform = {
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
await invoke("set_default_server_url", { url })
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
createMenu()
|
||||
|
||||
@@ -276,26 +293,37 @@ root?.addEventListener("mousewheel", (e) => {
|
||||
})
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
{ostype() === "macos" && (
|
||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
||||
)}
|
||||
<AppBaseProviders>
|
||||
<ServerGate>{(serverUrl) => <AppInterface defaultUrl={serverUrl()} />}</ServerGate>
|
||||
{ostype() === "macos" && (
|
||||
<div class="mx-px bg-background-base border-b border-border-weak-base h-8" data-tauri-drag-region />
|
||||
)}
|
||||
<ServerGate>
|
||||
{(data) => {
|
||||
setServerPassword(data().password)
|
||||
|
||||
return <AppInterface defaultUrl={data().url} />
|
||||
}}
|
||||
</ServerGate>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root!)
|
||||
|
||||
type ServerReadyData = { url: string; password: string | null }
|
||||
|
||||
// Gate component that waits for the server to be ready
|
||||
function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element }) {
|
||||
const [serverUrl] = createResource<string>(() => invoke("ensure_server_ready"))
|
||||
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
|
||||
const [serverData] = createResource<ServerReadyData>(() => invoke("ensure_server_ready"))
|
||||
|
||||
return (
|
||||
// Not using suspense as not all components are compatible with it (undefined refs)
|
||||
<Show
|
||||
when={serverUrl.state !== "pending" && serverUrl()}
|
||||
when={serverData.state !== "pending" && serverData()}
|
||||
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" />
|
||||
@@ -303,7 +331,7 @@ function ServerGate(props: { children: (url: Accessor<string>) => JSX.Element })
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(serverUrl) => props.children(serverUrl)}
|
||||
{(data) => props.children(data)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user