diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 33e579a51e..0ff9a76881 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -40,6 +40,7 @@ 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 = { dev: "OpenCode Dev", @@ -242,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() diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index c6369bc0d1..22560ff7da 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -8,7 +8,7 @@ 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 { 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) diff --git a/packages/desktop/src/main/store-cleanup.test.ts b/packages/desktop/src/main/store-cleanup.test.ts new file mode 100644 index 0000000000..40e72afb57 --- /dev/null +++ b/packages/desktop/src/main/store-cleanup.test.ts @@ -0,0 +1,98 @@ +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"]) + }) +}) diff --git a/packages/desktop/src/main/store-cleanup.ts b/packages/desktop/src/main/store-cleanup.ts new file mode 100644 index 0000000000..3d1f93389d --- /dev/null +++ b/packages/desktop/src/main/store-cleanup.ts @@ -0,0 +1,96 @@ +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() + 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 + } +} diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts index b99497f9b1..e0cc365931 100644 --- a/packages/desktop/src/main/store.ts +++ b/packages/desktop/src/main/store.ts @@ -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() @@ -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) +}