소스 검색

feat(desktop): spawn local server with password (#8139)

Brendan Allan 2 달 전
부모
커밋
f05f175842

+ 2 - 1
packages/app/src/context/global-sdk.tsx

@@ -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,

+ 4 - 0
packages/app/src/context/global-sync.tsx

@@ -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,
         })

+ 2 - 2
packages/desktop/scripts/predev.ts

@@ -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`
 

+ 2 - 4
packages/desktop/scripts/prepare.ts

@@ -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`))

+ 6 - 1
packages/desktop/scripts/utils.ts

@@ -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" : ""}`
+}

+ 4 - 3
packages/desktop/src-tauri/Cargo.lock

@@ -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",
 ]
 

+ 1 - 0
packages/desktop/src-tauri/Cargo.toml

@@ -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"

+ 56 - 20
packages/desktop/src-tauri/src/lib.rs

@@ -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);
         }

+ 41 - 13
packages/desktop/src/index.tsx

@@ -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>
   )
 }