diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c59cbe8988b..f218b9ad82d 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,6 +29,7 @@ import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" import { iife } from "@opencode-ai/util/iife" import { Suspense } from "solid-js" +import { CredentialsProvider } from "./context/credentials" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -132,11 +133,37 @@ export function AppInterface(props: { defaultUrl?: string }) { )} - /> - - - - + > + ( + }> + + + )} + /> + + } /> + ( + + + + }> + + + + + + )} + /> + + + + + + ) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 8eb08878912..d2a61124c80 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -1,6 +1,4 @@ -import { Component, createMemo, createSignal, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" +import { Component, createMemo } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" @@ -12,28 +10,14 @@ export const DialogSelectMcp: Component = () => { const language = useLanguage() const [loading, setLoading] = createSignal(null) - const items = createMemo(() => + const mcpItems = createMemo(() => Object.entries(sync.data.mcp ?? {}) .map(([name, status]) => ({ name, status: status.status })) .sort((a, b) => a.name.localeCompare(b.name)), ) - const toggle = async (name: string) => { - if (loading()) return - setLoading(name) - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) - } - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) - setLoading(null) - } - - const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) - const totalCount = createMemo(() => items().length) + const enabledCount = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) + const totalCount = createMemo(() => mcpItems().length) return ( + Object.entries(sync.data.mcp ?? {}) + .map(([name]) => name) + .sort((a, b) => a.localeCompare(b)), + ) + + return ( + + + + + {server.list.length} {server.list.length === 1 ? "Server" : "Servers"} + + + {mcpItems().length} {mcpItems().length === 1 ? "MCP" : "MCPs"} + + + LSP + + + Plugins + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/app/src/components/lsp-tab.tsx b/packages/app/src/components/lsp-tab.tsx new file mode 100644 index 00000000000..bc1d8a227ef --- /dev/null +++ b/packages/app/src/components/lsp-tab.tsx @@ -0,0 +1,9 @@ +import type { Component } from "solid-js" + +export const LspTab: Component = () => { + return ( +
+ LSPs auto-detected from file types +
+ ) +} diff --git a/packages/app/src/components/mcp-tab.tsx b/packages/app/src/components/mcp-tab.tsx new file mode 100644 index 00000000000..02fd67cf484 --- /dev/null +++ b/packages/app/src/components/mcp-tab.tsx @@ -0,0 +1,70 @@ +import { createSignal, createMemo } from "solid-js" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import type { Component } from "solid-js" + +export const McpTab: Component = () => { + const sync = useSync() + const sdk = useSDK() + const [mcpLoading, setMcpLoading] = createSignal(null) + + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const toggleMcp = async (name: string) => { + if (mcpLoading()) return + setMcpLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setMcpLoading(null) + } + + return ( +
+ x.name} + onSelect={(x) => { + if (x) toggleMcp(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} +
+
e.stopPropagation()}> + toggleMcp(i.name)} /> +
+
+ ) + }} + +
+ ) +} diff --git a/packages/app/src/components/plugins-tab.tsx b/packages/app/src/components/plugins-tab.tsx new file mode 100644 index 00000000000..46dcde47c21 --- /dev/null +++ b/packages/app/src/components/plugins-tab.tsx @@ -0,0 +1,11 @@ +import type { Component } from "solid-js" + +export const PluginsTab: Component = () => { + return ( +
+ + Plugins configured in opencode.json + +
+ ) +} diff --git a/packages/app/src/components/server-tab.tsx b/packages/app/src/components/server-tab.tsx new file mode 100644 index 00000000000..95f9e634001 --- /dev/null +++ b/packages/app/src/components/server-tab.tsx @@ -0,0 +1,494 @@ +import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Popover } from "@opencode-ai/ui/popover" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { useGlobalSDK } from "@/context/global-sdk" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import type { Component } from "solid-js" + +type ServerStatus = { healthy: boolean; version?: string; authenticated?: boolean } + +async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => { + const data = x.data as { healthy: boolean; version?: string; authenticated?: boolean } + return { + healthy: data?.healthy === true, + version: data?.version, + authenticated: data?.authenticated === true, + } + }) + .catch(() => ({ healthy: false })) +} + +async function checkCredentials( + url: string, + username: string, + password: string, + fetch?: typeof globalThis.fetch, +): Promise<{ correctCredentials: boolean }> { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + headers: { + Authorization: `Basic ${btoa(`${username}:${password}`)}`, + }, + }) + return sdk.config + .get() + .then((x) => { + if (x?.error) return { correctCredentials: false } + return { correctCredentials: true } + }) + .catch(() => { + return { correctCredentials: false } + }) +} + +export const ServerTab: Component<{ onTitleChange?: (title: string, description: string) => void }> = (props) => { + const server = useServer() + const platform = usePlatform() + const globalSDK = useGlobalSDK() + const dialog = useDialog() + const [store, setStore] = createStore({ + url: "", + adding: false, + error: "", + status: {} as Record, + showCredentials: false, + credentialsUrl: "", + username: "", + password: "", + credentialsError: "", + showAddServerForm: false, + addingStatus: undefined as "success" | "error" | undefined, + }) + const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) + const isDesktop = platform.platform === "desktop" + + const items = createMemo(() => { + const current = server.url + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((x) => x !== current)] + }) + + const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) + + const sortedItems = createMemo(() => { + const list = items() + if (!list.length) return list + const active = current() + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerStatus) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(store.status[a]) - rank(store.status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record = {} + await Promise.all( + items().map(async (url) => { + results[url] = await checkHealth(url, platform.fetch) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + items() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + createEffect(() => { + if (props.onTitleChange) { + const title = store.showCredentials ? "Enter credentials" : "Servers" + const description = store.showCredentials + ? `Connect to ${serverDisplayName(store.credentialsUrl)}` + : "Switch which OpenCode server this app connects to." + props.onTitleChange(title, description) + } + }) + + function select(value: string, persist?: boolean) { + if (!persist && store.status[value]?.healthy === false) return + if (persist) { + server.add(value) + return + } + server.setActive(value) + setTimeout(() => dialog.close(), 100) + } + + async function handleSaveServer(e: SubmitEvent) { + e.preventDefault() + const value = normalizeServerUrl(store.url) + if (!value) return + + setStore("adding", true) + setStore("error", "") + setStore("addingStatus", undefined) + + const result = await checkHealth(value, platform.fetch) + setStore("adding", false) + + if (!result.healthy) { + setStore("error", "Could not connect to server") + setStore("addingStatus", "error") + return + } + + setStore("addingStatus", "success") + + if (result.authenticated) { + setStore("credentialsUrl", value) + setStore("showCredentials", true) + setStore("username", "opencode") + setStore("password", "") + setStore("credentialsError", "") + return + } + + setStore("url", "") + setStore("showAddServerForm", false) + select(value, true) + setTimeout(() => dialog.close(), 100) + } + + async function handleCredentialsSubmit(e: SubmitEvent) { + e.preventDefault() + const url = store.credentialsUrl + if (!url || !store.username || !store.password) { + setStore("credentialsError", "Username and password are required") + return + } + + setStore("credentialsError", "") + setStore("adding", true) + + const result = await checkCredentials(url, store.username, store.password, platform.fetch) + setStore("adding", false) + + if (!result.correctCredentials) { + setStore("credentialsError", "Invalid username or password") + return + } + + await platform.storeServerCredentials?.(url, store.username, store.password) + await globalSDK.refetchCredentials() + + setStore("showCredentials", false) + setStore("url", "") + setStore("showAddServerForm", false) + select(url, true) + setTimeout(() => dialog.close(), 100) + } + + function handleCancelCredentials() { + setStore("showCredentials", false) + setStore("credentialsUrl", "") + setStore("username", "") + setStore("password", "") + setStore("credentialsError", "") + } + + async function handleRemove(url: string) { + server.remove(url) + await platform.removeServerCredentials?.(url) + } + + return ( + + x} + current={current()} + onSelect={(x) => { + if (x) select(x) + }} + > + {(i) => { + const [popoverOpen, setPopoverOpen] = createSignal(false) + return ( +
+
+
+ {serverDisplayName(i)} + + Authed + + {store.status[i]?.version} + + Default + +
+ +
e.stopPropagation()}> + + } + class="min-w-[150px]" + > +
+ + + + +
+ +
+ +
+ +
+ ) + }} + + + + +
+ } + > +
+
+
+
+ { + setStore("url", v) + setStore("error", "") + }} + validationState={store.error ? "invalid" : "valid"} + error={store.error} + autofocus + class="w-full" + /> +
+ +
+ + +
+ +
+ + + {/* +
+
+

Default server

+

+ Connect to this server on app launch instead of starting a local server. Requires restart. +

+
+
+ No server selected} + > + + + } + > +
+ {serverDisplayName(defaultUrl()!)} +
+ + +
+
+
*/} +
+ } + > +
+
+
+ { + setStore("username", v) + setStore("credentialsError", "") + }} + autofocus + class="w-full" + inputClass="w-full" + /> + { + setStore("password", v) + setStore("credentialsError", "") + }} + validationState={store.credentialsError ? "invalid" : "valid"} + error={store.credentialsError} + class="w-full" + inputClass="w-full" + /> +
+ + +
+
+
+
+ + ) +} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx index 489223b9bf5..43478d55a75 100644 --- a/packages/app/src/components/session-mcp-indicator.tsx +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -3,6 +3,7 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { DialogSelectMcp } from "@/components/dialog-select-mcp" +import { DialogServerConfig } from "./dialog-server-config" export function SessionMcpIndicator() { const sync = useSync() @@ -19,7 +20,7 @@ export function SessionMcpIndicator() { return ( 0}> - - -
+ +
+ { + setStore("username", v) + setStore("credentialsError", "") + }} + placeholder="opencode" + autofocus + required + /> + { + setStore("password", v) + setStore("credentialsError", "") + }} + placeholder="Enter password" + required + validationState={store.credentialsError ? "invalid" : "valid"} + error={store.credentialsError} + /> +
+ + +
+ +
+
+ + +
+ + + + {store.checking ? "Checking..." : "Check for updates"} + + } + > + + + +
+
{language.t("error.page.report.prefix")} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index befdf721d2b..8ae16b04b77 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -69,6 +69,7 @@ import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogEditProject } from "@/components/dialog-edit-project" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" +import { DialogServerConfig } from "@/components/dialog-server-config" import { useLanguage, type Locale } from "@/context/language" export default function Layout(props: ParentProps) { @@ -946,7 +947,8 @@ export default function Layout(props: ParentProps) { } function openServer() { - dialog.show(() => ) + // dialog.show(() => ) + dialog.show(() => ) } function openSettings() { diff --git a/packages/app/src/utils/credential-storage.ts b/packages/app/src/utils/credential-storage.ts new file mode 100644 index 00000000000..b5561812500 --- /dev/null +++ b/packages/app/src/utils/credential-storage.ts @@ -0,0 +1,106 @@ +import { hash } from "@opencode-ai/util/encode" +import { Platform } from "@/context/platform" + +// Web Crypto API helper functions +const CREDENTIALS_STORAGE_KEY = `opencode.server.credentials` +const CREDENTIALS_INDEX_KEY = `opencode.server.credentials.index` + +async function getEncryptionKey(): Promise { + // Derive a key from a domain-specific secret (could be improved with user-specific secret) + const keyMaterial = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("opencode-credentials-v1"), + { name: "PBKDF2" }, + false, + ["deriveBits", "deriveKey"], + ) + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: new TextEncoder().encode("opencode-salt-v1"), + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ) +} + +async function encrypt(data: string): Promise { + const key = await getEncryptionKey() + const iv = crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(data) + + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded) + + // Combine IV and encrypted data + const combined = new Uint8Array(iv.length + encrypted.byteLength) + combined.set(iv) + combined.set(new Uint8Array(encrypted), iv.length) + + // Convert to base64 for storage + return btoa(String.fromCharCode(...combined)) +} + +async function decrypt(encryptedData: string): Promise { + const key = await getEncryptionKey() + const combined = Uint8Array.from(atob(encryptedData), (c) => c.charCodeAt(0)) + + const iv = combined.slice(0, 12) + const encrypted = combined.slice(12) + + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encrypted) + + return new TextDecoder().decode(decrypted) +} + +export async function storeServerCredentials(url: string, username: string, password: string) { + const credentials = JSON.stringify({ username, password }) + const encrypted = await encrypt(credentials) + const urlHash = await hash(url) + + // Store encrypted credentials + localStorage.setItem(`${CREDENTIALS_STORAGE_KEY}:${urlHash}`, encrypted) + console.log("Hashing URL", url) + console.log("stored credentials", `${CREDENTIALS_STORAGE_KEY}:${urlHash}`, encrypted) + + // Update index + const index = JSON.parse(localStorage.getItem(CREDENTIALS_INDEX_KEY) || "[]") as string[] + if (!index.includes(urlHash)) { + index.push(urlHash) + localStorage.setItem(CREDENTIALS_INDEX_KEY, JSON.stringify(index)) + } +} + +export async function getServerCredentials(url: string) { + const urlHash = await hash(url) + console.log("Getting credentials for", urlHash) + console.log("get credentials", `${CREDENTIALS_STORAGE_KEY}:${urlHash}`) + const encrypted = localStorage.getItem(`${CREDENTIALS_STORAGE_KEY}:${urlHash}`) + if (!encrypted) return null + + try { + const decrypted = await decrypt(encrypted) + return JSON.parse(decrypted) as { username: string; password: string } + } catch { + return null + } +} + +export async function removeServerCredentials(url: string) { + const urlHash = await hash(url) + localStorage.removeItem(`${CREDENTIALS_STORAGE_KEY}:${urlHash}`) + + // Update index + const index = JSON.parse(localStorage.getItem(CREDENTIALS_INDEX_KEY) || "[]") as string[] + const filtered = index.filter((h) => h !== urlHash) + localStorage.setItem(CREDENTIALS_INDEX_KEY, JSON.stringify(filtered)) +} + +export async function listServerCredentials() { + const index = JSON.parse(localStorage.getItem(CREDENTIALS_INDEX_KEY) || "[]") as string[] + return index +} diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 5505f4e4d0c..01a508303ea 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2988,11 +2988,13 @@ version = "0.0.0" dependencies = [ "futures", "gtk", + "hex", "listeners", "reqwest", "semver", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index bcbf068bbbf..dd6bd604940 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -41,7 +41,8 @@ semver = "1.0.27" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } uuid = { version = "1.19.0", features = ["v4"] } tauri-plugin-decorum = "1.1.1" - +sha2 = "0.10" +hex = "0.4" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index aea730926a7..b546794f9c6 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -23,9 +23,12 @@ use tauri_plugin_store::StoreExt; use tokio::sync::oneshot; use crate::window_customizer::PinchZoomDisablePlugin; +use sha2::{Sha256, Digest}; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +const CREDENTIALS_INDEX_KEY: &str = "opencode.server.credentials.index"; +const CREDENTIALS_SERVICE_PREFIX: &str = "opencode.server.credentials"; #[derive(Clone, serde::Serialize)] struct ServerReadyData { @@ -137,6 +140,144 @@ async fn set_default_server_url(app: AppHandle, url: Option) -> Result<( Ok(()) } +fn hash_url(url: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(url.as_bytes()); + hex::encode(hasher.finalize()) +} + +#[tauri::command] +async fn store_server_credentials( + app: AppHandle, + url: String, + username: String, + password: String, +) -> Result<(), String> { + let url_hash = hash_url(&url); + let credentials_key = format!("{}:{}", CREDENTIALS_SERVICE_PREFIX, url_hash); + + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + // Store credentials + let credentials = serde_json::json!({ + "username": username, + "password": password + }); + store.set(&credentials_key, credentials); + + // Update index + let index_value = store.get(CREDENTIALS_INDEX_KEY); + let mut index: Vec = if let Some(v) = index_value { + if let Some(arr) = v.as_array() { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + } else { + vec![] + } + } else { + vec![] + }; + + if !index.contains(&url_hash) { + index.push(url_hash); + store.set(CREDENTIALS_INDEX_KEY, serde_json::Value::Array( + index.iter().map(|h| serde_json::Value::String(h.clone())).collect() + )); + } + + store.save().map_err(|e| format!("Failed to save credentials: {}", e))?; + + Ok(()) +} + +#[tauri::command] +async fn get_server_credentials(app: AppHandle, url: String) -> Result, String> { + let url_hash = hash_url(&url); + let credentials_key = format!("{}:{}", CREDENTIALS_SERVICE_PREFIX, url_hash); + + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let credentials_value = store.get(&credentials_key); + let credentials = match credentials_value { + Some(v) => v, + None => return Ok(None), + }; + + let username = credentials["username"] + .as_str() + .ok_or("Missing username in credentials")? + .to_string(); + let password = credentials["password"] + .as_str() + .ok_or("Missing password in credentials")? + .to_string(); + + Ok(Some((username, password))) +} + +#[tauri::command] +async fn remove_server_credentials(app: AppHandle, url: String) -> Result<(), String> { + let url_hash = hash_url(&url); + let credentials_key = format!("{}:{}", CREDENTIALS_SERVICE_PREFIX, url_hash); + + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + // Remove credentials + store.delete(&credentials_key); + + // Update index + let index_value = store.get(CREDENTIALS_INDEX_KEY); + let index: Vec = if let Some(v) = index_value { + if let Some(arr) = v.as_array() { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .filter(|h| h != &url_hash) + .collect() + } else { + vec![] + } + } else { + vec![] + }; + + store.set(CREDENTIALS_INDEX_KEY, serde_json::Value::Array( + index.iter().map(|h| serde_json::Value::String(h.clone())).collect() + )); + + store.save().map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + +#[tauri::command] +async fn list_server_credentials(app: AppHandle) -> Result, String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let index_value = store.get(CREDENTIALS_INDEX_KEY); + match index_value { + Some(v) => { + if let Some(arr) = v.as_array() { + Ok(arr + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect()) + } else { + Ok(vec![]) + } + } + None => Ok(vec![]), + } +} + fn get_sidecar_port() -> u32 { option_env!("OPENCODE_PORT") .map(|s| s.to_string()) @@ -283,7 +424,11 @@ pub fn run() { install_cli, ensure_server_ready, get_default_server_url, - set_default_server_url + set_default_server_url, + store_server_credentials, + get_server_credentials, + remove_server_credentials, + list_server_credentials ]) .setup(move |app| { let app = app.handle().clone(); diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index ac21b3c2866..fdcc7a7dc45 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -41,7 +41,7 @@ window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => { let update: Update | null = null -const createPlatform = (password: Accessor): Platform => ({ +const createPlatform = (password: Accessor, serverUrl: Accessor): Platform => ({ platform: "desktop", os: (() => { const type = ostype() @@ -290,17 +290,35 @@ const createPlatform = (password: Accessor): Platform => ({ // @ts-expect-error fetch: (input, init) => { const pw = password() + const desktopServerUrl = serverUrl()!.replace(/\/$/, "") const addHeader = (headers: Headers, password: string) => { headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) } if (input instanceof Request) { - if (pw) addHeader(input.headers, pw) + const url = input.url + const normalizedUrl = url.replace(/\/$/, "") + + if (normalizedUrl.startsWith(desktopServerUrl)) { + if (pw) addHeader(input.headers, pw) + } + return tauriFetch(input) } else { const headers = new Headers(init?.headers) - if (pw) addHeader(headers, pw) + let normalizedUrl: string + + if (input instanceof URL) { + normalizedUrl = input.toString().replace(/\/$/, "") + } else { + normalizedUrl = input.replace(/\/$/, "") + } + + if (desktopServerUrl && normalizedUrl.startsWith(desktopServerUrl)) { + if (pw) addHeader(headers, pw) + } + return tauriFetch(input, { ...(init as any), headers: headers, @@ -316,13 +334,35 @@ const createPlatform = (password: Accessor): Platform => ({ setDefaultServerUrl: async (url: string | null) => { await invoke("set_default_server_url", { url }) }, + + storeServerCredentials: async (url: string, username: string, password: string) => { + await invoke("store_server_credentials", { url, username, password }) + }, + + getServerCredentials: async (url: string) => { + const result = await invoke<[string, string] | null>("get_server_credentials", { url }) + if (!result) return null + return { username: result[0], password: result[1] } + }, + + removeServerCredentials: async (url: string) => { + await invoke("remove_server_credentials", { url }) + }, + + listServerCredentials: async () => { + return await invoke("list_server_credentials") + }, }) createMenu() render(() => { const [serverPassword, setServerPassword] = createSignal(null) - const platform = createPlatform(() => serverPassword()) + const [serverUrl, setServerUrl] = createSignal(null) + const platform = createPlatform( + () => serverPassword(), + () => serverUrl(), + ) function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null @@ -345,6 +385,7 @@ render(() => { {(data) => { setServerPassword(data().password) + setServerUrl(data().url) window.__OPENCODE__ ??= {} window.__OPENCODE__.serverPassword = data().password ?? undefined diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index fa646f21ea8..755e9da6ac2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -77,7 +77,29 @@ export namespace Server { status: 500, }) }) + .use( + cors({ + origin(input) { + if (!input) return + + if (input.startsWith("http://localhost:")) return input + if (input.startsWith("http://127.0.0.1:")) return input + if (input === "tauri://localhost" || input === "http://tauri.localhost") return input + + // *.opencode.ai (https only, adjust if needed) + if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { + return input + } + if (_corsWhitelist.includes(input)) { + return input + } + + return + }, + }), + ) .use((c, next) => { + if (c.req.path === "/global/health") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"