Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Application State Improvements #3166

Merged
merged 14 commits into from
Nov 14, 2024
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
Loading