Skip to content

Commit

Permalink
Application State Improvements (#3166)
Browse files Browse the repository at this point in the history
  • Loading branch information
jameskerr authored Nov 14, 2024
1 parent 6c7f877 commit edbe753
Show file tree
Hide file tree
Showing 17 changed files with 384 additions and 254 deletions.
26 changes: 15 additions & 11 deletions apps/zui/src/core/main/main-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,10 @@ import {
import {getPersistedGlobalState} from "../../js/state/stores/get-persistable"
import Lakes from "../../js/state/Lakes"
import {installExtensions} from "../../electron/extensions"
import {
decodeSessionState,
encodeSessionState,
} from "../../electron/session-state"
import {encodeSessionState} from "../../electron/session-state"
import {WindowManager} from "../../electron/windows/window-manager"
import * as zdeps from "../../electron/zdeps"
import {MainArgs, mainDefaults} from "../../electron/run-main/args"
import createSession, {Session} from "../../electron/session"
import {getAppMeta, AppMeta} from "../../electron/meta"
import {createMainStore} from "../../js/state/stores/create-main-store"
import {AppDispatch, State} from "../../js/state/types"
Expand All @@ -33,6 +29,7 @@ import {ElectronZedClient} from "../electron-zed-client"
import {ElectronZedLake} from "../electron-zed-lake"
import {DefaultLake} from "src/models/default-lake"
import {DomainModel} from "../domain-model"
import {AppState} from "src/electron/app-state"

export class MainObject {
public isQuitting = false
Expand All @@ -42,20 +39,23 @@ export class MainObject {

static async boot(params: Partial<MainArgs> = {}) {
const args = {...mainDefaults(), ...params}
const session = createSession(args.appState)
const data = decodeSessionState(await session.load())
const appState = new AppState({
path: args.appState,
backupDir: args.backupDir,
})
const data = appState.data
const windows = new WindowManager(data)
const store = createMainStore(data?.globalState)
DomainModel.store = store
const appMeta = await getAppMeta()
return new MainObject(windows, store, session, args, appMeta)
return new MainObject(windows, store, appState, args, appMeta)
}

// Only call this from boot
constructor(
readonly windows: WindowManager,
readonly store: ReduxStore<State, any>,
readonly session: Session,
readonly appState: AppState,
readonly args: MainArgs,
readonly appMeta: AppMeta
) {
Expand Down Expand Up @@ -112,15 +112,19 @@ export class MainObject {
keytar.deletePassword(toRefreshTokenKey(l.id), os.userInfo().username)
keytar.deletePassword(toAccessTokenKey(l.id), os.userInfo().username)
})
await this.session.delete()
await this.appState.reset()
app.relaunch()
app.exit(0)
}

saveSession() {
this.appState.save(this.appStateData)
}

get appStateData() {
const windowState = this.windows.serialize()
const mainState = getPersistedGlobalState(this.store.getState())
this.session.saveSync(encodeSessionState(windowState, mainState))
return encodeSessionState(windowState, mainState)
}

onBeforeQuit() {
Expand Down
34 changes: 34 additions & 0 deletions apps/zui/src/electron/app-state-backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import path from "path"
import fs from "fs"
import {AppStateFile} from "./app-state-file"

export class AppStateBackup {
constructor(public dir: string) {
if (!fs.existsSync(this.dir)) fs.mkdirSync(this.dir)
}

save(file: AppStateFile) {
const backupPath = this.getPath(file.version)
fs.copyFileSync(file.path, backupPath)
}

join(name: string) {
return path.join(this.dir, name)
}

getPath(version: number) {
const existing = fs.readdirSync(this.dir)
let i = 1
let name = ""
do {
name = this.getName(version, i++)
} while (existing.includes(name))

return this.join(name)
}

getName(version: number, n: number) {
if (n > 1) return `${version}_backup_${n}.json`
else return `${version}_backup.json`
}
}
80 changes: 80 additions & 0 deletions apps/zui/src/electron/app-state-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import fs from "fs-extra"
import path from "path"
import {isNumber} from "lodash"

export class AppStateFile {
state: any = undefined

constructor(public path: string) {
if (this.noFile) return
if (this.noContent) return
this.state = this.parse()
if (this.noJSON) throw new Error(JSON_ERROR_MSG(this.path))
if (this.noVersion) throw new Error(VERSION_ERROR_MSG(this.path))
}

create(version: number) {
this.write({version, data: undefined})
}

write(state) {
fs.ensureDirSync(path.dirname(this.path))
fs.writeFileSync(this.path, JSON.stringify(state))
this.state = state
}

update(data) {
this.write({version: this.version, data})
}

destroy() {
if (fs.existsSync(this.path)) fs.rmSync(this.path)
}

get isEmpty() {
return !this.state
}

get name() {
return path.basename(this.path)
}

get version() {
return this.state.version
}

get data() {
return this.state.data
}

private get noFile() {
return !fs.existsSync(this.path)
}

private get noContent() {
return fs.statSync(this.path).size === 0
}

private get noJSON() {
return !this.state
}

private get noVersion() {
return !(typeof this.state === "object" && isNumber(this.state.version))
}

private parse() {
try {
return JSON.parse(fs.readFileSync(this.path, "utf8"))
} catch {
return null
}
}
}

const JSON_ERROR_MSG = (path) =>
"The application state file could not be parsed as JSON:\npath: " + path

const VERSION_ERROR_MSG = (path) =>
"The application state file is a JSON object but is missing the top-level version key of type number\npath: " +
path
146 changes: 146 additions & 0 deletions apps/zui/src/electron/app-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* @jest-environment jsdom
*/

import fsExtra from "fs-extra"

import path from "path"
import os from "os"

import disableLogger from "src/test/unit/helpers/disableLogger"
import {Migrations} from "./migrations"
import {AppState} from "./app-state"
import states from "src/test/unit/states"

const dir = path.join(os.tmpdir(), "session.test.ts")
const file = path.join(dir, "appState.json")
const backupDir = path.join(dir, "backups")

disableLogger()
beforeEach(() => fsExtra.ensureDir(dir))
afterEach(() => fsExtra.remove(dir))

function init() {
return new AppState({path: file, backupDir})
}

test("app state file that doesn't exist", () => {
expect(fsExtra.existsSync(file)).toBe(false)
const appState = init()
expect(appState.data).toEqual(undefined)
expect(appState.version).toEqual(Migrations.latestVersion)
})

test("app state file that is empty", () => {
fsExtra.createFileSync(file)
const appState = init()
expect(appState.data).toEqual(undefined)
expect(appState.version).toEqual(Migrations.latestVersion)
})

test("app state file that does not parse to JSON", () => {
fsExtra.writeFileSync(file, "---\nthis_is_yaml: true\n---")
expect(fsExtra.existsSync(file)).toBe(true)
expect(() => {
init()
}).toThrow(/The application state file could not be parsed as JSON/)
})

test("app state file that is JSON but has not version number", async () => {
const v8 = {
order: [],
windows: {},
globalState: {investigation: [], pools: {zqd: {}}, version: "6"},
}
fsExtra.writeJSONSync(file, v8)

expect(() => {
init()
}).toThrow(
/The application state file is a JSON object but is missing the top-level version key of type number/
)
})

test("app state is migrated if migrations are pending", () => {
const needsMigration = states.getPath("v1.18.0.json")
const oldState = fsExtra.readJSONSync(needsMigration)
expect(oldState.version).not.toEqual(Migrations.latestVersion)

fsExtra.cpSync(needsMigration, file)
const appState = init()

expect(appState.version).toBe(Migrations.latestVersion)
})

test("app state is backed if migration is needed", () => {
const needsMigration = states.getPath("v1.18.0.json")
const oldState = fsExtra.readJSONSync(needsMigration)
fsExtra.cpSync(needsMigration, file)
init()
expect(fsExtra.existsSync(backupDir)).toBe(true)
const backup = fsExtra.readdirSync(backupDir)[0]
expect(backup).toMatch(/^\d{12}_backup.json$/)
const backupFile = path.join(backupDir, backup)
expect(fsExtra.readJSONSync(backupFile)).toEqual(oldState)
})

test("app state is not backed up if no migration is needed", () => {
init()
expect(fsExtra.existsSync(backupDir)).toBe(true)
expect(fsExtra.readdirSync(backupDir)).toEqual([])
})

test("backing up the same version twice creates distict backups", () => {
fsExtra.cpSync(states.getPath("v1.18.0.json"), file)
init()
expect(fsExtra.readdirSync(backupDir)).toEqual(["202407221450_backup.json"])
fsExtra.cpSync(states.getPath("v1.18.0.json"), file)
init()
expect(fsExtra.readdirSync(backupDir)).toEqual([
"202407221450_backup.json",
"202407221450_backup_2.json",
])
fsExtra.cpSync(states.getPath("v1.18.0.json"), file)
init()
expect(fsExtra.readdirSync(backupDir)).toEqual([
"202407221450_backup.json",
"202407221450_backup_2.json",
"202407221450_backup_3.json",
])
})

test("a migration error does not affect the state file", () => {
const fixture = states.getPath("v1.18.0.json")
fsExtra.cpSync(fixture, file)
Migrations.all.push({
version: 9999_99_99_99_99,
migrate: (bang) => bang.boom.boom,
})
expect(() => {
init()
}).toThrow(/Cannot read properties of undefined \(reading 'boom'\)/)
Migrations.all.pop()
expect(fsExtra.readJSONSync(file)).toEqual(fsExtra.readJSONSync(fixture))
})

test("app state saves new data", () => {
const appState = init()
appState.save({hello: "test"})

expect(fsExtra.readJSONSync(file)).toEqual({
version: Migrations.latestVersion,
data: {hello: "test"},
})
expect(appState.data).toEqual({hello: "test"})
expect(appState.version).toEqual(Migrations.latestVersion)
})

test("app state reset", () => {
const appState = init()
appState.save({hello: "test"})

appState.reset()
expect(fsExtra.readJSONSync(file)).toEqual({
version: Migrations.latestVersion,
})
})
48 changes: 48 additions & 0 deletions apps/zui/src/electron/app-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {AppStateBackup} from "./app-state-backup"
import {AppStateFile} from "./app-state-file"
import {Migrations} from "./migrations"

/**
* The application state is saved in a json file called appState.json
* It contains a single object.
* {
* version: number,
* data: object
* }
*
* In the code below, references to "state" mean the root object.
* References to version and data mean the keys inside the root object.
*/

export class AppState {
file: AppStateFile

constructor(args: {path: string | null; backupDir: string}) {
const file = new AppStateFile(args.path)
if (file.isEmpty) file.create(Migrations.latestVersion)
const migrations = Migrations.init({from: file.version})
const backup = new AppStateBackup(args.backupDir)
if (migrations.arePending) {
backup.save(file)
file.write(migrations.runPending(file.state))
}
this.file = file
}

get data() {
return this.file.data
}

get version() {
return this.file.version
}

reset() {
this.file.destroy()
this.file.create(Migrations.latestVersion)
}

save(data) {
this.file.update(data)
}
}
Loading

0 comments on commit edbe753

Please sign in to comment.