Skip to content

Commit edbe753

Browse files
authored
Application State Improvements (#3166)
1 parent 6c7f877 commit edbe753

17 files changed

+384
-254
lines changed

apps/zui/src/core/main/main-object.ts

+15-11
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,10 @@ import {
1212
import {getPersistedGlobalState} from "../../js/state/stores/get-persistable"
1313
import Lakes from "../../js/state/Lakes"
1414
import {installExtensions} from "../../electron/extensions"
15-
import {
16-
decodeSessionState,
17-
encodeSessionState,
18-
} from "../../electron/session-state"
15+
import {encodeSessionState} from "../../electron/session-state"
1916
import {WindowManager} from "../../electron/windows/window-manager"
2017
import * as zdeps from "../../electron/zdeps"
2118
import {MainArgs, mainDefaults} from "../../electron/run-main/args"
22-
import createSession, {Session} from "../../electron/session"
2319
import {getAppMeta, AppMeta} from "../../electron/meta"
2420
import {createMainStore} from "../../js/state/stores/create-main-store"
2521
import {AppDispatch, State} from "../../js/state/types"
@@ -33,6 +29,7 @@ import {ElectronZedClient} from "../electron-zed-client"
3329
import {ElectronZedLake} from "../electron-zed-lake"
3430
import {DefaultLake} from "src/models/default-lake"
3531
import {DomainModel} from "../domain-model"
32+
import {AppState} from "src/electron/app-state"
3633

3734
export class MainObject {
3835
public isQuitting = false
@@ -42,20 +39,23 @@ export class MainObject {
4239

4340
static async boot(params: Partial<MainArgs> = {}) {
4441
const args = {...mainDefaults(), ...params}
45-
const session = createSession(args.appState)
46-
const data = decodeSessionState(await session.load())
42+
const appState = new AppState({
43+
path: args.appState,
44+
backupDir: args.backupDir,
45+
})
46+
const data = appState.data
4747
const windows = new WindowManager(data)
4848
const store = createMainStore(data?.globalState)
4949
DomainModel.store = store
5050
const appMeta = await getAppMeta()
51-
return new MainObject(windows, store, session, args, appMeta)
51+
return new MainObject(windows, store, appState, args, appMeta)
5252
}
5353

5454
// Only call this from boot
5555
constructor(
5656
readonly windows: WindowManager,
5757
readonly store: ReduxStore<State, any>,
58-
readonly session: Session,
58+
readonly appState: AppState,
5959
readonly args: MainArgs,
6060
readonly appMeta: AppMeta
6161
) {
@@ -112,15 +112,19 @@ export class MainObject {
112112
keytar.deletePassword(toRefreshTokenKey(l.id), os.userInfo().username)
113113
keytar.deletePassword(toAccessTokenKey(l.id), os.userInfo().username)
114114
})
115-
await this.session.delete()
115+
await this.appState.reset()
116116
app.relaunch()
117117
app.exit(0)
118118
}
119119

120120
saveSession() {
121+
this.appState.save(this.appStateData)
122+
}
123+
124+
get appStateData() {
121125
const windowState = this.windows.serialize()
122126
const mainState = getPersistedGlobalState(this.store.getState())
123-
this.session.saveSync(encodeSessionState(windowState, mainState))
127+
return encodeSessionState(windowState, mainState)
124128
}
125129

126130
onBeforeQuit() {
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import path from "path"
2+
import fs from "fs"
3+
import {AppStateFile} from "./app-state-file"
4+
5+
export class AppStateBackup {
6+
constructor(public dir: string) {
7+
if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir)
8+
}
9+
10+
save(file: AppStateFile) {
11+
const backupPath = this.getPath(file.version)
12+
fs.copyFileSync(file.path, backupPath)
13+
}
14+
15+
join(name: string) {
16+
return path.join(this.dir, name)
17+
}
18+
19+
getPath(version: number) {
20+
const existing = fs.readdirSync(this.dir)
21+
let i = 1
22+
let name = ""
23+
do {
24+
name = this.getName(version, i++)
25+
} while (existing.includes(name))
26+
27+
return this.join(name)
28+
}
29+
30+
getName(version: number, n: number) {
31+
if (n > 1) return `${version}_backup_${n}.json`
32+
else return `${version}_backup.json`
33+
}
34+
}
+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import fs from "fs-extra"
2+
import path from "path"
3+
import {isNumber} from "lodash"
4+
5+
export class AppStateFile {
6+
state: any = undefined
7+
8+
constructor(public path: string) {
9+
if (this.noFile) return
10+
if (this.noContent) return
11+
this.state = this.parse()
12+
if (this.noJSON) throw new Error(JSON_ERROR_MSG(this.path))
13+
if (this.noVersion) throw new Error(VERSION_ERROR_MSG(this.path))
14+
}
15+
16+
create(version: number) {
17+
this.write({version, data: undefined})
18+
}
19+
20+
write(state) {
21+
fs.ensureDirSync(path.dirname(this.path))
22+
fs.writeFileSync(this.path, JSON.stringify(state))
23+
this.state = state
24+
}
25+
26+
update(data) {
27+
this.write({version: this.version, data})
28+
}
29+
30+
destroy() {
31+
if (fs.existsSync(this.path)) fs.rmSync(this.path)
32+
}
33+
34+
get isEmpty() {
35+
return !this.state
36+
}
37+
38+
get name() {
39+
return path.basename(this.path)
40+
}
41+
42+
get version() {
43+
return this.state.version
44+
}
45+
46+
get data() {
47+
return this.state.data
48+
}
49+
50+
private get noFile() {
51+
return !fs.existsSync(this.path)
52+
}
53+
54+
private get noContent() {
55+
return fs.statSync(this.path).size === 0
56+
}
57+
58+
private get noJSON() {
59+
return !this.state
60+
}
61+
62+
private get noVersion() {
63+
return !(typeof this.state === "object" && isNumber(this.state.version))
64+
}
65+
66+
private parse() {
67+
try {
68+
return JSON.parse(fs.readFileSync(this.path, "utf8"))
69+
} catch {
70+
return null
71+
}
72+
}
73+
}
74+
75+
const JSON_ERROR_MSG = (path) =>
76+
"The application state file could not be parsed as JSON:\npath: " + path
77+
78+
const VERSION_ERROR_MSG = (path) =>
79+
"The application state file is a JSON object but is missing the top-level version key of type number\npath: " +
80+
path
+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import fsExtra from "fs-extra"
6+
7+
import path from "path"
8+
import os from "os"
9+
10+
import disableLogger from "src/test/unit/helpers/disableLogger"
11+
import {Migrations} from "./migrations"
12+
import {AppState} from "./app-state"
13+
import states from "src/test/unit/states"
14+
15+
const dir = path.join(os.tmpdir(), "session.test.ts")
16+
const file = path.join(dir, "appState.json")
17+
const backupDir = path.join(dir, "backups")
18+
19+
disableLogger()
20+
beforeEach(() => fsExtra.ensureDir(dir))
21+
afterEach(() => fsExtra.remove(dir))
22+
23+
function init() {
24+
return new AppState({path: file, backupDir})
25+
}
26+
27+
test("app state file that doesn't exist", () => {
28+
expect(fsExtra.existsSync(file)).toBe(false)
29+
const appState = init()
30+
expect(appState.data).toEqual(undefined)
31+
expect(appState.version).toEqual(Migrations.latestVersion)
32+
})
33+
34+
test("app state file that is empty", () => {
35+
fsExtra.createFileSync(file)
36+
const appState = init()
37+
expect(appState.data).toEqual(undefined)
38+
expect(appState.version).toEqual(Migrations.latestVersion)
39+
})
40+
41+
test("app state file that does not parse to JSON", () => {
42+
fsExtra.writeFileSync(file, "---\nthis_is_yaml: true\n---")
43+
expect(fsExtra.existsSync(file)).toBe(true)
44+
expect(() => {
45+
init()
46+
}).toThrow(/The application state file could not be parsed as JSON/)
47+
})
48+
49+
test("app state file that is JSON but has not version number", async () => {
50+
const v8 = {
51+
order: [],
52+
windows: {},
53+
globalState: {investigation: [], pools: {zqd: {}}, version: "6"},
54+
}
55+
fsExtra.writeJSONSync(file, v8)
56+
57+
expect(() => {
58+
init()
59+
}).toThrow(
60+
/The application state file is a JSON object but is missing the top-level version key of type number/
61+
)
62+
})
63+
64+
test("app state is migrated if migrations are pending", () => {
65+
const needsMigration = states.getPath("v1.18.0.json")
66+
const oldState = fsExtra.readJSONSync(needsMigration)
67+
expect(oldState.version).not.toEqual(Migrations.latestVersion)
68+
69+
fsExtra.cpSync(needsMigration, file)
70+
const appState = init()
71+
72+
expect(appState.version).toBe(Migrations.latestVersion)
73+
})
74+
75+
test("app state is backed if migration is needed", () => {
76+
const needsMigration = states.getPath("v1.18.0.json")
77+
const oldState = fsExtra.readJSONSync(needsMigration)
78+
fsExtra.cpSync(needsMigration, file)
79+
init()
80+
expect(fsExtra.existsSync(backupDir)).toBe(true)
81+
const backup = fsExtra.readdirSync(backupDir)[0]
82+
expect(backup).toMatch(/^\d{12}_backup.json$/)
83+
const backupFile = path.join(backupDir, backup)
84+
expect(fsExtra.readJSONSync(backupFile)).toEqual(oldState)
85+
})
86+
87+
test("app state is not backed up if no migration is needed", () => {
88+
init()
89+
expect(fsExtra.existsSync(backupDir)).toBe(true)
90+
expect(fsExtra.readdirSync(backupDir)).toEqual([])
91+
})
92+
93+
test("backing up the same version twice creates distict backups", () => {
94+
fsExtra.cpSync(states.getPath("v1.18.0.json"), file)
95+
init()
96+
expect(fsExtra.readdirSync(backupDir)).toEqual(["202407221450_backup.json"])
97+
fsExtra.cpSync(states.getPath("v1.18.0.json"), file)
98+
init()
99+
expect(fsExtra.readdirSync(backupDir)).toEqual([
100+
"202407221450_backup.json",
101+
"202407221450_backup_2.json",
102+
])
103+
fsExtra.cpSync(states.getPath("v1.18.0.json"), file)
104+
init()
105+
expect(fsExtra.readdirSync(backupDir)).toEqual([
106+
"202407221450_backup.json",
107+
"202407221450_backup_2.json",
108+
"202407221450_backup_3.json",
109+
])
110+
})
111+
112+
test("a migration error does not affect the state file", () => {
113+
const fixture = states.getPath("v1.18.0.json")
114+
fsExtra.cpSync(fixture, file)
115+
Migrations.all.push({
116+
version: 9999_99_99_99_99,
117+
migrate: (bang) => bang.boom.boom,
118+
})
119+
expect(() => {
120+
init()
121+
}).toThrow(/Cannot read properties of undefined \(reading 'boom'\)/)
122+
Migrations.all.pop()
123+
expect(fsExtra.readJSONSync(file)).toEqual(fsExtra.readJSONSync(fixture))
124+
})
125+
126+
test("app state saves new data", () => {
127+
const appState = init()
128+
appState.save({hello: "test"})
129+
130+
expect(fsExtra.readJSONSync(file)).toEqual({
131+
version: Migrations.latestVersion,
132+
data: {hello: "test"},
133+
})
134+
expect(appState.data).toEqual({hello: "test"})
135+
expect(appState.version).toEqual(Migrations.latestVersion)
136+
})
137+
138+
test("app state reset", () => {
139+
const appState = init()
140+
appState.save({hello: "test"})
141+
142+
appState.reset()
143+
expect(fsExtra.readJSONSync(file)).toEqual({
144+
version: Migrations.latestVersion,
145+
})
146+
})

apps/zui/src/electron/app-state.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {AppStateBackup} from "./app-state-backup"
2+
import {AppStateFile} from "./app-state-file"
3+
import {Migrations} from "./migrations"
4+
5+
/**
6+
* The application state is saved in a json file called appState.json
7+
* It contains a single object.
8+
* {
9+
* version: number,
10+
* data: object
11+
* }
12+
*
13+
* In the code below, references to "state" mean the root object.
14+
* References to version and data mean the keys inside the root object.
15+
*/
16+
17+
export class AppState {
18+
file: AppStateFile
19+
20+
constructor(args: {path: string | null; backupDir: string}) {
21+
const file = new AppStateFile(args.path)
22+
if (file.isEmpty) file.create(Migrations.latestVersion)
23+
const migrations = Migrations.init({from: file.version})
24+
const backup = new AppStateBackup(args.backupDir)
25+
if (migrations.arePending) {
26+
backup.save(file)
27+
file.write(migrations.runPending(file.state))
28+
}
29+
this.file = file
30+
}
31+
32+
get data() {
33+
return this.file.data
34+
}
35+
36+
get version() {
37+
return this.file.version
38+
}
39+
40+
reset() {
41+
this.file.destroy()
42+
this.file.create(Migrations.latestVersion)
43+
}
44+
45+
save(data) {
46+
this.file.update(data)
47+
}
48+
}

0 commit comments

Comments
 (0)