deeplinks + dev fixes

This commit is contained in:
Brendan Allan
2026-02-23 16:48:46 +08:00
parent 18baa0e2d9
commit dd434b753c
11 changed files with 220 additions and 72 deletions

View File

@@ -482,7 +482,6 @@
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
@@ -504,6 +503,7 @@
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",

View File

@@ -23,3 +23,5 @@ dist-ssr
*.sln
*.sw?
out/
resources/sidecars

View File

@@ -28,6 +28,11 @@ mac:
dmg:
sign: false
protocols:
name: OpenCode
schemes:
- opencode
win:
icon: resources/icons/icon.ico
target:

View File

@@ -19,3 +19,5 @@ await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
await $`rm -rf ${dir}`

View File

@@ -46,8 +46,9 @@ export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) {
}
export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) {
await $`mkdir -p node_modules/.bin`
const dest = windowsify("node_modules/.bin/opencode-cli")
const dir = `resources/sidecars/${target}`
await $`mkdir -p ${dir}`
const dest = windowsify(`${dir}/opencode-cli`)
await $`cp ${source} ${dest}`
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`

View File

@@ -1,9 +1,6 @@
import { app, BrowserWindow, dialog } from "electron"
app.setName(app.isPackaged ? "OpenCode" : "OpenCode Dev")
import type { Event } from "electron"
import pkg from "electron-updater"
const { autoUpdater } = pkg
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
@@ -11,11 +8,18 @@ import { homedir } from "node:os"
import { join } from "node:path"
import { createServer } from "node:net"
app.setName(app.isPackaged ? "OpenCode" : "OpenCode Dev")
app.setPath(
"userData",
join(app.getPath("appData"), app.isPackaged ? "ai.opencode.desktop" : "ai.opencode.desktop.dev"),
)
const { autoUpdater } = pkg
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { getConfig, installCli, syncCli } from "./cli"
import { installCli, syncCli } from "./cli"
import { UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging, tail } from "./logging"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
@@ -33,6 +37,18 @@ import { createLoadingWindow, createMainWindow, setDockIcon } from "./windows"
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
import type { CommandChild } from "./cli"
type ServerConnection =
| { variant: "existing"; url: string }
| {
variant: "cli"
url: string
password: null | string
health: {
wait: Promise<void>
}
events: any
}
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
@@ -46,6 +62,8 @@ const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
const logger = initLogging()
logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged })
setupApp()
function setupApp() {
@@ -59,12 +77,16 @@ function setupApp() {
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
if (urls.length) emitDeepLinks(urls)
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
}
focusMainWindow()
})
app.on("open-url", (event: Event, url: string) => {
event.preventDefault()
logger.log("deep link received via open-url", { url })
emitDeepLinks([url])
})
@@ -73,6 +95,7 @@ function setupApp() {
})
void app.whenReady().then(async () => {
// migrate()
app.setAsDefaultProtocolClient("opencode")
setDockIcon()
setupAutoUpdater()
@@ -95,76 +118,98 @@ function focusMainWindow() {
function setInitStep(step: InitStep) {
initStep = step
logger.log("init step", { step })
initEmitter.emit("step", step)
}
async function setupServerConnection(): Promise<ServerConnection> {
const customUrl = await getSavedServerUrl()
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
serverReady.resolve({ url: customUrl, password: null })
return { variant: "existing", url: customUrl }
}
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const localUrl = `http://${hostname}:${port}`
if (await checkHealth(localUrl)) {
serverReady.resolve({ url: localUrl, password: null })
return { variant: "existing", url: localUrl }
}
const password = randomUUID()
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
return {
variant: "cli",
url: localUrl,
password,
health,
events,
}
}
async function initialize() {
const config = await getConfig().catch(() => null)
const customUrl = await getSavedServerUrl(config)
const needsMigration = !sqliteFileExists()
const sqliteDone = needsMigration ? defer<void>() : undefined
const init = (async () => {
if (customUrl && (await checkHealthOrAskRetry(customUrl))) {
serverReady.resolve({ url: customUrl, password: null })
return
}
const loadingTask = (async () => {
logger.log("setting up server connection")
const serverConnection = await setupServerConnection()
logger.log("server connection ready", { variant: serverConnection.variant, url: serverConnection.url })
const port = await getSidecarPort()
const hostname = "127.0.0.1"
const localUrl = `http://${hostname}:${port}`
if (await checkHealth(localUrl)) {
serverReady.resolve({ url: localUrl, password: null })
return
}
const password = randomUUID()
const { child, health, events } = spawnLocalServer(hostname, port, password)
sidecar = child
const needsMigration = !sqliteFileExists()
const sqliteDone = defer<void>()
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone.resolve()
})
const healthTask = (async () => {
if (needsMigration) await sqliteDone.promise
await health.wait
const cliHealthCheck = (() => {
if (serverConnection.variant == "cli") {
return async () => {
const { events, health } = serverConnection
events.on("sqlite", (progress: SqliteMigrationProgress) => {
setInitStep({ phase: "sqlite_waiting" })
if (loadingWindow) sendSqliteMigrationProgress(loadingWindow, progress)
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
if (progress.type === "Done") sqliteDone?.resolve()
})
await health.wait
serverReady.resolve({ url: serverConnection.url, password: serverConnection.password })
}
} else {
serverReady.resolve({ url: serverConnection.url, password: null })
return null
}
})()
try {
await healthTask
} catch (error) {
serverReady.reject(new Error(`Failed to spawn OpenCode Server (${String(error)}). Logs:\n${tail()}`))
return
logger.log("server connection started")
if (cliHealthCheck) {
if (needsMigration) await sqliteDone?.promise
cliHealthCheck?.()
}
serverReady.resolve({ url: localUrl, password })
logger.log("loading task finished")
})()
let showLoading = false
if (!sqliteFileExists()) {
showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
}
const globals = {
updaterEnabled: UPDATER_ENABLED,
wsl: getWslConfig().enabled,
deepLinks: pendingDeepLinks.splice(0),
deepLinks: pendingDeepLinks,
}
if (showLoading) {
loadingWindow = createLoadingWindow(globals)
} else {
mainWindow = createMainWindow(globals)
wireMenu()
}
const loadingWindow = await (async () => {
if (needsMigration /** TOOD: 1 second timeout */) {
// showLoading = await Promise.race([init.then(() => false).catch(() => false), delay(1000).then(() => true)])
const loadingWindow = createLoadingWindow(globals)
await delay(1000)
return loadingWindow
} else {
logger.log("showing main window without loading window")
mainWindow = createMainWindow(globals)
wireMenu()
}
})()
await init
await loadingTask
setInitStep({ phase: "done" })
if (loadingWindow) {
@@ -176,10 +221,7 @@ async function initialize() {
wireMenu()
}
if (loadingWindow) {
loadingWindow.close()
loadingWindow = null
}
loadingWindow?.close()
}
function wireMenu() {
@@ -209,7 +251,10 @@ registerIpcHandlers({
const listener = (step: InitStep) => sendStep(step)
initEmitter.on("step", listener)
try {
return await serverReady.promise
logger.log("awaiting server ready")
const res = await serverReady.promise
logger.log("server ready", { url: res.url })
return res
} finally {
initEmitter.off("step", listener)
}

View File

@@ -1,3 +1,4 @@
import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
@@ -117,8 +118,13 @@ export function registerIpcHandlers(deps: Deps) {
void shell.openExternal(url)
})
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, _app?: string) => {
await shell.openPath(path)
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
if (!app) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const [cmd, args] =
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
})
})
ipcMain.handle("read-clipboard-image", () => {

View File

@@ -0,0 +1,85 @@
import { app } from "electron"
import log from "electron-log/main.js"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { getStore, store } from "./store"
const TAURI_MIGRATED_KEY = "tauriMigrated"
// Resolve the directory where Tauri stored its .dat files for the given app identifier.
// Mirrors Tauri's AppLocalData / AppData resolution per OS.
function tauriDir(id: string) {
switch (process.platform) {
case "darwin":
return join(homedir(), "Library", "Application Support", id)
case "win32":
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id)
default:
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id)
}
}
// The Tauri app identifier changes between dev and prod builds.
function tauriAppId() {
return app.isPackaged ? "ai.opencode.desktop" : "ai.opencode.desktop.dev"
}
// Migrate a single Tauri .dat file into the corresponding electron-store.
// `opencode.settings.dat` is special: it maps to the `opencode.settings` store
// (the electron-store name without the `.dat` extension). All other .dat files
// keep their full filename as the electron-store name so they match what the
// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`).
function migrateFile(datPath: string, filename: string) {
let data: Record<string, unknown>
try {
data = JSON.parse(readFileSync(datPath, "utf-8"))
} catch (err) {
log.warn("tauri migration: failed to parse", filename, err)
return
}
// opencode.settings.dat → the electron settings store ("opencode.settings").
// All other .dat files keep their full filename as the store name so they match
// what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat").
const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename
const target = getStore(storeName)
const migrated: string[] = []
const skipped: string[] = []
for (const [key, value] of Object.entries(data)) {
// Don't overwrite values the user has already set in the Electron app.
if (target.has(key)) {
skipped.push(key)
continue
}
target.set(key, value)
migrated.push(key)
}
log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped })
}
export function migrate() {
if (store.get(TAURI_MIGRATED_KEY)) {
log.log("tauri migration: already done, skipping")
return
}
const dir = tauriDir(tauriAppId())
log.log("tauri migration: starting", { dir })
if (!existsSync(dir)) {
log.log("tauri migration: no tauri data directory found, nothing to migrate")
store.set(TAURI_MIGRATED_KEY, true)
return
}
for (const filename of readdirSync(dir)) {
if (!filename.endsWith(".dat")) continue
migrateFile(join(dir, filename), filename)
}
log.log("tauri migration: complete")
store.set(TAURI_MIGRATED_KEY, true)
}

View File

@@ -1,6 +1,6 @@
import { dialog } from "electron"
import { serve, type CommandChild, type Config } from "./cli"
import { getConfig, serve, type CommandChild, type Config } from "./cli"
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
import { store } from "./store"
@@ -31,10 +31,11 @@ export function setWslConfig(config: WslConfig) {
store.set(WSL_ENABLED_KEY, config.enabled)
}
export async function getSavedServerUrl(config: Config | null): Promise<string | null> {
export async function getSavedServerUrl(): Promise<string | null> {
const direct = getDefaultServerUrl()
if (direct) return direct
const config = await getConfig().catch(() => null)
if (!config) return null
return getServerUrlFromConfig(config)
}

View File

@@ -120,10 +120,11 @@ function loadWindow(win: BrowserWindow, html: string) {
function injectGlobals(win: BrowserWindow, globals: Globals) {
win.webContents.on("dom-ready", () => {
const deepLinks = globals.deepLinks ?? []
const data = {
updaterEnabled: globals.updaterEnabled,
wsl: globals.wsl,
deepLinks: globals.deepLinks ?? [],
deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks,
}
void win.webContents.executeJavaScript(
`window.__OPENCODE__ = Object.assign(window.__OPENCODE__ ?? {}, ${JSON.stringify(data)})`,

View File

@@ -34,6 +34,7 @@
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
@@ -50,7 +51,6 @@
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",