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:
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
@@ -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",
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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!)
|
||||
|
||||
Reference in New Issue
Block a user