11import { 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"
55import {
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
7172async 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
7582interface 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+
109129export 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