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
commit 8c94e9005f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
590 changed files with 15772 additions and 5530 deletions

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: () => {

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

View file

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

View file

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

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"

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

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

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)

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

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

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"})`
}

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)

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

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!)