@neurocode-ai/desktop 1.18.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +19 -0
- package/electron-builder.config.test.ts +48 -0
- package/electron-builder.config.ts +146 -0
- package/electron.vite.config.ts +106 -0
- package/icons/README.md +14 -0
- package/icons/beta/128x128.png +0 -0
- package/icons/beta/128x128@2x.png +0 -0
- package/icons/beta/32x32.png +0 -0
- package/icons/beta/64x64.png +0 -0
- package/icons/beta/Square107x107Logo.png +0 -0
- package/icons/beta/Square142x142Logo.png +0 -0
- package/icons/beta/Square150x150Logo.png +0 -0
- package/icons/beta/Square284x284Logo.png +0 -0
- package/icons/beta/Square30x30Logo.png +0 -0
- package/icons/beta/Square310x310Logo.png +0 -0
- package/icons/beta/Square44x44Logo.png +0 -0
- package/icons/beta/Square71x71Logo.png +0 -0
- package/icons/beta/Square89x89Logo.png +0 -0
- package/icons/beta/StoreLogo.png +0 -0
- package/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/icons/beta/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/icons/beta/android/values/ic_launcher_background.xml +4 -0
- package/icons/beta/dock.png +0 -0
- package/icons/beta/icon.icns +0 -0
- package/icons/beta/icon.ico +0 -0
- package/icons/beta/icon.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@1x.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@2x-1.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@2x.png +0 -0
- package/icons/beta/ios/AppIcon-20x20@3x.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@1x.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@2x-1.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@2x.png +0 -0
- package/icons/beta/ios/AppIcon-29x29@3x.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@1x.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@2x-1.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@2x.png +0 -0
- package/icons/beta/ios/AppIcon-40x40@3x.png +0 -0
- package/icons/beta/ios/AppIcon-512@2x.png +0 -0
- package/icons/beta/ios/AppIcon-60x60@2x.png +0 -0
- package/icons/beta/ios/AppIcon-60x60@3x.png +0 -0
- package/icons/beta/ios/AppIcon-76x76@1x.png +0 -0
- package/icons/beta/ios/AppIcon-76x76@2x.png +0 -0
- package/icons/beta/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/icons/dev/128x128.png +0 -0
- package/icons/dev/128x128@2x.png +0 -0
- package/icons/dev/32x32.png +0 -0
- package/icons/dev/64x64.png +0 -0
- package/icons/dev/Square107x107Logo.png +0 -0
- package/icons/dev/Square142x142Logo.png +0 -0
- package/icons/dev/Square150x150Logo.png +0 -0
- package/icons/dev/Square284x284Logo.png +0 -0
- package/icons/dev/Square30x30Logo.png +0 -0
- package/icons/dev/Square310x310Logo.png +0 -0
- package/icons/dev/Square44x44Logo.png +0 -0
- package/icons/dev/Square71x71Logo.png +0 -0
- package/icons/dev/Square89x89Logo.png +0 -0
- package/icons/dev/StoreLogo.png +0 -0
- package/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/icons/dev/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/icons/dev/android/values/ic_launcher_background.xml +4 -0
- package/icons/dev/dock.png +0 -0
- package/icons/dev/icon.icns +0 -0
- package/icons/dev/icon.ico +0 -0
- package/icons/dev/icon.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@1x.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@2x-1.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@2x.png +0 -0
- package/icons/dev/ios/AppIcon-20x20@3x.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@1x.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@2x-1.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@2x.png +0 -0
- package/icons/dev/ios/AppIcon-29x29@3x.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@1x.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@2x-1.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@2x.png +0 -0
- package/icons/dev/ios/AppIcon-40x40@3x.png +0 -0
- package/icons/dev/ios/AppIcon-512@2x.png +0 -0
- package/icons/dev/ios/AppIcon-60x60@2x.png +0 -0
- package/icons/dev/ios/AppIcon-60x60@3x.png +0 -0
- package/icons/dev/ios/AppIcon-76x76@1x.png +0 -0
- package/icons/dev/ios/AppIcon-76x76@2x.png +0 -0
- package/icons/dev/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/icons/prod/128x128.png +0 -0
- package/icons/prod/128x128@2x.png +0 -0
- package/icons/prod/32x32.png +0 -0
- package/icons/prod/64x64.png +0 -0
- package/icons/prod/Square107x107Logo.png +0 -0
- package/icons/prod/Square142x142Logo.png +0 -0
- package/icons/prod/Square150x150Logo.png +0 -0
- package/icons/prod/Square284x284Logo.png +0 -0
- package/icons/prod/Square30x30Logo.png +0 -0
- package/icons/prod/Square310x310Logo.png +0 -0
- package/icons/prod/Square44x44Logo.png +0 -0
- package/icons/prod/Square71x71Logo.png +0 -0
- package/icons/prod/Square89x89Logo.png +0 -0
- package/icons/prod/StoreLogo.png +0 -0
- package/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/icons/prod/android/mipmap-hdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-mdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-xhdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png +0 -0
- package/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/icons/prod/android/values/ic_launcher_background.xml +4 -0
- package/icons/prod/dock.png +0 -0
- package/icons/prod/icon.icns +0 -0
- package/icons/prod/icon.ico +0 -0
- package/icons/prod/icon.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@1x.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@2x-1.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@2x.png +0 -0
- package/icons/prod/ios/AppIcon-20x20@3x.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@1x.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@2x-1.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@2x.png +0 -0
- package/icons/prod/ios/AppIcon-29x29@3x.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@1x.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@2x-1.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@2x.png +0 -0
- package/icons/prod/ios/AppIcon-40x40@3x.png +0 -0
- package/icons/prod/ios/AppIcon-512@2x.png +0 -0
- package/icons/prod/ios/AppIcon-60x60@2x.png +0 -0
- package/icons/prod/ios/AppIcon-60x60@3x.png +0 -0
- package/icons/prod/ios/AppIcon-76x76@1x.png +0 -0
- package/icons/prod/ios/AppIcon-76x76@2x.png +0 -0
- package/icons/prod/ios/AppIcon-83.5x83.5@2x.png +0 -0
- package/package.json +75 -0
- package/resources/entitlements.plist +18 -0
- package/resources/linux/opencode-desktop.desktop +10 -0
- package/scripts/copy-bundles.ts +12 -0
- package/scripts/copy-icons.ts +12 -0
- package/scripts/copy-metainfo.ts +47 -0
- package/scripts/finalize-latest-json.ts +219 -0
- package/scripts/finalize-latest-yml.ts +124 -0
- package/scripts/prebuild.ts +10 -0
- package/scripts/predev.ts +5 -0
- package/scripts/prepare.ts +9 -0
- package/scripts/utils.ts +77 -0
- package/src/main/apps.ts +136 -0
- package/src/main/attachment-picker.test.ts +87 -0
- package/src/main/attachment-picker.ts +56 -0
- package/src/main/constants.ts +7 -0
- package/src/main/debug.ts +95 -0
- package/src/main/desktop-menu-actions.ts +84 -0
- package/src/main/env.d.ts +19 -0
- package/src/main/index.test.ts +37 -0
- package/src/main/index.ts +399 -0
- package/src/main/initialization.ts +6 -0
- package/src/main/install-state.test.ts +19 -0
- package/src/main/install-state.ts +8 -0
- package/src/main/ipc.ts +274 -0
- package/src/main/logging.ts +205 -0
- package/src/main/markdown.ts +16 -0
- package/src/main/menu.ts +67 -0
- package/src/main/migrate.ts +91 -0
- package/src/main/onboarding.ts +45 -0
- package/src/main/server.ts +238 -0
- package/src/main/shell-env.test.ts +50 -0
- package/src/main/shell-env.ts +101 -0
- package/src/main/sidecar.ts +157 -0
- package/src/main/store-cleanup.test.ts +93 -0
- package/src/main/store-cleanup.ts +94 -0
- package/src/main/store-keys.ts +7 -0
- package/src/main/store.ts +35 -0
- package/src/main/unresponsive.ts +70 -0
- package/src/main/updater-controller.test.ts +111 -0
- package/src/main/updater-controller.ts +97 -0
- package/src/main/updater-subscriptions.test.ts +16 -0
- package/src/main/updater-subscriptions.ts +20 -0
- package/src/main/updater.ts +85 -0
- package/src/main/window-registry.test.ts +91 -0
- package/src/main/window-registry.ts +47 -0
- package/src/main/window-state.ts +29 -0
- package/src/main/windows.ts +496 -0
- package/src/main/wsl/ipc.ts +103 -0
- package/src/main/wsl/policy.ts +33 -0
- package/src/main/wsl/runtime.ts +400 -0
- package/src/main/wsl/servers.test.ts +231 -0
- package/src/main/wsl/servers.ts +521 -0
- package/src/main/wsl/sidecar.ts +129 -0
- package/src/main/wsl/startup.ts +31 -0
- package/src/preload/index.ts +128 -0
- package/src/preload/types.ts +109 -0
- package/src/renderer/cli.ts +12 -0
- package/src/renderer/env.d.ts +10 -0
- package/src/renderer/html.test.ts +62 -0
- package/src/renderer/i18n/ar.ts +26 -0
- package/src/renderer/i18n/br.ts +27 -0
- package/src/renderer/i18n/bs.ts +28 -0
- package/src/renderer/i18n/da.ts +28 -0
- package/src/renderer/i18n/de.ts +28 -0
- package/src/renderer/i18n/en.ts +27 -0
- package/src/renderer/i18n/es.ts +27 -0
- package/src/renderer/i18n/fr.ts +28 -0
- package/src/renderer/i18n/index.ts +194 -0
- package/src/renderer/i18n/ja.ts +28 -0
- package/src/renderer/i18n/ko.ts +27 -0
- package/src/renderer/i18n/no.ts +28 -0
- package/src/renderer/i18n/pl.ts +28 -0
- package/src/renderer/i18n/ru.ts +27 -0
- package/src/renderer/i18n/uk.ts +28 -0
- package/src/renderer/i18n/zh.ts +26 -0
- package/src/renderer/i18n/zht.ts +26 -0
- package/src/renderer/index.html +21 -0
- package/src/renderer/index.tsx +464 -0
- package/src/renderer/initialization.test.ts +73 -0
- package/src/renderer/initialization.ts +22 -0
- package/src/renderer/onboarding.tsx +53 -0
- package/src/renderer/styles.css +0 -0
- package/src/renderer/webview-zoom.ts +138 -0
- package/src/renderer/wsl/connections.test.ts +43 -0
- package/src/renderer/wsl/connections.ts +28 -0
- package/sst-env.d.ts +10 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readdir, readFile, rm, stat } from "node:fs/promises"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
const EMPTY_STORE_MAX_BYTES = 128
|
|
5
|
+
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
|
6
|
+
const DRAFT_KEEP_RECENT = 100
|
|
7
|
+
|
|
8
|
+
type StoreKind = "draft" | "workspace"
|
|
9
|
+
type StoreCandidate = {
|
|
10
|
+
name: string
|
|
11
|
+
path: string
|
|
12
|
+
kind: StoreKind
|
|
13
|
+
modified: number
|
|
14
|
+
empty: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) {
|
|
18
|
+
const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => [])
|
|
19
|
+
const candidates = (
|
|
20
|
+
await Promise.all(
|
|
21
|
+
entries
|
|
22
|
+
.filter((entry) => entry.isFile())
|
|
23
|
+
.map(async (entry) => {
|
|
24
|
+
const kind = storeKind(entry.name)
|
|
25
|
+
if (!kind) return
|
|
26
|
+
|
|
27
|
+
const file = join(userDataPath, entry.name)
|
|
28
|
+
const stats = await stat(file).catch(() => undefined)
|
|
29
|
+
if (!stats?.isFile()) return
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
name: entry.name,
|
|
33
|
+
path: file,
|
|
34
|
+
kind,
|
|
35
|
+
modified: stats.mtimeMs,
|
|
36
|
+
empty: await isEmptyStore(file, stats.size),
|
|
37
|
+
}
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
).filter((candidate) => !!candidate)
|
|
41
|
+
|
|
42
|
+
const stale = new Set<StoreCandidate>()
|
|
43
|
+
for (const candidate of candidates) {
|
|
44
|
+
if (candidate.empty) stale.add(candidate)
|
|
45
|
+
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
candidates
|
|
49
|
+
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
|
|
50
|
+
.sort((a, b) => b.modified - a.modified)
|
|
51
|
+
.slice(DRAFT_KEEP_RECENT)
|
|
52
|
+
.forEach((candidate) => stale.add(candidate))
|
|
53
|
+
|
|
54
|
+
const deleted = await Promise.all(
|
|
55
|
+
[...stale].map(async (candidate) => {
|
|
56
|
+
await rm(candidate.path, { force: true })
|
|
57
|
+
return candidate.name
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return { scanned: candidates.length, deleted }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
|
|
65
|
+
if (!storeKind(name)) return false
|
|
66
|
+
|
|
67
|
+
const file = join(userDataPath, name)
|
|
68
|
+
const stats = await stat(file).catch(() => undefined)
|
|
69
|
+
if (!stats?.isFile()) return false
|
|
70
|
+
if (!(await isEmptyStore(file, stats.size))) return false
|
|
71
|
+
|
|
72
|
+
await rm(file, { force: true })
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function storeKind(name: string): StoreKind | undefined {
|
|
77
|
+
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
|
|
78
|
+
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function isEmptyStore(file: string, size: number) {
|
|
82
|
+
if (size > EMPTY_STORE_MAX_BYTES) return false
|
|
83
|
+
|
|
84
|
+
const raw = await readFile(file, "utf8").catch(() => undefined)
|
|
85
|
+
if (raw === undefined) return false
|
|
86
|
+
if (raw.trim() === "") return true
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(raw) as unknown
|
|
90
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const SETTINGS_STORE = "neurocode.settings"
|
|
2
|
+
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
|
3
|
+
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
|
|
4
|
+
export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible"
|
|
5
|
+
export const WSL_SERVERS_KEY = "wslServers"
|
|
6
|
+
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
|
7
|
+
export const WINDOW_IDS_KEY = "windowIds"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Store from "electron-store"
|
|
2
|
+
import electron from "electron"
|
|
3
|
+
import { rmSync } from "node:fs"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
|
|
6
|
+
import { SETTINGS_STORE } from "./store-keys"
|
|
7
|
+
import { deleteStoreFileIfEmpty } from "./store-cleanup"
|
|
8
|
+
|
|
9
|
+
const cache = new Map<string, Store>()
|
|
10
|
+
|
|
11
|
+
// We cannot instantiate the electron-store at module load time because
|
|
12
|
+
// module import hoisting causes this to run before app.setPath("userData", ...)
|
|
13
|
+
// in index.ts has executed, which would result in files being written to the default directory
|
|
14
|
+
// (e.g. bad: %APPDATA%\@opencode-ai\desktop\neurocode.settings vs good: %APPDATA%\ai.neurocode.desktop.dev\neurocode.settings).
|
|
15
|
+
export function getStore(name = SETTINGS_STORE) {
|
|
16
|
+
const cached = cache.get(name)
|
|
17
|
+
if (cached) return cached
|
|
18
|
+
const next = new Store({
|
|
19
|
+
name,
|
|
20
|
+
cwd: electron.app.getPath("userData"),
|
|
21
|
+
fileExtension: "",
|
|
22
|
+
accessPropertiesByDotNotation: false,
|
|
23
|
+
})
|
|
24
|
+
cache.set(name, next)
|
|
25
|
+
return next
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function removeStoreFileIfEmpty(name: string) {
|
|
29
|
+
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function removeStoreFile(name: string) {
|
|
33
|
+
rmSync(join(electron.app.getPath("userData"), name), { force: true })
|
|
34
|
+
cache.delete(name)
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { BrowserWindow } from "electron"
|
|
2
|
+
import { write as writeLog } from "./logging"
|
|
3
|
+
import { safeWindowURL } from "./window-state"
|
|
4
|
+
|
|
5
|
+
const sampleInterval = 1000
|
|
6
|
+
const samplePeriod = 15000
|
|
7
|
+
|
|
8
|
+
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
|
|
9
|
+
let sampleTimer: ReturnType<typeof setTimeout> | undefined
|
|
10
|
+
let stopTimer: ReturnType<typeof setTimeout> | undefined
|
|
11
|
+
let sampling = false
|
|
12
|
+
const samples = new Map<string, number>()
|
|
13
|
+
|
|
14
|
+
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
|
|
15
|
+
const clearTimers = () => {
|
|
16
|
+
if (sampleTimer) clearTimeout(sampleTimer)
|
|
17
|
+
if (stopTimer) clearTimeout(stopTimer)
|
|
18
|
+
sampleTimer = undefined
|
|
19
|
+
stopTimer = undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const schedule = () => {
|
|
23
|
+
sampleTimer = setTimeout(() => {
|
|
24
|
+
void collect()
|
|
25
|
+
}, sampleInterval)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const collect = async () => {
|
|
29
|
+
if (!active()) return
|
|
30
|
+
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
|
|
31
|
+
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
|
|
32
|
+
return undefined
|
|
33
|
+
})
|
|
34
|
+
if (!active()) return
|
|
35
|
+
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
|
|
36
|
+
schedule()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const stopAndFlush = () => {
|
|
40
|
+
const wasSampling = sampling
|
|
41
|
+
sampling = false
|
|
42
|
+
clearTimers()
|
|
43
|
+
if (samples.size === 0) return wasSampling
|
|
44
|
+
|
|
45
|
+
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
|
|
46
|
+
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
|
|
47
|
+
const message = [
|
|
48
|
+
"renderer unresponsive samples",
|
|
49
|
+
`Window: ${name}`,
|
|
50
|
+
`URL: ${safeWindowURL(win)}`,
|
|
51
|
+
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
|
|
52
|
+
`Total Samples: ${total}`,
|
|
53
|
+
].join("\n")
|
|
54
|
+
writeLog("window", message, undefined, "error")
|
|
55
|
+
samples.clear()
|
|
56
|
+
return wasSampling
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const start = () => {
|
|
60
|
+
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
|
|
61
|
+
sampling = true
|
|
62
|
+
samples.clear()
|
|
63
|
+
schedule()
|
|
64
|
+
stopTimer = setTimeout(stopAndFlush, samplePeriod)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
win.on("closed", stopAndFlush)
|
|
68
|
+
|
|
69
|
+
return { start, stopAndFlush }
|
|
70
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createUpdaterController, type UpdaterBackend, type UpdaterReadyRecord } from "./updater-controller"
|
|
3
|
+
|
|
4
|
+
function setup(input?: { currentVersion?: string; ready?: UpdaterReadyRecord }) {
|
|
5
|
+
const calls: string[] = []
|
|
6
|
+
const backend: UpdaterBackend = {
|
|
7
|
+
async checkForUpdates() {
|
|
8
|
+
calls.push("check")
|
|
9
|
+
return { isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }
|
|
10
|
+
},
|
|
11
|
+
async downloadUpdate() {
|
|
12
|
+
calls.push("download")
|
|
13
|
+
},
|
|
14
|
+
quitAndInstall() {
|
|
15
|
+
calls.push("install")
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
let ready = input?.ready
|
|
19
|
+
const controller = createUpdaterController({
|
|
20
|
+
enabled: true,
|
|
21
|
+
currentVersion: input?.currentVersion ?? "1.0.0",
|
|
22
|
+
backend,
|
|
23
|
+
persistence: {
|
|
24
|
+
get: () => ready,
|
|
25
|
+
set: (value) => {
|
|
26
|
+
ready = value
|
|
27
|
+
},
|
|
28
|
+
clear: () => {
|
|
29
|
+
ready = undefined
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
stop: async () => {
|
|
33
|
+
calls.push("stop")
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
return { controller, calls, getReady: () => ready }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("updater controller", () => {
|
|
40
|
+
test("checks, downloads, persists, and publishes one authoritative ready state", async () => {
|
|
41
|
+
const app = setup()
|
|
42
|
+
const states: ReturnType<typeof app.controller.getState>[] = []
|
|
43
|
+
app.controller.subscribe((state) => states.push(state))
|
|
44
|
+
|
|
45
|
+
await app.controller.start()
|
|
46
|
+
|
|
47
|
+
expect(app.calls).toEqual(["check", "download"])
|
|
48
|
+
expect(app.getReady()).toEqual({ version: "2.0.0" })
|
|
49
|
+
expect(states.map((state) => state.status)).toEqual(["idle", "checking", "downloading", "ready"])
|
|
50
|
+
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("revalidates a persisted target through the updater cache on launch", async () => {
|
|
54
|
+
const app = setup({ ready: { version: "2.0.0" } })
|
|
55
|
+
|
|
56
|
+
await app.controller.start()
|
|
57
|
+
|
|
58
|
+
expect(app.calls).toEqual(["check", "download"])
|
|
59
|
+
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("clears a target already installed before checking", async () => {
|
|
63
|
+
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
|
|
64
|
+
|
|
65
|
+
await app.controller.start()
|
|
66
|
+
|
|
67
|
+
expect(app.getReady()).toBeUndefined()
|
|
68
|
+
expect(app.calls).toEqual(["check"])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("coalesces concurrent checks", async () => {
|
|
72
|
+
const app = setup()
|
|
73
|
+
|
|
74
|
+
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
|
|
75
|
+
|
|
76
|
+
expect(app.calls).toEqual(["check", "download"])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("returns to ready when quitAndInstall returns without exiting", async () => {
|
|
80
|
+
const app = setup()
|
|
81
|
+
await app.controller.start()
|
|
82
|
+
|
|
83
|
+
await app.controller.install()
|
|
84
|
+
|
|
85
|
+
expect(app.calls).toEqual(["check", "download", "stop", "install"])
|
|
86
|
+
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("returns to ready when installation cannot start", async () => {
|
|
90
|
+
const app = setup()
|
|
91
|
+
await app.controller.start()
|
|
92
|
+
|
|
93
|
+
const failed = createUpdaterController({
|
|
94
|
+
enabled: true,
|
|
95
|
+
currentVersion: "1.0.0",
|
|
96
|
+
backend: {
|
|
97
|
+
checkForUpdates: async () => ({ isUpdateAvailable: true, updateInfo: { version: "2.0.0" } }),
|
|
98
|
+
downloadUpdate: async () => {},
|
|
99
|
+
quitAndInstall() {},
|
|
100
|
+
},
|
|
101
|
+
persistence: { get: () => undefined, set() {}, clear() {} },
|
|
102
|
+
stop: async () => {
|
|
103
|
+
throw new Error("stop failed")
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
await failed.start()
|
|
107
|
+
|
|
108
|
+
await expect(failed.install()).rejects.toThrow("stop failed")
|
|
109
|
+
expect(failed.getState()).toEqual({ status: "ready", version: "2.0.0" })
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { UpdaterState } from "@neurocode-ai/app/updater"
|
|
2
|
+
|
|
3
|
+
export type { UpdaterState } from "@neurocode-ai/app/updater"
|
|
4
|
+
|
|
5
|
+
export type UpdaterReadyRecord = { version: string }
|
|
6
|
+
|
|
7
|
+
export type UpdaterBackend = {
|
|
8
|
+
checkForUpdates(): Promise<{ isUpdateAvailable?: boolean; updateInfo?: { version?: string } } | null | undefined>
|
|
9
|
+
downloadUpdate(): Promise<unknown>
|
|
10
|
+
quitAndInstall(): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type UpdaterPersistence = {
|
|
14
|
+
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
|
|
15
|
+
set(value: UpdaterReadyRecord): void | Promise<void>
|
|
16
|
+
clear(): void | Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createUpdaterController(input: {
|
|
20
|
+
enabled: boolean
|
|
21
|
+
currentVersion: string
|
|
22
|
+
backend: UpdaterBackend
|
|
23
|
+
persistence: UpdaterPersistence
|
|
24
|
+
stop: () => Promise<void>
|
|
25
|
+
log?: (message: string, data?: object) => void
|
|
26
|
+
}) {
|
|
27
|
+
let state: UpdaterState = input.enabled ? { status: "idle" } : { status: "disabled" }
|
|
28
|
+
let pending: Promise<UpdaterState> | undefined
|
|
29
|
+
const listeners = new Set<(state: UpdaterState) => void>()
|
|
30
|
+
|
|
31
|
+
const transition = (next: UpdaterState) => {
|
|
32
|
+
input.log?.("updater state changed", { from: state.status, to: next.status })
|
|
33
|
+
state = next
|
|
34
|
+
listeners.forEach((listener) => listener(state))
|
|
35
|
+
return state
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const check = () => {
|
|
39
|
+
if (!input.enabled) return Promise.resolve(state)
|
|
40
|
+
if (state.status === "ready") return Promise.resolve(state)
|
|
41
|
+
if (pending) return pending
|
|
42
|
+
|
|
43
|
+
pending = (async () => {
|
|
44
|
+
transition({ status: "checking" })
|
|
45
|
+
const result = await input.backend.checkForUpdates()
|
|
46
|
+
const version = result?.updateInfo?.version
|
|
47
|
+
if (!result?.isUpdateAvailable || !version || version === input.currentVersion) {
|
|
48
|
+
await input.persistence.clear()
|
|
49
|
+
return transition({ status: "up-to-date" })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
transition({ status: "downloading", version })
|
|
53
|
+
await input.backend.downloadUpdate()
|
|
54
|
+
await input.persistence.set({ version })
|
|
55
|
+
return transition({ status: "ready", version })
|
|
56
|
+
})()
|
|
57
|
+
.catch((error) =>
|
|
58
|
+
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
|
|
59
|
+
)
|
|
60
|
+
.finally(() => {
|
|
61
|
+
pending = undefined
|
|
62
|
+
})
|
|
63
|
+
return pending
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
getState: () => state,
|
|
68
|
+
subscribe(listener: (state: UpdaterState) => void) {
|
|
69
|
+
listeners.add(listener)
|
|
70
|
+
listener(state)
|
|
71
|
+
return () => listeners.delete(listener)
|
|
72
|
+
},
|
|
73
|
+
async start() {
|
|
74
|
+
const ready = await input.persistence.get()
|
|
75
|
+
if (ready?.version === input.currentVersion) await input.persistence.clear()
|
|
76
|
+
return check()
|
|
77
|
+
},
|
|
78
|
+
check,
|
|
79
|
+
async install() {
|
|
80
|
+
if (state.status !== "ready") throw new Error("Update is not ready to install")
|
|
81
|
+
const version = state.version
|
|
82
|
+
transition({ status: "installing", version })
|
|
83
|
+
await input
|
|
84
|
+
.stop()
|
|
85
|
+
.then(() => {
|
|
86
|
+
input.backend.quitAndInstall()
|
|
87
|
+
transition({ status: "ready", version })
|
|
88
|
+
})
|
|
89
|
+
.catch((error) => {
|
|
90
|
+
transition({ status: "ready", version })
|
|
91
|
+
throw error
|
|
92
|
+
})
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UpdaterController = ReturnType<typeof createUpdaterController>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createUpdaterSubscriptions } from "./updater-subscriptions"
|
|
3
|
+
|
|
4
|
+
describe("updater subscriptions", () => {
|
|
5
|
+
test("replaces the previous renderer subscription on reload", () => {
|
|
6
|
+
const subscriptions = createUpdaterSubscriptions()
|
|
7
|
+
const disposed: string[] = []
|
|
8
|
+
|
|
9
|
+
subscriptions.set(1, () => disposed.push("first"))
|
|
10
|
+
subscriptions.set(1, () => disposed.push("second"))
|
|
11
|
+
|
|
12
|
+
expect(disposed).toEqual(["first"])
|
|
13
|
+
subscriptions.delete(1)
|
|
14
|
+
expect(disposed).toEqual(["first", "second"])
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function createUpdaterSubscriptions() {
|
|
2
|
+
const subscriptions = new Map<number, () => void>()
|
|
3
|
+
|
|
4
|
+
const remove = (id: number) => {
|
|
5
|
+
subscriptions.get(id)?.()
|
|
6
|
+
subscriptions.delete(id)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
set(id: number, unsubscribe: () => void) {
|
|
11
|
+
remove(id)
|
|
12
|
+
subscriptions.set(id, unsubscribe)
|
|
13
|
+
},
|
|
14
|
+
delete: remove,
|
|
15
|
+
clear() {
|
|
16
|
+
subscriptions.forEach((unsubscribe) => unsubscribe())
|
|
17
|
+
subscriptions.clear()
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { app, dialog } from "electron"
|
|
2
|
+
import pkg from "electron-updater"
|
|
3
|
+
import { UPDATER_ENABLED } from "./constants"
|
|
4
|
+
import { createUpdaterController, type UpdaterReadyRecord } from "./updater-controller"
|
|
5
|
+
import { getLogger } from "./logging"
|
|
6
|
+
import { getStore } from "./store"
|
|
7
|
+
import { setAppQuitting } from "./windows"
|
|
8
|
+
|
|
9
|
+
const { autoUpdater } = pkg
|
|
10
|
+
const key = "ready"
|
|
11
|
+
|
|
12
|
+
export function setupAutoUpdater(stop: () => Promise<void>) {
|
|
13
|
+
const logger = getLogger()
|
|
14
|
+
autoUpdater.logger = logger
|
|
15
|
+
autoUpdater.channel = "latest"
|
|
16
|
+
autoUpdater.allowPrerelease = false
|
|
17
|
+
autoUpdater.allowDowngrade = true
|
|
18
|
+
autoUpdater.autoDownload = false
|
|
19
|
+
autoUpdater.autoInstallOnAppQuit = false
|
|
20
|
+
logger.log("auto updater configured", {
|
|
21
|
+
channel: autoUpdater.channel,
|
|
22
|
+
allowPrerelease: autoUpdater.allowPrerelease,
|
|
23
|
+
allowDowngrade: autoUpdater.allowDowngrade,
|
|
24
|
+
currentVersion: app.getVersion(),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const store = getStore("neurocode.updater")
|
|
28
|
+
return createUpdaterController({
|
|
29
|
+
enabled: UPDATER_ENABLED,
|
|
30
|
+
currentVersion: app.getVersion(),
|
|
31
|
+
backend: {
|
|
32
|
+
checkForUpdates: () => autoUpdater.checkForUpdates(),
|
|
33
|
+
downloadUpdate: () => autoUpdater.downloadUpdate(),
|
|
34
|
+
quitAndInstall: () => {
|
|
35
|
+
// quitAndInstall closes all windows before emitting before-quit, so
|
|
36
|
+
// flag the quit first to keep window ids persisted for restore.
|
|
37
|
+
setAppQuitting()
|
|
38
|
+
try {
|
|
39
|
+
autoUpdater.quitAndInstall()
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// The install failed and the app keeps running; clear the flag so
|
|
42
|
+
// deliberate window closes prune ids again.
|
|
43
|
+
setAppQuitting(false)
|
|
44
|
+
throw error
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
persistence: {
|
|
49
|
+
get() {
|
|
50
|
+
const value = store.get(key)
|
|
51
|
+
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
|
|
52
|
+
return { version: value.version } satisfies UpdaterReadyRecord
|
|
53
|
+
},
|
|
54
|
+
set: (value) => store.set(key, value),
|
|
55
|
+
clear: () => store.delete(key),
|
|
56
|
+
},
|
|
57
|
+
stop,
|
|
58
|
+
log: (message, data) => logger.log(message, data),
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function showUpdaterDialog(controller: ReturnType<typeof setupAutoUpdater>, alertOnFail: boolean) {
|
|
63
|
+
const state = await controller.check()
|
|
64
|
+
if (state.status === "error") {
|
|
65
|
+
if (!alertOnFail) return
|
|
66
|
+
await dialog.showMessageBox({ type: "error", message: "Update check failed.", title: "Update Error" })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (state.status === "up-to-date") {
|
|
70
|
+
if (!alertOnFail) return
|
|
71
|
+
await dialog.showMessageBox({ type: "info", message: "You're up to date.", title: "No Updates" })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (state.status !== "ready") return
|
|
75
|
+
|
|
76
|
+
const response = await dialog.showMessageBox({
|
|
77
|
+
type: "info",
|
|
78
|
+
message: `Update ${state.version} downloaded. Restart now?`,
|
|
79
|
+
title: "Update Ready",
|
|
80
|
+
buttons: ["Restart", "Later"],
|
|
81
|
+
defaultId: 0,
|
|
82
|
+
cancelId: 1,
|
|
83
|
+
})
|
|
84
|
+
if (response.response === 0) await controller.install()
|
|
85
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createWindowRegistry } from "./window-registry"
|
|
3
|
+
|
|
4
|
+
function setup(initial: unknown = []) {
|
|
5
|
+
const state = { stored: initial }
|
|
6
|
+
const cleaned: string[] = []
|
|
7
|
+
const registry = createWindowRegistry<{ name: string }>({
|
|
8
|
+
read: () => state.stored,
|
|
9
|
+
write: (ids) => {
|
|
10
|
+
state.stored = ids
|
|
11
|
+
},
|
|
12
|
+
cleanup: (id) => cleaned.push(id),
|
|
13
|
+
})
|
|
14
|
+
return { registry, state, cleaned }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("window registry", () => {
|
|
18
|
+
test("restores persisted ids and ignores malformed entries", () => {
|
|
19
|
+
expect(setup(["a", "", 42, "b"]).registry.persisted()).toEqual(["a", "b"])
|
|
20
|
+
expect(setup("junk").registry.persisted()).toEqual([])
|
|
21
|
+
expect(setup(undefined).registry.persisted()).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("registers windows and persists each id once", () => {
|
|
25
|
+
const app = setup()
|
|
26
|
+
app.registry.register("a", { name: "a" })
|
|
27
|
+
app.registry.register("a", { name: "a" })
|
|
28
|
+
app.registry.register("b", { name: "b" })
|
|
29
|
+
expect(app.state.stored).toEqual(["a", "b"])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("forgets a deliberately closed window while others remain open", () => {
|
|
33
|
+
const app = setup()
|
|
34
|
+
app.registry.register("a", { name: "a" })
|
|
35
|
+
app.registry.register("b", { name: "b" })
|
|
36
|
+
app.registry.closed("a")
|
|
37
|
+
expect(app.state.stored).toEqual(["b"])
|
|
38
|
+
expect(app.cleaned).toEqual(["a"])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("keeps the id when the last window closes so relaunch restores it", () => {
|
|
42
|
+
const app = setup()
|
|
43
|
+
app.registry.register("a", { name: "a" })
|
|
44
|
+
app.registry.closed("a")
|
|
45
|
+
expect(app.state.stored).toEqual(["a"])
|
|
46
|
+
expect(app.cleaned).toEqual([])
|
|
47
|
+
|
|
48
|
+
const restarted = createWindowRegistry<{ name: string }>({
|
|
49
|
+
read: () => app.state.stored,
|
|
50
|
+
write: (ids) => {
|
|
51
|
+
app.state.stored = ids
|
|
52
|
+
},
|
|
53
|
+
cleanup: () => {},
|
|
54
|
+
})
|
|
55
|
+
expect(restarted.persisted()).toEqual(["a"])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("keeps every id when windows close during quit", () => {
|
|
59
|
+
const app = setup()
|
|
60
|
+
app.registry.register("a", { name: "a" })
|
|
61
|
+
app.registry.register("b", { name: "b" })
|
|
62
|
+
app.registry.setQuitting()
|
|
63
|
+
app.registry.closed("a")
|
|
64
|
+
app.registry.closed("b")
|
|
65
|
+
expect(app.state.stored).toEqual(["a", "b"])
|
|
66
|
+
expect(app.cleaned).toEqual([])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("tracks the last focused window and falls back on close", () => {
|
|
70
|
+
const app = setup()
|
|
71
|
+
app.registry.register("a", { name: "a" })
|
|
72
|
+
app.registry.register("b", { name: "b" })
|
|
73
|
+
app.registry.focused("a")
|
|
74
|
+
expect(app.registry.lastFocused()).toEqual({ name: "a" })
|
|
75
|
+
app.registry.closed("a")
|
|
76
|
+
expect(app.registry.lastFocused()).toEqual({ name: "b" })
|
|
77
|
+
app.registry.closed("b")
|
|
78
|
+
expect(app.registry.lastFocused()).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("resumes forgetting closed windows after the quit flag resets", () => {
|
|
82
|
+
const app = setup()
|
|
83
|
+
app.registry.register("a", { name: "a" })
|
|
84
|
+
app.registry.register("b", { name: "b" })
|
|
85
|
+
app.registry.setQuitting()
|
|
86
|
+
app.registry.setQuitting(false)
|
|
87
|
+
app.registry.closed("a")
|
|
88
|
+
expect(app.state.stored).toEqual(["b"])
|
|
89
|
+
expect(app.cleaned).toEqual(["a"])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Tracks open windows and the persisted window id list used to restore
|
|
2
|
+
// windows (and their per-window persisted state) across app launches.
|
|
3
|
+
export function createWindowRegistry<W>(persistence: {
|
|
4
|
+
read: () => unknown
|
|
5
|
+
write: (ids: string[]) => void
|
|
6
|
+
cleanup: (id: string) => void
|
|
7
|
+
}) {
|
|
8
|
+
const windows = new Map<string, W>()
|
|
9
|
+
let quitting = false
|
|
10
|
+
let lastFocusedID: string | undefined
|
|
11
|
+
|
|
12
|
+
const persisted = () => {
|
|
13
|
+
const value = persistence.read()
|
|
14
|
+
if (!Array.isArray(value)) return []
|
|
15
|
+
return value.filter((id): id is string => typeof id === "string" && id.length > 0)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
persisted,
|
|
20
|
+
setQuitting(value = true) {
|
|
21
|
+
quitting = value
|
|
22
|
+
},
|
|
23
|
+
register(id: string, window: W) {
|
|
24
|
+
windows.set(id, window)
|
|
25
|
+
const ids = persisted()
|
|
26
|
+
if (!ids.includes(id)) persistence.write([...ids, id])
|
|
27
|
+
},
|
|
28
|
+
focused(id: string) {
|
|
29
|
+
lastFocusedID = id
|
|
30
|
+
},
|
|
31
|
+
lastFocused() {
|
|
32
|
+
if (!lastFocusedID) return
|
|
33
|
+
return windows.get(lastFocusedID)
|
|
34
|
+
},
|
|
35
|
+
closed(id: string) {
|
|
36
|
+
windows.delete(id)
|
|
37
|
+
if (lastFocusedID === id) lastFocusedID = windows.keys().next().value
|
|
38
|
+
// Only a deliberate close (app keeps running with other windows open)
|
|
39
|
+
// forgets a window. Closing the last window quits the app and fires
|
|
40
|
+
// `closed` before `before-quit`, so treat it as a quit and keep the id
|
|
41
|
+
// for restore on next launch.
|
|
42
|
+
if (quitting || windows.size === 0) return
|
|
43
|
+
persistence.write(persisted().filter((item) => item !== id))
|
|
44
|
+
persistence.cleanup(id)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|