Skip to content

Commit cb2d0ca

Browse files
committed
fix(restore): share desktop state across hosts
Persist Electron and Tauri restore data in one cross-host envelope, migrate legacy host files deterministically, and prevent cleared state from being resurrected. Keep atomic replacements on the destination filesystem and normalize Tauri window bounds for HiDPI handoffs. Delay capture until native flush listeners are ready, preserve the saved active tab and session IDs during startup, accept legacy unversioned snapshots, and hydrate incomplete session metadata without overwriting newer updates. Validated with 89 Electron native tests, 69 focused UI tests, 36 Tauri client-state tests, UI/Electron typechecks, cargo check, rustfmt, and the Electron production build.
1 parent 975e700 commit cb2d0ca

20 files changed

Lines changed: 586 additions & 56 deletions

packages/electron-app/electron/main/client-state-cross-host-child.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getProcessStartIdentity } from "./client-state-process-identity"
44
import { isPidAlive } from "./client-state-process"
55
import { ClientStateManager } from "./client-state"
66

7-
const [directory, startPath, readyPath, mode, userDataPath, participantReadyPath, participantContinuePath, legacyTauriDataPath] = process.argv.slice(2)
7+
const [directory, startPath, readyPath, mode, userDataPath, participantReadyPath, participantContinuePath, legacyTauriDataPath, operation, payload] = process.argv.slice(2)
88
if (!directory || !startPath) throw new Error("Expected election directory and start path")
99
if (readyPath) writeFileSync(readyPath, "")
1010
while (!existsSync(startPath)) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5)
@@ -32,7 +32,11 @@ const registration = owner && CrossHostRegistration.register(directory, owner, t
3232
onOwnerPrepared: mode === "owner-crash" ? () => process.exit(91) : undefined,
3333
onOwnerRetired: mode === "retire-crash" ? () => process.exit(91) : undefined,
3434
})
35-
process.stdout.write(`${JSON.stringify({ acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary) })}\n`)
35+
if (manager?.isPrimary && operation === "save") await manager.saveClientState(JSON.parse(payload))
36+
process.stdout.write(`${JSON.stringify({
37+
acquired: manager?.isPrimary ?? Boolean(registration?.isPrimary),
38+
state: operation === "load" ? manager?.loadClientState() : undefined,
39+
})}\n`)
3640
process.stdin.resume()
3741
process.stdin.once("end", () => {
3842
if (manager) void manager.drainAndReleasePrimary().finally(() => process.exit())

packages/electron-app/electron/main/client-state-cross-host.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
CrossHostRegistration,
1111
CROSS_HOST_OWNER_DIRECTORY,
1212
resolveCrossHostElectionDirectory,
13+
resolveCrossHostStatePath,
1314
type CrossHostLeaseDependencies,
1415
} from "./client-state-cross-host"
1516
import type { ProcessOwner } from "./client-state-process"
@@ -150,4 +151,7 @@ test("platform paths match the Rust contract", () => {
150151
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "election"))
151152
assert.equal(resolveCrossHostElectionDirectory({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "election"))
152153
assert.equal(resolveCrossHostElectionDirectory({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "election"))
154+
assert.equal(resolveCrossHostStatePath({ HOME: "/Users/dev" }, "darwin", "/fallback"), posix.join("/Users/dev", ".codenomad", "client-state", "client-state.json"))
155+
assert.equal(resolveCrossHostStatePath({ HOME: "/home/dev" }, "linux", "/fallback"), posix.join("/home/dev", ".codenomad", "client-state", "client-state.json"))
156+
assert.equal(resolveCrossHostStatePath({ USERPROFILE: "", HOME: "D:\\Home" }, "win32", "C:\\Fallback"), win32.join("D:\\Home", ".codenomad", "client-state", "client-state.json"))
153157
})

packages/electron-app/electron/main/client-state-cross-host.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ export function resolveCrossHostElectionDirectory(
4343
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "election")
4444
}
4545

46+
export function resolveCrossHostStatePath(
47+
environment: NodeJS.ProcessEnv = process.env,
48+
platform: NodeJS.Platform = process.platform,
49+
fallbackHome = homedir(),
50+
): string {
51+
const pathApi = platform === "win32" ? win32 : posix
52+
const configured = platform === "win32"
53+
? validHome(environment.USERPROFILE, platform) ?? validHome(environment.HOME, platform)
54+
: validHome(environment.HOME, platform)
55+
return pathApi.join(configured ?? fallbackHome, ".codenomad", "client-state", "client-state.json")
56+
}
57+
4658
export function resolveLegacyTauriDataDirectory(
4759
environment: NodeJS.ProcessEnv = process.env,
4860
platform: NodeJS.Platform = process.platform,

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import assert from "node:assert/strict"
2-
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
33
import { writeFile } from "node:fs/promises"
44
import { tmpdir } from "node:os"
55
import { join } from "node:path"
@@ -79,6 +79,45 @@ test("cross-host ownership is required in addition to each host-local election",
7979
await successor.drainAndReleasePrimary()
8080
})
8181

82+
test("first shared primary deterministically migrates legacy host envelopes", async (t) => {
83+
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
84+
const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election")
85+
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
86+
t.after(() => rmSync(root, { recursive: true, force: true }))
87+
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { revision: 999, savedAt: 10, host: "electron" } }))
88+
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 20, host: "tauri" } }))
89+
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
90+
assert.deepEqual(manager.loadClientState().snapshot, { savedAt: 20, host: "tauri" })
91+
assert.equal(existsSync(join(electron, "client-state.json")), true)
92+
assert.equal(existsSync(join(tauri, "client-state.json")), true)
93+
await manager.drainAndReleasePrimary()
94+
})
95+
96+
test("legacy migration prefers disabled and ignores malformed candidates", async (t) => {
97+
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
98+
const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election")
99+
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
100+
t.after(() => rmSync(root, { recursive: true, force: true }))
101+
writeFileSync(join(electron, "client-state.json"), "malformed")
102+
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: false, snapshot: { savedAt: 1 } }))
103+
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
104+
assert.deepEqual(manager.loadClientState(), { isPrimary: true, restoreEnabled: false, snapshot: null })
105+
assert.equal(JSON.parse(readFileSync(join(root, "shared", "client-state.json"), "utf8")).restoreEnabled, false)
106+
await manager.drainAndReleasePrimary()
107+
})
108+
109+
test("legacy migration does not resurrect a snapshot after clear", async (t) => {
110+
const root = mkdtempSync(join(tmpdir(), "codenomad-migration-"))
111+
const electron = join(root, "electron"), tauri = join(root, "tauri"), election = join(root, "shared", "election")
112+
mkdirSync(electron, { recursive: true }); mkdirSync(tauri, { recursive: true })
113+
t.after(() => rmSync(root, { recursive: true, force: true }))
114+
writeFileSync(join(electron, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true }))
115+
writeFileSync(join(tauri, "client-state.json"), JSON.stringify({ version: 1, restoreEnabled: true, snapshot: { savedAt: 20 } }))
116+
const manager = new ClientStateManager(electron, undefined, { crossHostElectionDirectory: election, legacyTauriDataPath: tauri })
117+
assert.equal(manager.loadClientState().snapshot, null)
118+
await manager.drainAndReleasePrimary()
119+
})
120+
82121
test("ownership loss immediately disables restore reads and mutations", async (t) => {
83122
const h = harness(t, {
84123
version: 1,

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

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { randomUUID } from "node:crypto"
2-
import { mkdirSync, readFileSync } from "node:fs"
3-
import { rename, rm, writeFile } from "node:fs/promises"
4-
import { join } from "node:path"
2+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"
3+
import { open, rename, rm } from "node:fs/promises"
4+
import { dirname, join } from "node:path"
55
import {
66
electClientStateProcess,
77
getRunningMarkerPath,
@@ -17,6 +17,7 @@ import {
1717
CrossHostRegistration,
1818
crossHostParticipants,
1919
resolveCrossHostElectionDirectory,
20+
resolveCrossHostStatePath,
2021
resolveLegacyTauriDataDirectory,
2122
type CrossHostLeaseDependencies,
2223
} from "./client-state-cross-host"
@@ -69,7 +70,13 @@ interface ClientStateManagerOptions {
6970
}
7071

7172
async function writeClientStateTemporary(temporaryPath: string, serializedState: string): Promise<void> {
72-
await writeFile(temporaryPath, serializedState, { encoding: "utf8", mode: 0o600 })
73+
const file = await open(temporaryPath, "w", 0o600)
74+
try {
75+
await file.writeFile(serializedState, "utf8")
76+
await file.sync()
77+
} finally {
78+
await file.close()
79+
}
7380
}
7481

7582
interface ParsedClientState {
@@ -106,6 +113,19 @@ function parseClientState(value: string): ParsedClientState {
106113
}
107114
}
108115

116+
function legacyCandidate(path: string, host: "electron" | "tauri"): { host: string; state: PersistedClientState; savedAt: number; hasSnapshot: boolean } | undefined {
117+
try {
118+
const candidate = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
119+
if (!candidate || candidate.version !== CLIENT_STATE_VERSION) return undefined
120+
const parsed = parseClientState(JSON.stringify(candidate)).state
121+
const snapshot = candidate.snapshot as Record<string, unknown> | undefined
122+
const savedAt = typeof snapshot?.savedAt === "number" && Number.isFinite(snapshot.savedAt) ? snapshot.savedAt : -1
123+
return { host, state: parsed, savedAt, hasSnapshot: snapshot !== undefined }
124+
} catch {
125+
return undefined
126+
}
127+
}
128+
109129
export class ClientStateManager {
110130
private readonly userDataPath: string
111131
private readonly statePath: string
@@ -134,7 +154,11 @@ export class ClientStateManager {
134154
}
135155
mkdirSync(userDataPath, { recursive: true })
136156
this.userDataPath = userDataPath
137-
this.statePath = join(userDataPath, CLIENT_STATE_FILENAME)
157+
const crossHostElectionDirectory = options?.crossHostElectionDirectory ?? resolveCrossHostElectionDirectory()
158+
this.statePath = options?.crossHostElectionDirectory
159+
? join(dirname(crossHostElectionDirectory), CLIENT_STATE_FILENAME)
160+
: resolveCrossHostStatePath()
161+
mkdirSync(dirname(this.statePath), { recursive: true })
138162
this.lockPath = join(userDataPath, PRIMARY_LOCK_FILENAME)
139163
const registrationLockPath = join(userDataPath, REGISTRATION_LOCK_FILENAME)
140164

@@ -144,7 +168,6 @@ export class ClientStateManager {
144168
{ primaryLockPath: this.lockPath, registrationLockPath },
145169
(message, error) => console.warn(`[client-state] ${message}`, error),
146170
)
147-
const crossHostElectionDirectory = options?.crossHostElectionDirectory ?? resolveCrossHostElectionDirectory()
148171
const legacyTauriDataPath = options?.legacyTauriDataPath === undefined
149172
? (options?.crossHostElectionDirectory ? null : resolveLegacyTauriDataDirectory())
150173
: options.legacyTauriDataPath
@@ -179,6 +202,10 @@ export class ClientStateManager {
179202
this.primary = false
180203
}
181204
if (this.isPrimary) {
205+
this.migrateLegacyStateIfNeeded([
206+
["electron", join(userDataPath, CLIENT_STATE_FILENAME)],
207+
...(legacyTauriDataPath ? [["tauri", join(legacyTauriDataPath, CLIENT_STATE_FILENAME)] as const] : []),
208+
])
182209
const persisted = this.readState()
183210
this.state = persisted.state
184211
this.persistenceSuppressed = !this.state.restoreEnabled
@@ -335,6 +362,40 @@ export class ClientStateManager {
335362
}
336363
}
337364

365+
private migrateLegacyStateIfNeeded(paths: ReadonlyArray<readonly ["electron" | "tauri", string]>): void {
366+
try {
367+
readFileSync(this.statePath)
368+
return
369+
} catch (error) {
370+
if (!hasErrorCode(error, "ENOENT")) return
371+
}
372+
const winner = paths
373+
.map(([host, path]) => legacyCandidate(path, host))
374+
.filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate))
375+
.sort((left, right) =>
376+
Number(left.state.restoreEnabled) - Number(right.state.restoreEnabled) ||
377+
Number(left.hasSnapshot) - Number(right.hasSnapshot) ||
378+
right.savedAt - left.savedAt ||
379+
right.host.localeCompare(left.host),
380+
)[0]
381+
if (!winner) return
382+
383+
const temporaryPath = join(dirname(this.statePath), `.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.migration.tmp`)
384+
let descriptor: number | undefined
385+
try {
386+
descriptor = openSync(temporaryPath, "wx", 0o600)
387+
writeFileSync(descriptor, JSON.stringify(winner.state), "utf8")
388+
fsyncSync(descriptor)
389+
closeSync(descriptor)
390+
descriptor = undefined
391+
this.assertReplacementAllowed()
392+
renameSync(temporaryPath, this.statePath)
393+
} finally {
394+
if (descriptor !== undefined) closeSync(descriptor)
395+
rm(temporaryPath, { force: true }).catch(() => {})
396+
}
397+
}
398+
338399
private getMutationDisposition(futureEnvelopeResult = true): Promise<boolean> | undefined {
339400
if (!this.isPrimary) {
340401
return Promise.resolve(false)
@@ -376,7 +437,7 @@ export class ClientStateManager {
376437

377438
private async writeAtomically(serializedState: string): Promise<void> {
378439
const temporaryPath = join(
379-
this.userDataPath,
440+
dirname(this.statePath),
380441
`.${CLIENT_STATE_FILENAME}.${this.owner.pid}.${this.owner.runToken}.tmp`,
381442
)
382443
try {

0 commit comments

Comments
 (0)