chore: merge dev into v2 (#34788)

Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
Co-authored-by: Max Anderson <max.a.anderson95@gmail.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
This commit is contained in:
James Long
2026-07-01 17:12:00 -04:00
committed by GitHub
co-authored by Brendan Allan Kit Langton opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Affan Ali affanali2k3 Frank opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 Aiden Cline Jay V Dax Raad Aarav Sareen OpeOginni Luke Parker Ben Guthrie Dax Filip Max Anderson Brendan Allan Jack Shoubhit Dash Dustin Deus starptech Aiden Cline usrnk1 Jay runvip opencode Julian Coy Vladimir Glafirov
parent 932a40cfd9
commit 8c94e9005f
590 changed files with 15772 additions and 5530 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.11",
"version": "1.17.13",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+29 -10
View File
@@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
import { app, BrowserWindow } from "electron"
import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import contextMenu from "electron-context-menu"
@@ -28,16 +28,19 @@ import {
} from "./server"
import { setupAutoUpdater, showUpdaterDialog } from "./updater"
import {
createMainWindow,
getLastFocusedWindow,
registerRendererProtocol,
setRelaunchHandler,
setAppQuitting,
setBackgroundColor,
setDockIcon,
restoreMainWindows,
} from "./windows"
import { createWslServersController } from "./wsl/servers"
import { registerWslIpcHandlers } from "./wsl/ipc"
import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@@ -53,7 +56,6 @@ const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging>
let mainWindow: BrowserWindow | null = null
let server: SidecarListener | null = null
const pendingDeepLinks: string[] = []
@@ -70,7 +72,8 @@ function useEnvProxy() {
function emitDeepLinks(urls: string[]) {
if (urls.length === 0) return
pendingDeepLinks.push(...urls)
if (mainWindow) sendDeepLinks(mainWindow, urls)
const win = getLastFocusedWindow()
if (win) sendDeepLinks(win, urls)
}
async function killSidecar() {
@@ -194,9 +197,10 @@ const main = Effect.gen(function* () {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
}
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
const win = getLastFocusedWindow()
if (win) {
win.show()
win.focus()
}
})
@@ -207,10 +211,12 @@ const main = Effect.gen(function* () {
})
app.on("before-quit", () => {
setAppQuitting()
void stopSidecars()
})
app.on("will-quit", () => {
setAppQuitting()
void stopSidecars()
})
@@ -237,6 +243,19 @@ const main = Effect.gen(function* () {
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result.deleted.length === 0) return
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
}),
),
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to clean scoped store files", error)
}),
),
)
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
@@ -347,11 +366,11 @@ const main = Effect.gen(function* () {
yield* Fiber.await(loadingTask)
mainWindow = createMainWindow()
if (mainWindow) {
const windows = restoreMainWindows()
if (windows.length) {
createMenu({
trigger: (id) => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => {
+12 -2
View File
@@ -8,8 +8,8 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore } from "./store"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
@@ -91,9 +91,11 @@ export function registerIpcHandlers(deps: Deps) {
})
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
getStore(name).delete(key)
void removeStoreFileIfEmpty(name)
})
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
getStore(name).clear()
void removeStoreFileIfEmpty(name)
})
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
const store = getStore(name)
@@ -190,6 +192,14 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
ipcMain.handle("get-window-id", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
})
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup"
const roots: string[] = []
async function tempRoot() {
const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-"))
roots.push(root)
return root
}
async function writeStore(root: string, name: string, value: string, modified: Date) {
await writeFile(join(root, name), value)
await utimes(join(root, name), modified, modified)
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe("store cleanup", () => {
test("removes empty scoped stores and leaves global stores alone", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await writeStore(root, "opencode.draft.empty.dat", "{}", now)
await writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
await writeStore(root, "opencode.global.dat", "{}", now)
await writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
const result = await cleanupStoreFiles(root, now.getTime())
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((await readdir(root)).sort()).toEqual(["opencode.global.dat", "opencode.workspace.empty.dat.json"])
})
test("removes stale drafts by age without removing non-empty workspace stores", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await writeStore(root, "opencode.draft.old.dat", '{"draft:prompt":"hello"}', new Date("2026-05-01T00:00:00.000Z"))
await writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
await writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
await writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = await cleanupStoreFiles(root, now.getTime())
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((await readdir(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
})
test("caps scoped stores by recency", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await Promise.all(
Array.from({ length: 102 }, (_, index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
),
)
const result = await cleanupStoreFiles(root, now.getTime())
const remaining = await readdir(root)
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
})
test("removes a scoped store immediately when it becomes empty", async () => {
const root = await tempRoot()
await writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
await writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
expect(await deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(await deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(await readdir(root)).toEqual(["opencode.global.dat"])
})
})
@@ -0,0 +1,94 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"
import { join } from "node:path"
const EMPTY_STORE_MAX_BYTES = 128
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
type StoreKind = "draft" | "workspace"
type StoreCandidate = {
name: string
path: string
kind: StoreKind
modified: number
empty: boolean
}
export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) {
const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => [])
const candidates = (
await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const kind = storeKind(entry.name)
if (!kind) return
const file = join(userDataPath, entry.name)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) return
return {
name: entry.name,
path: file,
kind,
modified: stats.mtimeMs,
empty: await isEmptyStore(file, stats.size),
}
}),
)
).filter((candidate) => !!candidate)
const stale = new Set<StoreCandidate>()
for (const candidate of candidates) {
if (candidate.empty) stale.add(candidate)
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
}
candidates
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
.sort((a, b) => b.modified - a.modified)
.slice(DRAFT_KEEP_RECENT)
.forEach((candidate) => stale.add(candidate))
const deleted = await Promise.all(
[...stale].map(async (candidate) => {
await rm(candidate.path, { force: true })
return candidate.name
}),
)
return { scanned: candidates.length, deleted }
}
export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
if (!storeKind(name)) return false
const file = join(userDataPath, name)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) return false
if (!(await isEmptyStore(file, stats.size))) return false
await rm(file, { force: true })
return true
}
function storeKind(name: string): StoreKind | undefined {
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
}
async function isEmptyStore(file: string, size: number) {
if (size > EMPTY_STORE_MAX_BYTES) return false
const raw = await readFile(file, "utf8").catch(() => undefined)
if (raw === undefined) return false
if (raw.trim() === "") return true
try {
const parsed = JSON.parse(raw) as unknown
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
} catch {
return false
}
}
+1
View File
@@ -2,3 +2,4 @@ export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const WINDOW_IDS_KEY = "windowIds"
+5
View File
@@ -2,6 +2,7 @@ import Store from "electron-store"
import electron from "electron"
import { SETTINGS_STORE } from "./store-keys"
import { deleteStoreFileIfEmpty } from "./store-cleanup"
const cache = new Map<string, Store>()
@@ -21,3 +22,7 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
export async function removeStoreFileIfEmpty(name: string) {
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
}
+73 -3
View File
@@ -2,13 +2,15 @@ import windowState from "electron-window-state"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../ui/src/theme/themes/oc-2.json"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
import { PINCH_ZOOM_ENABLED_KEY, WINDOW_IDS_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
@@ -42,8 +44,12 @@ let relaunchHandler = () => {
app.relaunch()
app.exit(0)
}
let appQuitting = false
let lastFocusedWindowID: string | undefined
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
const windowIDs = new WeakMap<BrowserWindow, string>()
const windowsByID = new Map<string, BrowserWindow>()
const titlebarHeight = 40
const maxZoomLevel = 10
const minZoomLevel = 0.2
@@ -52,6 +58,10 @@ export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}
export function setAppQuitting() {
appQuitting = true
}
export function setBackgroundColor(color: string) {
backgroundColor = color
BrowserWindow.getAllWindows().forEach((win) => win.setBackgroundColor(color))
@@ -111,14 +121,33 @@ export function getPinchZoomEnabled() {
return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
}
export function getWindowID(win: BrowserWindow) {
return windowIDs.get(win)
}
export function getLastFocusedWindow() {
const focused = BrowserWindow.getFocusedWindow()
if (focused) return focused
if (!lastFocusedWindowID) return null
const win = windowsByID.get(lastFocusedWindowID)
if (!win || win.isDestroyed()) return null
return win
}
export function restoreMainWindows() {
const ids = readWindowIDs()
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
}
export function setDockIcon() {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
export function createMainWindow() {
export function createMainWindow(id: string = randomUUID()) {
const state = windowState({
file: windowStateFile(id),
defaultWidth: 1280,
defaultHeight: 800,
})
@@ -156,7 +185,7 @@ export function createMainWindow() {
})
allowRendererPermissions(win)
wireWindowRecovery(win, "main")
wireWindowRecovery(win, id)
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
@@ -171,6 +200,7 @@ export function createMainWindow() {
})
state.manage(win)
registerWindow(win, id)
loadWindow(win, "index.html")
wireZoom(win)
@@ -181,6 +211,46 @@ export function createMainWindow() {
return win
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
windowsByID.set(id, win)
persistWindowID(id)
win.on("focus", () => {
lastFocusedWindowID = id
})
win.on("closed", () => {
windowsByID.delete(id)
if (lastFocusedWindowID === id) lastFocusedWindowID = windowsByID.keys().next().value
if (!appQuitting) removeWindowID(id)
})
}
function readWindowIDs() {
const value = getStore().get(WINDOW_IDS_KEY)
if (!Array.isArray(value)) return []
return value.filter((id): id is string => typeof id === "string" && id.length > 0)
}
function writeWindowIDs(ids: string[]) {
getStore().set(WINDOW_IDS_KEY, [...new Set(ids)])
}
function persistWindowID(id: string) {
const ids = readWindowIDs()
if (ids.includes(id)) return
writeWindowIDs([...ids, id])
}
function removeWindowID(id: string) {
writeWindowIDs(readWindowIDs().filter((item) => item !== id))
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
}
function windowStateFile(id: string) {
return `window-state-${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return
+4 -8
View File
@@ -1,7 +1,7 @@
import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString } from "./policy"
import { requireWslIpcString, requireWslIpcStrings } from "./policy"
import type { WslServersState } from "../../preload/types"
export function registerWslIpcHandlers(controller: WslServersController) {
@@ -46,11 +46,8 @@ export function registerWslIpcHandlers(controller: WslServersController) {
ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
controller.installDistro(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
controller.probeDistro(requireWslIpcString("distro", name)),
)
ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
controller.probeOpencode(requireWslIpcString("distro", name)),
ipcMain.handle("wsl-servers-probe-addable", (_event: IpcMainInvokeEvent, distros: string[]) =>
controller.probeAddable(requireWslIpcStrings("distro", distros)),
)
ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
controller.installOpencode(requireWslIpcString("distro", name)),
@@ -97,8 +94,7 @@ function registerUnavailableWslIpcHandlers() {
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
ipcMain.handle("wsl-servers-install-wsl", unavailable)
ipcMain.handle("wsl-servers-install-distro", unavailable)
ipcMain.handle("wsl-servers-probe-distro", unavailable)
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
ipcMain.handle("wsl-servers-probe-addable", unavailable)
ipcMain.handle("wsl-servers-install-opencode", unavailable)
ipcMain.handle("wsl-servers-open-terminal", unavailable)
ipcMain.handle("wsl-servers-add", unavailable)
+7
View File
@@ -24,3 +24,10 @@ export function requireWslIpcString(name: string, value: unknown) {
if (typeof value === "string" && value.length > 0) return value
throw new Error(`Invalid ${name}`)
}
export function requireWslIpcStrings(name: string, value: unknown) {
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
const values = value.map((item) => requireWslIpcString(name, item))
if (values.length > 0) return values
throw new Error(`Invalid ${name}`)
}
+65 -1
View File
@@ -1,5 +1,11 @@
import { expect, test } from "bun:test"
import { clearWslDistroState, requireWslIpcString, wslServerIdToRestart, wslTerminalArgs } from "./policy"
import {
clearWslDistroState,
requireWslIpcString,
requireWslIpcStrings,
wslServerIdToRestart,
wslTerminalArgs,
} from "./policy"
import {
expectOpencodeVersion,
pendingRestartAfterWslInstall,
@@ -87,8 +93,10 @@ test("stops health polling when sidecar startup settles", async () => {
test("validates WSL IPC identifiers at the module boundary", () => {
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"])
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro")
})
test("derives a required Windows restart from the post-install runtime probe", () => {
@@ -142,6 +150,62 @@ test("ignores stale startup OpenCode checks after removing a WSL server", async
expect(controller.getState().opencodeChecks).toEqual({})
})
test("probes addable distros in parallel before checking OpenCode", async () => {
persistedServers = []
const started: string[] = []
const release = new Map<string, () => void>()
const opencode: string[] = []
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
...testControllerOptions(),
probeDistro: async (distro) => {
started.push(distro)
await new Promise<void>((resolve) => release.set(distro, resolve))
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
},
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode"
},
})
const task = controller.probeAddable(["Debian", "Ubuntu"])
await waitFor(() => started.length === 2)
expect(started).toEqual(["Debian", "Ubuntu"])
expect(opencode).toEqual([])
release.get("Debian")?.()
release.get("Ubuntu")?.()
await task
expect(Object.keys(controller.getState().distroProbes)).toEqual(["Debian", "Ubuntu"])
expect(opencode).toEqual(["Debian", "Ubuntu"])
expect(Object.keys(controller.getState().opencodeChecks)).toEqual(["Debian", "Ubuntu"])
})
test("does not check OpenCode in addable distros that cannot execute commands", async () => {
persistedServers = []
const opencode: string[] = []
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
...testControllerOptions(),
probeDistro: async (distro) => ({
name: distro,
canExecute: distro === "Debian",
hasBash: distro === "Debian",
hasCurl: distro === "Debian",
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
}),
resolveOpencode: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode"
},
})
await controller.probeAddable(["Debian", "Ubuntu"])
expect(Object.keys(controller.getState().distroProbes)).toEqual(["Debian", "Ubuntu"])
expect(opencode).toEqual(["Debian"])
expect(Object.keys(controller.getState().opencodeChecks)).toEqual(["Debian"])
})
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 20; attempt++) {
if (check()) return
+33 -11
View File
@@ -47,6 +47,7 @@ type WslServersControllerOptions = {
logger?: ControllerLogger
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
probeDistro?: typeof probeWslDistro
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
}
@@ -70,6 +71,7 @@ export function createWslServersController(
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const probeDistro = options?.probeDistro ?? probeWslDistro
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -140,6 +142,28 @@ export function createWslServersController(
setOpencodeCheck(distro, await checkOpencode(distro, opts))
}
const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => {
const unique = [...new Set(distros)]
const distroProbes = await Promise.all(
unique
.filter((distro) => !state.distroProbes[distro])
.map(async (distro) => [distro, await probeDistro(distro, opts)] as const),
)
if (distroProbes.length) {
setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } })
}
const opencodeChecks = await Promise.all(
unique
.filter((distro) => distroProbeReady(state.distroProbes[distro]))
.filter((distro) => !state.opencodeChecks[distro])
.map(async (distro) => [distro, await checkOpencode(distro, opts)] as const),
)
if (opencodeChecks.length) {
setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } })
}
}
const hasServer = (id: string, distro: string) => {
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
}
@@ -319,7 +343,7 @@ export function createWslServersController(
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal })
const probe = await probeWslDistro(name, { signal: abort.signal })
const probe = await probeDistro(name, { signal: abort.signal })
setState({
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
@@ -327,16 +351,10 @@ export function createWslServersController(
})
},
async probeDistro(name: string) {
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
})
},
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
await refreshOpencodeCheck(name, { signal: abort.signal })
async probeAddable(distros: string[]) {
if (!distros.length) return
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => {
await probeAddableDistros(distros, { signal: abort.signal })
})
},
@@ -480,6 +498,10 @@ function opencodeCheck(
}
}
function distroProbeReady(probe: WslDistroProbe | undefined) {
return !!probe?.canExecute && probe.hasBash && probe.hasCurl
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
}
+2 -2
View File
@@ -29,8 +29,7 @@ const api: ElectronAPI = {
refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
probeAddable: (distros) => ipcRenderer.invoke("wsl-servers-probe-addable", distros),
installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
@@ -73,6 +72,7 @@ const api: ElectronAPI = {
storeLength: (name) => ipcRenderer.invoke("store-length", name),
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
getWindowID: () => ipcRenderer.invoke("get-window-id"),
onMenuCommand: (cb) => {
const handler = (_: unknown, id: string) => cb(id)
ipcRenderer.on("menu-command", handler)
+1
View File
@@ -62,6 +62,7 @@ export type ElectronAPI = {
storeLength: (name: string) => Promise<number>
getWindowCount: () => Promise<number>
getWindowID: () => Promise<string>
onMenuCommand: (cb: (id: string) => void) => () => void
onDeepLink: (cb: (urls: string[]) => void) => () => void
+65 -12
View File
@@ -17,7 +17,7 @@ import {
import type { UpdaterState } from "@opencode-ai/app/updater"
import * as Sentry from "@sentry/solid"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
@@ -64,6 +64,10 @@ void window.api.updater.subscribe(setUpdaterState)
const deepLinkEvent = "opencode:deep-link"
type DesktopWindowState = {
id?: string
}
const emitDeepLinks = (urls: string[]) => {
if (urls.length === 0) return
window.__OPENCODE__ ??= {}
@@ -77,7 +81,35 @@ const listenForDeepLinks = () => {
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
const createPlatform = (): Platform => {
function windowLastActiveUrlKey(windowID: string) {
return `opencode.desktop.window.${windowID}.last-active-url`
}
function getLastActiveUrl(windowID: string) {
if (typeof localStorage !== "object") return "/"
try {
const value = localStorage.getItem(windowLastActiveUrlKey(windowID))
if (value?.startsWith("/") && !value.startsWith("//")) return value
} catch {}
return "/"
}
function setLastActiveUrl(windowID: string, value: string) {
if (typeof localStorage !== "object") return
try {
localStorage.setItem(windowLastActiveUrlKey(windowID), value)
} catch {}
}
function DesktopMemoryRouter(props: BaseRouterProps & { windowID: string }) {
const history = createMemoryHistory()
const initialUrl = getLastActiveUrl(props.windowID)
if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false })
onCleanup(history.listen((value) => setLastActiveUrl(props.windowID, value)))
return <MemoryRouter {...props} history={history} />
}
const createPlatform = (windowState: DesktopWindowState): Platform => {
const attachmentPaths = new WeakMap<File, string>()
const os = (() => {
const ua = navigator.userAgent
@@ -136,6 +168,7 @@ const createPlatform = (): Platform => {
platform: "desktop",
os,
version: pkg.version,
windowID: windowState.id,
async openDirectoryPickerDialog(opts) {
return window.api.openDirectoryPicker({
@@ -282,8 +315,16 @@ window.api.onMenuCommand((id) => {
})
listenForDeepLinks()
render(() => {
const platform = createPlatform()
function LoadingSplash() {
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
}
function DesktopRoot(props: { windowState: DesktopWindowState }) {
const platform = createPlatform(props.windowState)
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
@@ -303,6 +344,9 @@ render(() => {
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale)
const router = (props: BaseRouterProps) => (
<DesktopMemoryRouter {...props} windowID={platform.windowID ?? "browser"} />
)
function handleClick(e: MouseEvent) {
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
@@ -332,12 +376,6 @@ render(() => {
function App() {
const wslServers = useWslServers()
const splash = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading,
)
@@ -364,10 +402,10 @@ render(() => {
)
return (
<Show when={ready()} fallback={splash}>
<Show when={ready()} fallback={<LoadingSplash />}>
<Show when={effectiveDefaultServer()} keyed>
{(key) => (
<AppInterface defaultServer={key} servers={servers()} router={MemoryRouter}>
<AppInterface defaultServer={key} servers={servers()} router={router}>
<Inner />
</AppInterface>
)}
@@ -390,4 +428,19 @@ render(() => {
</AppBaseProviders>
</PlatformProvider>
)
}
render(() => {
const [windowState] = createResource(async () => {
const api = window.api as typeof window.api & {
getWindowID?: () => Promise<string>
}
return { id: await api.getWindowID?.() }
})
return (
<Show when={windowState.latest} fallback={<LoadingSplash />} keyed>
{(state) => <DesktopRoot windowState={state} />}
</Show>
)
}, root!)