From 597370ae078806ecccdd446fff62513d6b7f184e Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 4 Mar 2021 15:18:35 +0200 Subject: [PATCH] persist local-storage state in external json-file Signed-off-by: Roman --- src/renderer/hooks/useStorage.ts | 4 +- src/renderer/utils/createStorage.ts | 223 ++++++++++++++++++++++------ 2 files changed, 182 insertions(+), 45 deletions(-) diff --git a/src/renderer/hooks/useStorage.ts b/src/renderer/hooks/useStorage.ts index 97b0588d29..0f2aa2b317 100644 --- a/src/renderer/hooks/useStorage.ts +++ b/src/renderer/hooks/useStorage.ts @@ -1,7 +1,7 @@ import { useState } from "react"; -import { createStorage, IStorageHelperOptions } from "../utils"; +import { createStorage, StorageHelperOptions } from "../utils"; -export function useStorage(key: string, initialValue?: T, options?: IStorageHelperOptions) { +export function useStorage(key: string, initialValue?: T, options?: StorageHelperOptions) { const storage = createStorage(key, initialValue, options); const [storageValue, setStorageValue] = useState(storage.get()); const setValue = (value: T) => { diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index 54c064ab75..b91226a3e3 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -1,73 +1,210 @@ -// Helper to work with browser's local/session storage api +// Helper for work with persistent local storage (default: window.localStorage) +// TODO: write unit/integration tests -export interface IStorageHelperOptions { - addKeyPrefix?: boolean; - useSession?: boolean; // use `sessionStorage` instead of `localStorage` -} +import { app, remote } from "electron"; +import path from "path"; +import { ensureDirSync, readJsonSync, writeJson } from "fs-extra"; +import type { CreateObservableOptions } from "mobx/lib/api/observable"; +import { action, comparer, observable, reaction, toJS, when } from "mobx"; +import produce, { Draft, setAutoFreeze } from "immer"; +import { isEqual, isFunction, isObject } from "lodash"; -export function createStorage(key: string, defaultValue?: T, options?: IStorageHelperOptions) { +setAutoFreeze(false); // allow to merge observables + +export function createStorage(key: string, defaultValue?: T, options?: StorageHelperOptions) { return new StorageHelper(key, defaultValue, options); } -export class StorageHelper { - static keyPrefix = "lens_"; +export interface StorageHelperOptions extends StorageConfiguration { + autoInit?: boolean; // default: true +} - static defaultOptions: IStorageHelperOptions = { - addKeyPrefix: true, - useSession: false, +export interface StorageConfiguration { + storage?: StorageAdapter; + observable?: CreateObservableOptions; +} + +export interface StorageAdapter { + [metadata: string]: any; + getItem(key: string): T; // import + setItem(key: string, value: T): void; // export + removeItem?(key: string): void; // if not provided setItem(key,undefined) will be used + onChange?(data: { key: string, value: T, oldValue?: T }): void; +} + +export class StorageHelper { + static defaultOptions: StorageHelperOptions = { + autoInit: true, + get storage() { + return jsonFileSyncStorageAdapter; + }, + observable: { + deep: true, + equals: comparer.shallow, + } }; - constructor(protected key: string, protected defaultValue?: T, protected options?: IStorageHelperOptions) { - this.options = Object.assign({}, StorageHelper.defaultOptions, options); + private data = observable.box(); + @observable.ref storage: StorageAdapter; + @observable initialized = false; + whenReady = when(() => this.initialized); - if (this.options.addKeyPrefix) { - this.key = StorageHelper.keyPrefix + key; + constructor(readonly key: string, readonly defaultValue?: T, readonly options: StorageHelperOptions = {}) { + this.options = { ...StorageHelper.defaultOptions, ...options }; + this.configure(); + this.reset(); + + if (this.options.autoInit) { + this.init(); } } - protected get storage() { - if (this.options.useSession) return window.sessionStorage; + @action + init() { + if (this.initialized) return; - return window.localStorage; + try { + const value = this.load(); + const notEmpty = value != null; + const notDefault = !this.isDefaultValue(value); + + if (notEmpty && notDefault) { + this.merge(value); + } + this.initialized = true; + } catch (error) { + console.error(`[init]: ${error}`, this); + } + } + + isDefaultValue(value: T): boolean { + return isEqual(value, this.defaultValue); + } + + @action + private configure({ storage, observable }: StorageConfiguration = this.options): this { + if (storage) this.storage = storage; + if (observable) this.configureObservable(observable); + + return this; + } + + @action + private configureObservable(options: CreateObservableOptions = {}) { + this.data = observable.box(this.data.get(), { + ...StorageHelper.defaultOptions.observable, // inherit default observability options + ...options, + }); + this.data.observe(change => { + const { newValue, oldValue } = toJS(change, { recurseEverything: true }); + + this.onChange(newValue, oldValue); + }); + } + + protected onChange(value: T, oldValue?: T) { + if (!this.initialized) return; + + this.storage.onChange?.({ value, oldValue, key: this.key }); + this.storage.setItem(this.key, value); + } + + load(): T { + return this.storage.getItem(this.key); } get(): T { - const strValue = this.storage.getItem(this.key); - - if (strValue != null) { - try { - return JSON.parse(strValue); - } catch (e) { - console.error(`Parsing json failed for pair: ${this.key}=${strValue}`); - } - } - - return this.defaultValue; + return this.data.get(); } set(value: T) { - this.storage.setItem(this.key, JSON.stringify(value)); - - return this; + this.data.set(value); } - merge(value: Partial) { - const currentValue = this.get(); - - return this.set(Object.assign(currentValue, value)); + reset() { + this.set(this.defaultValue); } clear() { - this.storage.removeItem(this.key); - - return this; + this.data.set(null); } - getDefaultValue() { - return this.defaultValue; + merge(value: Partial | ((draft: Draft) => Partial | void)) { + const updater = isFunction(value) ? value : (state: Draft) => { + if (isObject(state)) return Object.assign(state, value); + + return value; + }; + const currentValue = this.toJS(); + const nextValue = produce(currentValue, updater) as T; + + this.set(nextValue); } - restoreDefaultValue() { - return this.set(this.defaultValue); + toJS() { + return toJS(this.get(), { recurseEverything: true }); } } + +export const localStorageAdapter: StorageAdapter = { + getItem(key: string) { + return JSON.parse(localStorage.getItem(key)); + }, + setItem(key: string, value: any) { + localStorage.setItem(key, JSON.stringify(value)); + }, + removeItem(key: string) { + localStorage.removeItem(key); + } +}; + +// TODO: remove after merge https://github.com/lensapp/lens/pull/2269 +export const jsonFileSyncStorageAdapter: StorageAdapter = { + cwd: path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"), + data: observable.map([], { deep: false }), + initialized: false, + + get filePath() { + const clusterId = location.hostname.split(".").slice(-2, -1)[0]; + + return path.resolve(this.cwd, `${clusterId ?? "app"}.json`); + }, + + init() { + if (this.initialized) return; + + try { + ensureDirSync(this.cwd, { mode: 0o755 }); + const data = readJsonSync(this.filePath, { throws: false }) || {}; + + this.data.replace(data); + this.bindAutoSave(); + } catch (error) { + console.error(`[init]: ${this.filePath} failed: ${error}`, this); + } finally { + this.initialized = true; + } + }, + + bindAutoSave() { + return reaction(() => this.data.toJSON(), async (data) => { + try { + await writeJson(this.filePath, data, { spaces: 2 }); + } catch (error) { + console.error(`[save]: ${this.filePath} failed: ${error}`, this); + } + }); + }, + + getItem(key: string) { + this.init(); + + return this.data.get(key); + }, + setItem(key: string, value: any) { + this.data.set(key, value); + }, + removeItem(key: string) { + this.data.delete(key); + } +};