diff --git a/src/renderer/utils/__tests__/createStorage.test.ts b/src/renderer/utils/__tests__/storageHelper.test.ts similarity index 97% rename from src/renderer/utils/__tests__/createStorage.test.ts rename to src/renderer/utils/__tests__/storageHelper.test.ts index e79bc149b1..4dfb318105 100644 --- a/src/renderer/utils/__tests__/createStorage.test.ts +++ b/src/renderer/utils/__tests__/storageHelper.test.ts @@ -1,4 +1,4 @@ -import { StorageAdapter, StorageHelper } from "../createStorage"; +import { StorageAdapter, StorageHelper } from "../storageHelper"; describe("renderer/utils/StorageHelper", () => { describe("window.localStorage might be used as StorageAdapter", () => { @@ -127,4 +127,6 @@ describe("renderer/utils/StorageHelper", () => { }); }); + // TODO: test observability options + }); diff --git a/src/renderer/utils/createStorage.ts b/src/renderer/utils/createStorage.ts index b6048df685..0f1d6f55c2 100755 --- a/src/renderer/utils/createStorage.ts +++ b/src/renderer/utils/createStorage.ts @@ -1,211 +1,70 @@ -// Helper for work with persistent local storage (default: window.localStorage) - -import { app, remote } from "electron"; -import path from "path"; -import { ensureDir, readJson, writeJson } from "fs-extra"; +// Keeps window.localStorage state in external JSON-files. +// Because app creates random port between restarts => storage session wiped out each time. import type { CreateObservableOptions } from "mobx/lib/api/observable"; -import { action, comparer, observable, reaction, toJS, when } from "mobx"; -import produce, { Draft, enableMapSet, setAutoFreeze } from "immer"; -import { isEqual, isFunction, isObject, noop } from "lodash"; -setAutoFreeze(false); // allow to merge observables -enableMapSet(); // allow merging maps and sets +import path from "path"; +import { app, remote } from "electron"; +import { observable, reaction, when } from "mobx"; +import fse from "fs-extra"; +import { StorageHelper } from "./storageHelper"; +import { clusterStore, getHostedClusterId } from "../../common/cluster-store"; +import logger from "../../main/logger"; + +let initialized = false; +const loaded = observable.box(false); +const storage = observable.map(); export function createStorage(key: string, defaultValue?: T, observableOptions?: CreateObservableOptions) { + const clusterId = getHostedClusterId(); + const savingFolder = path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"); + const jsonFilePath = path.resolve(savingFolder, `${clusterId ?? "app"}.json`); + + if (!initialized) { + initialized = true; + + // read once per cluster domain + fse.readJson(jsonFilePath) + .then((data = {}) => storage.merge(data)) + .catch(() => null) // ignore empty / non-existing / invalid json files + .finally(() => loaded.set(true)); + + // bind auto-saving + reaction(() => storage.toJSON(), saveFile, { delay: 250 }); + + // remove json-file when cluster deleted + when(() => clusterStore.removedClusters.has(clusterId)).then(removeFile); + } + + async function saveFile(json = {}) { + try { + await fse.ensureDir(savingFolder, { mode: 0o755 }); + await fse.writeJson(jsonFilePath, json, { spaces: 2 }); + } catch (error) { + logger.error(`[save]: ${error}`, { json, jsonFilePath }); + } + } + + function removeFile() { + logger.debug("[remove]:", jsonFilePath); + fse.unlink(jsonFilePath).catch(Function); + } + return new StorageHelper(key, { autoInit: true, - storage: jsonFileStorageAdapter as StorageAdapter, observable: observableOptions, defaultValue, + storage: { + async getItem(key: string) { + await when(() => loaded.get()); + + return storage.get(key); + }, + setItem(key: string, value: any) { + storage.set(key, value); + }, + removeItem(key: string) { + storage.delete(key); + } + }, }); } - -export type StorageModel = Record; // any json-compatible model - -export interface StorageAdapter { - [metadata: string]: any; - getItem(key: string): T | Promise; - setItem(key: string, value: T): void; - removeItem(key: string): void; - onChange?(change: { key: string, value: T, oldValue?: T }): void; -} - -export interface StorageHelperOptions { - autoInit?: boolean; // start preloading data immediately, default: true - observable?: CreateObservableOptions; - storage: StorageAdapter; - defaultValue?: T; -} - -export class StorageHelper { - static defaultOptions: Partial> = { - autoInit: true, - observable: { - deep: true, - equals: comparer.shallow, - } - }; - - @observable private data = observable.box(); - @observable initialized = false; - whenReady = when(() => this.initialized); - - get storage(): StorageAdapter { - return this.options.storage; - } - - get defaultValue(): T { - return this.options.defaultValue; - } - - constructor(readonly key: string, private options: StorageHelperOptions) { - this.options = { ...StorageHelper.defaultOptions, ...options }; - this.configureObservable(); - this.reset(); - - if (this.options.autoInit) { - this.init(); - } - } - - @action - async init() { - if (this.initialized) return; - - try { - await this.load(); - this.initialized = true; - } catch (error) { - console.error(`[init]: ${error}`, this); - } - } - - isDefaultValue(value: T): boolean { - return isEqual(value, this.defaultValue); - } - - @action - private configureObservable(options = this.options.observable) { - 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; - - try { - if (value == null) { - this.storage.removeItem(this.key); - } else { - this.storage.setItem(this.key, value); - } - - this.storage.onChange?.({ value, oldValue, key: this.key }); - } catch (error) { - console.error(`[change]: ${error}`, this, { value, oldValue }); - } - } - - async load(): Promise { - const value = await this.storage.getItem(this.key); - const notEmpty = value != null; - const notDefault = !this.isDefaultValue(value); - - if (notEmpty && notDefault) { - this.merge(value); - } - - return value; - } - - get(): T { - return this.data.get(); - } - - set(value: T) { - this.data.set(value); - } - - reset() { - this.set(this.defaultValue); - } - - clear() { - this.data.set(null); - } - - 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); - } - - toJS() { - return toJS(this.get(), { recurseEverything: true }); - } -} - -/** - * Keep intended window.localStorage state in external JSON-file. - * Reason: app creates random ports between restarts and as a result storage not persistent. - */ -export const jsonFileStorageAdapter: StorageAdapter = { - cwd: path.resolve((app || remote.app).getPath("userData"), "lens-local-storage"), - data: observable.map([], { deep: false }), - initialized: false, - - get filePath() { - // TODO: use getHostedClusterId() after moving out from cluster-store.ts - const clusterId = location.hostname.split(".").slice(-2, -1)[0]; - - return path.resolve(this.cwd, `${clusterId ?? "app"}.json`); - }, - - async init() { - if (this.initialized) return; - - const data = await readJson(this.filePath).catch(noop); - - if (data) { - this.data.replace(data); - } - this.bindAutoSave(); - this.initialized = true; - }, - - bindAutoSave() { - return reaction(() => this.data.toJSON(), async (data) => { - try { - await ensureDir(this.cwd, { mode: 0o755 }); - await writeJson(this.filePath, data, { spaces: 2 }); - } catch (error) { - console.error(`[save]: ${this.filePath}: ${error}`, this); - } - }); - }, - - async getItem(key: string) { - await this.init(); - - return this.data.get(key); - }, - setItem(key: string, value: any) { - this.data.set(key, value); - }, - removeItem(key: string) { - this.data.delete(key); - } -}; diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index e546f94154..cb4ef559e7 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -7,6 +7,7 @@ export * from "./cssNames"; export * from "../../common/event-emitter"; export * from "./saveFile"; export * from "./prevDefault"; +export * from "./storageHelper"; export * from "./createStorage"; export * from "./interval"; export * from "./copyToClipboard"; diff --git a/src/renderer/utils/storageHelper.ts b/src/renderer/utils/storageHelper.ts new file mode 100755 index 0000000000..9d95a1e1aa --- /dev/null +++ b/src/renderer/utils/storageHelper.ts @@ -0,0 +1,144 @@ +// Helper for working with storages (e.g. window.localStorage, NodeJS/file-system, etc.) + +import type { CreateObservableOptions } from "mobx/lib/api/observable"; +import { action, comparer, observable, toJS, when } from "mobx"; +import produce, { Draft, enableMapSet, setAutoFreeze } from "immer"; +import { isEqual, isFunction, isObject } from "lodash"; + +setAutoFreeze(false); // allow to merge observables +enableMapSet(); // allow merging maps and sets + +export interface StorageAdapter { + [metadata: string]: any; + getItem(key: string): T | Promise; + setItem(key: string, value: T): void; + removeItem(key: string): void; + onChange?(change: { key: string, value: T, oldValue?: T }): void; +} + +export interface StorageHelperOptions { + autoInit?: boolean; // start preloading data immediately, default: true + observable?: CreateObservableOptions; + storage: StorageAdapter; + defaultValue?: T; +} + +export class StorageHelper { + static defaultOptions: Partial> = { + autoInit: true, + observable: { + deep: true, + equals: comparer.shallow, + } + }; + + @observable private data = observable.box(); + @observable initialized = false; + whenReady = when(() => this.initialized); + + get storage(): StorageAdapter { + return this.options.storage; + } + + get defaultValue(): T { + return this.options.defaultValue; + } + + constructor(readonly key: string, private options: StorageHelperOptions) { + this.options = { ...StorageHelper.defaultOptions, ...options }; + this.configureObservable(); + this.reset(); + + if (this.options.autoInit) { + this.init(); + } + } + + @action + async init() { + if (this.initialized) return; + + try { + await this.load(); + this.initialized = true; + } catch (error) { + console.error(`[init]: ${error}`, this); + } + } + + isDefaultValue(value: T): boolean { + return isEqual(value, this.defaultValue); + } + + @action + private configureObservable(options = this.options.observable) { + 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; + + try { + if (value == null) { + this.storage.removeItem(this.key); + } else { + this.storage.setItem(this.key, value); + } + + this.storage.onChange?.({ value, oldValue, key: this.key }); + } catch (error) { + console.error(`[change]: ${error}`, this, { value, oldValue }); + } + } + + async load(): Promise { + const value = await this.storage.getItem(this.key); + const notEmpty = value != null; + const notDefault = !this.isDefaultValue(value); + + if (notEmpty && notDefault) { + this.merge(value); + } + + return value; + } + + get(): T { + return this.data.get(); + } + + set(value: T) { + this.data.set(value); + } + + reset() { + this.set(this.defaultValue); + } + + clear() { + this.data.set(null); + } + + 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 nextValue = produce(this.get(), updater) as T; + + this.set(nextValue); + } + + toJS() { + return toJS(this.get(), { recurseEverything: true }); + } +}