Skip to content

Commit 698908d

Browse files
committed
fix(restore): simplify persistence and harden cleanup
Consolidate the desktop restore lifecycle and its regression coverage while preserving primary-process ownership, renderer authorization, active drafts, attachments, session tombstones, and cancellation semantics. Bound workspace launches end to end, retain cleanup ownership after unexpected exits, and use identity- and token-guarded portable POSIX process-group cleanup. This avoids the macOS all-process ps timeout without introducing an unverified PID fallback, with real Darwin integration tests for CI. Harden packaged Electron origins, transactional Tauri navigation authority, and bounded Windows session-end shutdown. Validation covers Electron, Tauri, UI, and server focused suites plus all TypeScript typechecks.
1 parent e586ea3 commit 698908d

96 files changed

Lines changed: 6295 additions & 12162 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.

packages/electron-app/electron/main/client-state-election-child.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ const election = electClientStateProcess(
5454
},
5555
)
5656

57-
process.stdout.write(`${JSON.stringify({ isPrimary: election.isPrimary, owner, warnings })}\n`)
57+
process.stdout.write(`${JSON.stringify({ isPrimary: election, owner, warnings })}\n`)
5858
process.stdin.resume()
5959
process.stdin.once("end", () => {
60-
removeRunningMarkerIfOwned(election.runningMarkerPath, owner)
60+
removeRunningMarkerIfOwned(join(directory, `client-state.running.${owner.pid}.${owner.runToken}.json`), owner)
6161
removeProcessOwnerLockIfOwned(primaryLockPath, owner)
6262
})

packages/electron-app/electron/main/client-state-ipc-handlers.test.ts

Lines changed: 0 additions & 76 deletions
This file was deleted.

packages/electron-app/electron/main/client-state-ipc-handlers.ts

Lines changed: 0 additions & 52 deletions
This file was deleted.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import type { IpcMainInvokeEvent } from "electron"
4+
import { setupClientStateIPC } from "./client-state-ipc"
5+
6+
function harness() {
7+
const handlers = new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>()
8+
const listeners = new Map<string, (...args: unknown[]) => void>()
9+
const frame = { url: "http://127.0.0.1:3000/app" }
10+
const webContents = {
11+
mainFrame: frame,
12+
getURL: () => "http://127.0.0.1:3000/app",
13+
on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener),
14+
}
15+
const window = { isDestroyed: () => false, webContents }
16+
let current: typeof window | null = window
17+
const calls: string[] = []
18+
const state = {
19+
claimClientStateAccess: (token: unknown) => { calls.push(`claim:${token}`); return true },
20+
assertRendererAccessToken: (token: unknown) => calls.push(`assert:${token}`),
21+
loadClientState: () => ({ isPrimary: true }),
22+
saveClientState: () => true,
23+
setRestoreEnabled: () => true,
24+
clearClientState: () => true,
25+
resetRendererAccessToken: () => calls.push("reset"),
26+
}
27+
const bind = setupClientStateIPC(
28+
{ handle: (channel, listener) => handlers.set(channel, listener) },
29+
state as never,
30+
() => current as never,
31+
() => ["http://127.0.0.1:3000"],
32+
)
33+
bind(window as never)
34+
return { calls, frame, handlers, listeners, setCurrent: (value: typeof window | null) => { current = value }, webContents, window }
35+
}
36+
37+
test("IPC channels enforce the current main sender, frame, origin, and token", async () => {
38+
const h = harness()
39+
assert.deepEqual([...h.handlers.keys()], [
40+
"client-state:claimAccess", "client-state:load", "client-state:save",
41+
"client-state:setRestoreEnabled", "client-state:clear",
42+
])
43+
const event = { sender: h.webContents, senderFrame: h.frame }
44+
await h.handlers.get("client-state:claimAccess")!(event as never, "token")
45+
await h.handlers.get("client-state:load")!(event as never, "token")
46+
assert.deepEqual(h.calls, ["claim:token", "assert:token"])
47+
48+
for (const invalid of [
49+
{ sender: {}, senderFrame: h.frame },
50+
{ sender: h.webContents, senderFrame: { url: h.frame.url } },
51+
{ sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } },
52+
]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise<unknown>)
53+
})
54+
55+
test("only the registered current window can reset renderer authority", () => {
56+
const h = harness()
57+
h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/next")
58+
h.listeners.get("render-process-gone")!()
59+
assert.deepEqual(h.calls, ["reset", "reset"])
60+
h.setCurrent(null)
61+
h.listeners.get("did-navigate")!({}, "http://127.0.0.1:3000/late")
62+
h.listeners.get("destroyed")!()
63+
assert.deepEqual(h.calls, ["reset", "reset"])
64+
})
Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent } from "electron"
1+
import type { BrowserWindow, IpcMainInvokeEvent } from "electron"
22
import type { ClientStateManager } from "./client-state"
3-
import {
4-
createClientStateIPCHandlers,
5-
createRendererAccessNavigationCommitHandler,
6-
} from "./client-state-ipc-handlers"
7-
import { isAllowedRendererOrigin } from "./permissions"
3+
import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation"
4+
import { isAllowedRendererOrigin } from "./renderer-origin"
85

9-
function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, getAllowedOrigins: () => string[]) {
6+
interface IPCRegistrar {
7+
handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void
8+
}
9+
10+
function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | null, allowedOrigins: string[]) {
1011
if (
12+
!mainWindow ||
1113
mainWindow.isDestroyed() ||
1214
event.sender !== mainWindow.webContents ||
1315
event.senderFrame !== mainWindow.webContents.mainFrame
1416
) {
1517
throw new Error("Client state IPC is only available to the local main window")
1618
}
1719

18-
const allowedOrigins = getAllowedOrigins()
1920
const currentUrl = mainWindow.webContents.getURL()
2021
if (
2122
!isAllowedRendererOrigin(currentUrl, allowedOrigins) ||
@@ -27,42 +28,51 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow, ge
2728
}
2829

2930
export function setupClientStateIPC(
30-
mainWindow: BrowserWindow,
31+
ipcMain: IPCRegistrar,
3132
clientState: ClientStateManager,
32-
getAllowedOrigins: () => string[],
33+
getMainWindow: () => BrowserWindow | null,
34+
getAllowedOrigins: (window: BrowserWindow | null) => string[],
3335
) {
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)
36+
const validate = (event: IpcMainInvokeEvent) => {
37+
const window = getMainWindow()
38+
validateSender(event, window, getAllowedOrigins(window))
39+
}
40+
const handle = (
41+
channel: string,
42+
operation: (argument: unknown) => unknown,
43+
) => ipcMain.handle(channel, async (event, token: unknown, argument: unknown) => {
44+
validate(event)
45+
clientState.assertRendererAccessToken(token)
46+
return operation(argument)
5347
})
5448

55-
ipcMain.handle("client-state:setRestoreEnabled", async (event, token: unknown, enabled: unknown) => {
56-
validateSender(event, mainWindow, getAllowedOrigins)
57-
return handlers.setRestoreEnabled(token, enabled)
49+
ipcMain.handle("client-state:claimAccess", async (event, token: unknown) => {
50+
validate(event)
51+
return clientState.claimClientStateAccess(token)
5852
})
59-
60-
ipcMain.handle("client-state:clear", async (event, token: unknown) => {
61-
validateSender(event, mainWindow, getAllowedOrigins)
62-
return handlers.clear(token)
53+
handle("client-state:load", () => clientState.loadClientState())
54+
handle("client-state:save", (snapshot) => clientState.saveClientState(snapshot))
55+
handle("client-state:setRestoreEnabled", (enabled) => {
56+
if (typeof enabled !== "boolean") throw new Error("Restore enabled must be a boolean")
57+
return clientState.setRestoreEnabled(enabled)
6358
})
59+
handle("client-state:clear", () => clientState.clearClientState())
6460

65-
mainWindow.webContents.on("did-navigate", (_event, url) => {
66-
handleNavigationCommit(url, false, true)
67-
})
61+
return (window: BrowserWindow): void => {
62+
window.webContents.on("did-navigate", (_event, url) => {
63+
if (getMainWindow() === window && shouldResetRendererAccessTokenForNavigation(
64+
url,
65+
false,
66+
true,
67+
(target) => isAllowedRendererOrigin(target, getAllowedOrigins(window)),
68+
)) {
69+
clientState.resetRendererAccessToken()
70+
}
71+
})
72+
const resetDestroyedRenderer = () => {
73+
if (getMainWindow() === window) clientState.resetRendererAccessToken()
74+
}
75+
window.webContents.on("render-process-gone", resetDestroyedRenderer)
76+
window.webContents.on("destroyed", resetDestroyedRenderer)
77+
}
6878
}

0 commit comments

Comments
 (0)