Skip to content

Commit 1efe5e9

Browse files
authored
feat(desktop): persist and restore UI state across restarts (NeuralNomadsAI#578)
## Summary - Restore the primary desktop client's window bounds, zoom, workspace tabs, active sessions, drafts, bounded attachments, scroll positions, and panel layout across launches. - Keep additional Electron and Tauri processes independent: they still start normally but do not read or write the shared native snapshot. - Add translated settings to disable startup restoration or clear the saved state. ## Implementation - Persist a versioned, bounded snapshot atomically in Electron and Tauri, preserving unknown future envelopes until an explicit clear. - Elect one primary process with stale-owner recovery and process identity checks, then protect renderer access with per-document capability tokens. - Flush renderer state before close, quit, reload, and app-owned navigation with bounded failure handling and fresh token rotation. - Reconcile partial or timed-out workspace hydration without losing unsent prompt state or resurrecting explicitly cleared drafts, attachments, sessions, or tabs. - Correlate restore-created workspaces so cancellation and delayed SSE events cannot produce ghost tabs. - Harden workspace launch cancellation and cross-platform process cleanup so failed starts cannot orphan detached POSIX, WSL, or Windows children; incomplete shutdown exits nonzero. The restored shell state remains local to the desktop client. Durable semantic session properties continue to use OpenCode session metadata separately. ## Validation pm run typecheck --workspace @neuralnomads/codenomad - Electron native suite: 43 passed - Tauri suite: 54 passed - Focused UI restore, reconciliation, attachment, authority, and race suites passed - Focused server workspace runtime, manager, process identity, route, and shutdown suites passed - pm run build - pm run build:tauri - git diff --check - 16-pass adversarial review loop completed with final No findings - extensivly optimized, tested and used day by day ## Platform notes - Native menu reloads and app-owned navigation have awaited durability guarantees. Browser-engine crashes or forced process termination remain inherently best effort. - Process cleanup uses identity-guarded platform adapters and fails conservatively when target ownership cannot be proven.
1 parent b1d6421 commit 1efe5e9

163 files changed

Lines changed: 19599 additions & 895 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package-lock.json

Lines changed: 29 additions & 41 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { existsSync, writeFileSync } from "node:fs"
2+
import { join } from "node:path"
3+
import {
4+
electClientStateProcess,
5+
isPidAlive,
6+
REGISTRATION_LOCK_WAIT_MS,
7+
removeProcessOwnerLockIfOwned,
8+
removeRunningMarkerIfOwned,
9+
type ProcessOwner,
10+
} from "./client-state-process"
11+
import { getProcessStartIdentity } from "./client-state-process-identity"
12+
13+
const [directory, runToken, startPath, registrationWaitArgument, primaryPausedPath, primaryReleasePath] =
14+
process.argv.slice(2)
15+
if (!directory || !runToken || !startPath) {
16+
throw new Error("Expected election directory, run token, and start path")
17+
}
18+
19+
while (!existsSync(startPath)) {
20+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
21+
}
22+
23+
const owner: ProcessOwner = {
24+
pid: process.pid,
25+
runToken,
26+
processStartIdentity: getProcessStartIdentity(process.pid),
27+
}
28+
const primaryLockPath = join(directory, "client-state.primary.lock")
29+
const registrationLockPath = join(directory, "client-state.registration.lock")
30+
const registrationLockWaitMs = registrationWaitArgument
31+
? Number(registrationWaitArgument)
32+
: REGISTRATION_LOCK_WAIT_MS
33+
const warnings: string[] = []
34+
const election = electClientStateProcess(
35+
directory,
36+
owner,
37+
{ primaryLockPath, registrationLockPath },
38+
(message, error) => warnings.push(`${message}: ${String(error)}`),
39+
isPidAlive,
40+
registrationLockWaitMs,
41+
() => {
42+
if (!primaryPausedPath || !primaryReleasePath) {
43+
return
44+
}
45+
try {
46+
writeFileSync(primaryPausedPath, JSON.stringify(owner), { encoding: "utf8", flag: "wx" })
47+
} catch {
48+
// Only the contender that published the synchronization point owns the pause gate.
49+
return
50+
}
51+
while (!existsSync(primaryReleasePath)) {
52+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
53+
}
54+
},
55+
)
56+
57+
process.stdout.write(`${JSON.stringify({ isPrimary: election.isPrimary, owner, warnings })}\n`)
58+
process.stdin.resume()
59+
process.stdin.once("end", () => {
60+
removeRunningMarkerIfOwned(election.runningMarkerPath, owner)
61+
removeProcessOwnerLockIfOwned(primaryLockPath, owner)
62+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from "node:assert/strict"
2+
import { mkdtempSync, rmSync } from "node:fs"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import test from "node:test"
6+
import { ClientStateManager } from "./client-state"
7+
import {
8+
createClientStateIPCHandlers,
9+
shouldResetRendererAccessTokenForNavigation,
10+
} from "./client-state-ipc-handlers"
11+
12+
test("only trusted full main-frame navigation resets renderer access", () => {
13+
const trusted = (url: string) => new URL(url).origin === "http://127.0.0.1:3000"
14+
15+
assert.equal(
16+
shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/reload", false, true, trusted),
17+
true,
18+
)
19+
assert.equal(
20+
shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/frame", false, false, trusted),
21+
false,
22+
)
23+
assert.equal(
24+
shouldResetRendererAccessTokenForNavigation("http://127.0.0.1:3000/#route", true, true, trusted),
25+
false,
26+
)
27+
assert.equal(
28+
shouldResetRendererAccessTokenForNavigation("https://untrusted.example/reload", false, true, trusted),
29+
false,
30+
)
31+
})
32+
33+
test("client-state handlers require the claimed nonempty renderer token", async (testContext) => {
34+
const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-ipc-"))
35+
const manager = new ClientStateManager(directory)
36+
const handlers = createClientStateIPCHandlers(manager)
37+
testContext.after(async () => {
38+
await manager.drainAndReleasePrimary().catch(() => {})
39+
rmSync(directory, { recursive: true, force: true })
40+
})
41+
42+
assert.throws(() => handlers.claimAccess(""), /nonempty string/)
43+
assert.throws(() => handlers.load("not-claimed"), /has not been claimed/)
44+
assert.equal(handlers.claimAccess("trusted-renderer-token"), true)
45+
assert.equal(handlers.claimAccess("trusted-renderer-token"), true)
46+
assert.throws(() => handlers.claimAccess("child-frame-token"), /does not match/)
47+
48+
for (const invoke of [
49+
() => handlers.load("child-frame-token"),
50+
() => handlers.save("child-frame-token", { denied: true }),
51+
() => handlers.setRestoreEnabled("child-frame-token", false),
52+
() => handlers.clear("child-frame-token"),
53+
]) {
54+
assert.throws(invoke, /has not been claimed/)
55+
}
56+
57+
assert.equal(await handlers.save("trusted-renderer-token", { shutdownFlush: true }), true)
58+
assert.deepEqual(handlers.load("trusted-renderer-token").snapshot, { shutdownFlush: true })
59+
})
60+
61+
test("trusted renderer navigation reset invalidates the old token before a new claim", async (testContext) => {
62+
const directory = mkdtempSync(join(tmpdir(), "codenomad-client-state-token-reset-"))
63+
const manager = new ClientStateManager(directory)
64+
const handlers = createClientStateIPCHandlers(manager)
65+
testContext.after(async () => {
66+
await manager.drainAndReleasePrimary().catch(() => {})
67+
rmSync(directory, { recursive: true, force: true })
68+
})
69+
70+
handlers.claimAccess("first-document")
71+
manager.resetRendererAccessToken()
72+
73+
assert.throws(() => handlers.load("first-document"), /has not been claimed/)
74+
assert.equal(handlers.claimAccess("reloaded-document"), true)
75+
assert.equal(handlers.load("reloaded-document").isPrimary, true)
76+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { ClientStateManager } from "./client-state"
2+
3+
export function shouldResetRendererAccessTokenForNavigation(
4+
url: string,
5+
isInPlace: boolean,
6+
isMainFrame: boolean,
7+
isTrustedOrigin: (url: string) => boolean,
8+
): boolean {
9+
return isMainFrame && !isInPlace && isTrustedOrigin(url)
10+
}
11+
12+
export function createRendererAccessNavigationCommitHandler(
13+
clientState: Pick<ClientStateManager, "resetRendererAccessToken">,
14+
isTrustedOrigin: (url: string) => boolean,
15+
) {
16+
return (url: string, isInPlace: boolean, isMainFrame: boolean): void => {
17+
if (shouldResetRendererAccessTokenForNavigation(url, isInPlace, isMainFrame, isTrustedOrigin)) {
18+
clientState.resetRendererAccessToken()
19+
}
20+
}
21+
}
22+
23+
export function createClientStateIPCHandlers(clientState: ClientStateManager) {
24+
const requireAccess = (token: unknown) => clientState.assertRendererAccessToken(token)
25+
26+
return {
27+
claimAccess(token: unknown) {
28+
return clientState.claimClientStateAccess(token)
29+
},
30+
load(token: unknown) {
31+
requireAccess(token)
32+
return clientState.loadClientState()
33+
},
34+
save(token: unknown, snapshot: unknown) {
35+
requireAccess(token)
36+
return clientState.saveClientState(snapshot)
37+
},
38+
setRestoreEnabled(token: unknown, enabled: unknown) {
39+
requireAccess(token)
40+
if (typeof enabled !== "boolean") {
41+
throw new TypeError("Restore enabled must be a boolean")
42+
}
43+
return clientState.setRestoreEnabled(enabled)
44+
},
45+
clear(token: unknown) {
46+
requireAccess(token)
47+
return clientState.clearClientState()
48+
},
49+
}
50+
}
51+
52+
export type ClientStateIPCHandlers = ReturnType<typeof createClientStateIPCHandlers>
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from "electron"
2+
import type { ClientStateManager } from "./client-state"
3+
import {
4+
createClientStateIPCHandlers,
5+
createRendererAccessNavigationCommitHandler,
6+
} from "./client-state-ipc-handlers"
7+
import { isAllowedRendererOrigin } from "./permissions"
8+
9+
function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, getAllowedOrigins: () => string[]) {
10+
if (
11+
mainWindow.isDestroyed() ||
12+
event.sender !== mainWindow.webContents ||
13+
event.senderFrame !== mainWindow.webContents.mainFrame
14+
) {
15+
throw new Error("Client state IPC is only available to the local main window")
16+
}
17+
18+
const allowedOrigins = getAllowedOrigins()
19+
const currentUrl = mainWindow.webContents.getURL()
20+
if (
21+
!isAllowedRendererOrigin(currentUrl, allowedOrigins) ||
22+
!isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) ||
23+
new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin
24+
) {
25+
throw new Error("Client state IPC is not available to the current renderer origin")
26+
}
27+
}
28+
29+
export function setupClientStateIPC(
30+
mainWindow: BrowserWindow,
31+
clientState: ClientStateManager,
32+
getAllowedOrigins: () => string[],
33+
) {
34+
const handlers = createClientStateIPCHandlers(clientState)
35+
const handleNavigationCommit = createRendererAccessNavigationCommitHandler(
36+
clientState,
37+
(url) => isAllowedRendererOrigin(url, getAllowedOrigins()),
38+
)
39+
40+
ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => {
41+
validateSender(event, mainWindow, getAllowedOrigins)
42+
return handlers.claimAccess(token)
43+
})
44+
45+
ipcMain.handle("client-state:load", async (event, token: unknown) => {
46+
validateSender(event, mainWindow, getAllowedOrigins)
47+
return handlers.load(token)
48+
})
49+
50+
ipcMain.handle("client-state:save", async (event, token: unknown, snapshot: unknown) => {
51+
validateSender(event, mainWindow, getAllowedOrigins)
52+
return handlers.save(token, snapshot)
53+
})
54+
55+
ipcMain.handle("client-state:setRestoreEnabled", async (event, token: unknown, enabled: unknown) => {
56+
validateSender(event, mainWindow, getAllowedOrigins)
57+
return handlers.setRestoreEnabled(token, enabled)
58+
})
59+
60+
ipcMain.handle("client-state:clear", async (event, token: unknown) => {
61+
validateSender(event, mainWindow, getAllowedOrigins)
62+
return handlers.clear(token)
63+
})
64+
65+
mainWindow.webContents.on("did-navigate", (_event, url) => {
66+
handleNavigationCommit(url, false, true)
67+
})
68+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import type { App, BrowserWindow } from "electron"
4+
import { ClientStateLifecycle } from "./client-state-lifecycle"
5+
import type { ClientStateManager } from "./client-state"
6+
import type { CliProcessManager } from "./process-manager"
7+
import type { WindowStateTracker } from "./window-state"
8+
9+
function createHarness(options: { rendererFlush?: () => Promise<unknown>; timeoutMs?: number } = {}) {
10+
const handlers = new Map<string, (event?: { preventDefault(): void }) => void>()
11+
let nativeFlushes = 0
12+
let rendererFlushes = 0
13+
let primaryReleases = 0
14+
let cliStops = 0
15+
let exits = 0
16+
const window = {
17+
on: (event: string, handler: () => void) => {
18+
handlers.set(event, handler)
19+
},
20+
isDestroyed: () => false,
21+
close: () => {},
22+
webContents: {
23+
isDestroyed: () => false,
24+
getURL: () => "http://127.0.0.1:43123/workspace",
25+
executeJavaScript: () => {
26+
rendererFlushes += 1
27+
return options.rendererFlush?.() ?? Promise.resolve()
28+
},
29+
},
30+
} as unknown as BrowserWindow
31+
const app = { on: () => {}, quit: () => {}, exit: () => { exits += 1 } } as unknown as App
32+
const clientStateManager = {
33+
isPrimary: true,
34+
drainAndReleasePrimary: async () => { primaryReleases += 1 },
35+
} as ClientStateManager
36+
const cliManager = { stop: async () => { cliStops += 1 } } as unknown as CliProcessManager
37+
const lifecycle = new ClientStateLifecycle({
38+
app,
39+
clientStateManager,
40+
cliManager,
41+
getMainWindow: () => window,
42+
getAllWindows: () => [window],
43+
getAllowedRendererOrigins: () => ["http://127.0.0.1:43123"],
44+
isTrustedRendererOrigin: () => true,
45+
windowsSessionEndFlushTimeoutMs: options.timeoutMs,
46+
rendererFlushTimeoutMs: options.timeoutMs === undefined ? undefined : options.timeoutMs * 2,
47+
isWindows: true,
48+
})
49+
const tracker = { flush: async () => { nativeFlushes += 1 } } as unknown as WindowStateTracker
50+
lifecycle.attachMainWindow(window, tracker)
51+
return {
52+
handlers,
53+
lifecycle,
54+
getNativeFlushes: () => nativeFlushes,
55+
getRendererFlushes: () => rendererFlushes,
56+
getPrimaryReleases: () => primaryReleases,
57+
getCliStops: () => cliStops,
58+
getExits: () => exits,
59+
}
60+
}
61+
62+
test("Windows session termination flushes renderer and native client state once", async () => {
63+
const harness = createHarness()
64+
65+
let prevented = false
66+
harness.handlers.get("query-session-end")?.({ preventDefault: () => { prevented = true } })
67+
harness.handlers.get("session-end")?.()
68+
await (harness.lifecycle as any).windowsSessionEndFlush
69+
await new Promise((resolve) => setImmediate(resolve))
70+
71+
assert.equal(prevented, true)
72+
assert.equal(harness.getRendererFlushes(), 1)
73+
assert.equal(harness.getNativeFlushes(), 1)
74+
assert.equal(harness.getPrimaryReleases(), 1)
75+
assert.equal(harness.getCliStops(), 1)
76+
assert.equal(harness.getExits(), 1)
77+
})
78+
79+
test("Windows session termination flush is globally bounded", async () => {
80+
const harness = createHarness({ rendererFlush: () => new Promise(() => {}), timeoutMs: 20 })
81+
const startedAt = Date.now()
82+
83+
harness.handlers.get("query-session-end")?.({ preventDefault: () => {} })
84+
await (harness.lifecycle as any).windowsSessionEndFlush
85+
await new Promise((resolve) => setImmediate(resolve))
86+
87+
assert.ok(Date.now() - startedAt < 500)
88+
assert.equal(harness.getRendererFlushes(), 1)
89+
assert.equal(harness.getNativeFlushes(), 0)
90+
assert.equal(harness.getPrimaryReleases(), 0)
91+
assert.equal(harness.getCliStops(), 0)
92+
assert.equal(harness.getExits(), 1)
93+
})

0 commit comments

Comments
 (0)