Skip to content

Commit f6353a4

Browse files
authored
perf(desktop): replace electron-store with a plain JSON settings store (#49791)
1 parent 4facc03 commit f6353a4

4 files changed

Lines changed: 97 additions & 41 deletions

File tree

‎bun.lock‎

Lines changed: 0 additions & 25 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/desktop/package.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
"@zip.js/zip.js": "2.7.62",
2929
"electron-context-menu": "4.1.2",
3030
"electron-log": "^5",
31-
"electron-store": "11.0.2",
3231
"electron-updater": "6.8.9",
3332
"lighthouse": "13.4.1"
3433
},
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
3+
import { tmpdir } from "node:os"
4+
import path from "node:path"
5+
import { createSettingsStore } from "./store"
6+
7+
const dir = () => mkdtempSync(path.join(tmpdir(), "opencode-settings-"))
8+
9+
describe("settings store", () => {
10+
test("reads the tab-indented file electron-store wrote and rewrites it in the same shape", () => {
11+
const file = path.join(dir(), "opencode.settings")
12+
writeFileSync(file, JSON.stringify({ windowIds: ["a"], backgroundColor: "#121212" }, null, "\t"))
13+
const store = createSettingsStore(file)
14+
expect(store.get("windowIds")).toEqual(["a"])
15+
store.set("backgroundColor", "#ffffff")
16+
expect(readFileSync(file, "utf8")).toBe(
17+
JSON.stringify({ windowIds: ["a"], backgroundColor: "#ffffff" }, null, "\t"),
18+
)
19+
expect(createSettingsStore(file).get("backgroundColor")).toBe("#ffffff")
20+
})
21+
22+
test("starts empty without a file and creates it on the first write", () => {
23+
const file = path.join(dir(), "nested", "opencode.updater")
24+
const store = createSettingsStore(file)
25+
expect(store.get("ready")).toBeUndefined()
26+
expect(existsSync(file)).toBe(false)
27+
store.set("ready", { version: "1.0.0" })
28+
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ ready: { version: "1.0.0" } })
29+
store.delete("ready")
30+
expect(store.get("ready")).toBeUndefined()
31+
expect(readFileSync(file, "utf8")).toBe("{}")
32+
})
33+
34+
test("sets an unreadable file aside instead of failing", () => {
35+
const file = path.join(dir(), "opencode.settings")
36+
writeFileSync(file, "{ not json")
37+
const store = createSettingsStore(file)
38+
expect(store.get("anything")).toBeUndefined()
39+
expect(existsSync(`${file}.corrupt`)).toBe(true)
40+
store.set("firstLaunchOnboardingComplete", true)
41+
expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ firstLaunchOnboardingComplete: true })
42+
})
43+
44+
test("rejects undefined like electron-store did", () => {
45+
const store = createSettingsStore(path.join(dir(), "opencode.settings"))
46+
expect(() => store.set("key", undefined)).toThrow(TypeError)
47+
})
48+
})
Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,62 @@
1-
import Store from "electron-store"
1+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
2+
import path from "node:path"
23
import electron from "electron"
34

45
import { SETTINGS_STORE } from "./keys"
56

6-
const cache = new Map<string, Store>()
7+
const cache = new Map<string, SettingsStore>()
8+
9+
export type SettingsStore = ReturnType<typeof createSettingsStore>
710

811
// Main-process settings only (onboarding, default server, window list, appearance, updater).
9-
// These are read synchronously before the storage layer exists and written on user action, so
10-
// electron-store's synchronous file write is acceptable here. Renderer state goes through
11-
// DesktopStorage instead.
12+
// These are read synchronously before the storage layer exists and written on user action, so a
13+
// synchronous whole-file write is acceptable here. Renderer state goes through DesktopStorage.
14+
//
15+
// The file is the tab-indented JSON object electron-store used to write, so existing profiles
16+
// (and older builds) read the same data.
1217
//
13-
// We cannot instantiate the electron-store at module load time because
14-
// module import hoisting causes this to run before app.setPath("userData", ...)
15-
// in index.ts has executed, which would result in files being written to the default directory
16-
// (e.g. bad: %APPDATA%\@opencode\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
18+
// We cannot instantiate the store at module load time because module import hoisting causes this
19+
// to run before app.setPath("userData", ...) has executed, which would result in files being
20+
// written to the default directory (e.g. bad: %APPDATA%\@opencode\desktop\opencode.settings vs
21+
// good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
1722
export function getStore(name = SETTINGS_STORE) {
1823
const cached = cache.get(name)
1924
if (cached) return cached
20-
const next = new Store({
21-
name,
22-
cwd: electron.app.getPath("userData"),
23-
fileExtension: "",
24-
accessPropertiesByDotNotation: false,
25-
})
25+
const next = createSettingsStore(path.join(electron.app.getPath("userData"), name))
2626
cache.set(name, next)
2727
return next
2828
}
29+
30+
export function createSettingsStore(file: string) {
31+
let data = read(file)
32+
const write = () => {
33+
mkdirSync(path.dirname(file), { recursive: true })
34+
writeFileSync(`${file}.tmp`, JSON.stringify(data, null, "\t"))
35+
renameSync(`${file}.tmp`, file)
36+
}
37+
return {
38+
path: file,
39+
get: (key: string): unknown => data[key],
40+
set: (key: string, value: unknown) => {
41+
if (value === undefined) throw new TypeError(`Use \`delete()\` to clear values: ${key}`)
42+
data = { ...data, [key]: value }
43+
write()
44+
},
45+
delete: (key: string) => {
46+
data = Object.fromEntries(Object.entries(data).filter(([item]) => item !== key))
47+
write()
48+
},
49+
}
50+
}
51+
52+
// A missing file is an empty store. An unreadable one is set aside instead of blocking startup.
53+
function read(file: string): Record<string, unknown> {
54+
if (!existsSync(file)) return {}
55+
try {
56+
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"))
57+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
58+
} catch {
59+
renameSync(file, `${file}.corrupt`)
60+
return {}
61+
}
62+
}

0 commit comments

Comments
 (0)