mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
deeplinks + dev fixes
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -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:",
|
||||
|
||||
2
packages/desktop-electron/.gitignore
vendored
2
packages/desktop-electron/.gitignore
vendored
@@ -23,3 +23,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
out/
|
||||
|
||||
resources/sidecars
|
||||
|
||||
@@ -28,6 +28,11 @@ mac:
|
||||
dmg:
|
||||
sign: false
|
||||
|
||||
protocols:
|
||||
name: OpenCode
|
||||
schemes:
|
||||
- opencode
|
||||
|
||||
win:
|
||||
icon: resources/icons/icon.ico
|
||||
target:
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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}`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
85
packages/desktop-electron/src/main/migrate.ts
Normal file
85
packages/desktop-electron/src/main/migrate.ts
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)})`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user