diff --git a/package.json b/package.json index cceecd8989..5afbc706b8 100644 --- a/package.json +++ b/package.json @@ -170,9 +170,9 @@ "@types/node": "^12.12.45", "@types/proper-lockfile": "^4.1.1", "@types/tar": "^4.0.3", + "conf": "^7.0.1", "crypto-js": "^4.0.0", "electron-promise-ipc": "^2.1.0", - "electron-store": "^5.2.0", "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", "filenamify": "^4.1.0", @@ -193,6 +193,7 @@ "request": "^2.88.2", "request-promise-native": "^1.0.8", "semver": "^7.3.2", + "serializr": "^2.0.3", "shell-env": "^3.0.0", "tar": "^6.0.2", "tcp-port-used": "^1.0.1", diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 607de54ca1..7536364e49 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,32 +1,32 @@ -import ElectronStore from "electron-store" -import { Singleton } from "./utils/singleton"; +import Config from "conf" +import Singleton from "./utils/singleton"; import migrations from "../migrations/cluster-store" import { Cluster, ClusterBaseInfo } from "../main/cluster"; export class ClusterStore extends Singleton { - private store = new ElectronStore({ - name: "lens-cluster-store", + private storeConfig = new Config({ + configName: "lens-cluster-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names migrations: migrations, }) public getAllClusterObjects(): Cluster[] { - return this.store.get("clusters", []).map((clusterInfo: ClusterBaseInfo) => { + return this.storeConfig.get("clusters", []).map((clusterInfo: ClusterBaseInfo) => { return new Cluster(clusterInfo) }) } public getAllClusters(): ClusterBaseInfo[] { - return this.store.get("clusters", []) + return this.storeConfig.get("clusters", []) } public removeCluster(id: string): void { - this.store.delete(id); + this.storeConfig.delete(id); const clusterBaseInfos = this.getAllClusters() const index = clusterBaseInfos.findIndex((cbi) => cbi.id === id) if (index !== -1) { clusterBaseInfos.splice(index, 1) - this.store.set("clusters", clusterBaseInfos) + this.storeConfig.set("clusters", clusterBaseInfos) } } @@ -62,7 +62,7 @@ export class ClusterStore extends Singleton { } else { clusters[index] = storable } - this.store.set("clusters", clusters) + this.storeConfig.set("clusters", clusters) } public storeClusters(clusters: ClusterBaseInfo[]) { diff --git a/src/common/request.ts b/src/common/request.ts index 092b1a756a..ca6fbd2893 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -1,12 +1,11 @@ import request from "request" import { userStore } from "./user-store" -export function globalRequestOpts(requestOpts: request.Options ) { - const userPrefs = userStore.getPreferences() - if (userPrefs.httpsProxy) { - requestOpts.proxy = userPrefs.httpsProxy +export function globalRequestOpts(requestOpts: request.Options) { + const { httpsProxy, allowUntrustedCAs } = userStore.preferences + if (httpsProxy) { + requestOpts.proxy = httpsProxy } - requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs; - + requestOpts.rejectUnauthorized = !allowUntrustedCAs; return requestOpts } diff --git a/src/common/tracker.ts b/src/common/tracker.ts index 2476c47dbd..97550d2ad6 100644 --- a/src/common/tracker.ts +++ b/src/common/tracker.ts @@ -1,10 +1,12 @@ +import { app, App, remote } from "electron" import ua from "universal-analytics" import { machineIdSync } from "node-machine-id" +import Singleton from "./utils/singleton"; import { userStore } from "./user-store" -const GA_ID = "UA-159377374-1" +export class Tracker extends Singleton { + static readonly GA_ID = "UA-159377374-1" -export class Tracker { protected visitor: ua.Visitor protected machineId: string = null; protected ip: string = null; @@ -12,31 +14,35 @@ export class Tracker { protected locale: string; protected electronUA: string; - constructor(app: Electron.App) { + private constructor(app: App) { + super(); try { - this.visitor = ua(GA_ID, machineIdSync(), {strictCidFormat: false}) + this.visitor = ua(Tracker.GA_ID, machineIdSync(), { strictCidFormat: false }) } catch (error) { - this.visitor = ua(GA_ID) + this.visitor = ua(Tracker.GA_ID) } this.visitor.set("dl", "https://lensapptelemetry.lakendlabs.com") } - public async event(eventCategory: string, eventAction: string) { - return new Promise(async (resolve, reject) => { - if (!this.telemetryAllowed()) { - resolve() - return + protected async isTelemetryAllowed(): Promise { + return userStore.preferences.allowTelemetry; + } + + async event(eventCategory: string, eventAction: string, otherParams = {}) { + try { + const allowed = await this.isTelemetryAllowed(); + if (!allowed) { + return; } this.visitor.event({ ec: eventCategory, - ea: eventAction + ea: eventAction, + ...otherParams, }).send() - resolve() - }) - } - - protected telemetryAllowed() { - const userPrefs = userStore.getPreferences() - return !!userPrefs.allowTelemetry + } catch (err) { + console.error(`Failed to track "${eventCategory}:${eventAction}"`, err) + } } } + +export const tracker: Tracker = Tracker.getInstance(app || remote.app); diff --git a/src/common/user-store.ts b/src/common/user-store.ts index ad71deefae..d7ba191ee1 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -1,59 +1,105 @@ -import ElectronStore from "electron-store" +import { app, remote } from "electron" +import { computed, observable, reaction, toJS } from "mobx"; +import Config from "conf" +import semver from "semver" import migrations from "../migrations/user-store" -import { Singleton } from "./utils/singleton"; +import Singleton from "./utils/singleton"; +import { getAppVersion } from "./utils/app-version"; +import { tracker } from "./tracker"; + +export interface UserStoreModel { + lastSeenAppVersion: string; + seenContexts: string[]; + preferences: UserPreferences; +} export interface UserPreferences { httpsProxy?: string; - colorTheme?: string; + colorTheme?: string | "dark"; allowUntrustedCAs?: boolean; allowTelemetry?: boolean; - downloadMirror?: string; + downloadMirror?: string | "default"; } export class UserStore extends Singleton { - protected store = new ElectronStore({ - name: "lens-user-store", - migrations: migrations, - }); + private storeConfig: Config; - public lastSeenAppVersion() { - return this.store.get('lastSeenAppVersion', "0.0.0") + @observable isReady = false; + @observable lastSeenAppVersion = "0.0.0" + @observable seenContexts = observable.set(); + + @observable preferences: UserPreferences = { + allowTelemetry: true, + colorTheme: "dark", + downloadMirror: "default", + }; + + @computed get hasNewAppVersion() { + return semver.gt(getAppVersion(), this.lastSeenAppVersion); } - public setLastSeenAppVersion(version: string) { - this.store.set('lastSeenAppVersion', version) + private constructor() { + super(); + this.init(); } - public getSeenContexts(): Array { - return this.store.get("seenContexts", []) + async init() { + /*await*/ this.load(); + this.bindEvents(); + this.isReady = true; } - public storeSeenContext(newContexts: string[]) { - const seenContexts = this.getSeenContexts().concat(newContexts) - // store unique contexts by casting array to set first - const newContextSet = new Set(seenContexts) - const allContexts = [...newContextSet] - this.store.set("seenContexts", allContexts) - return allContexts + saveLastSeenAppVersion() { + this.lastSeenAppVersion = getAppVersion(); } - public setPreferences(preferences: UserPreferences) { - this.store.set('preferences', preferences) + // todo: use "conf" as pseudo-async for more future-proof usages + protected async load() { + this.storeConfig = new Config({ + configName: "lens-user-store", + migrations: migrations, + cwd: (app || remote.app).getPath("userData"), + }); + this.fromStore(this.storeConfig.store); } - public getPreferences(): UserPreferences { - const prefs = this.store.get("preferences", {}) - if (!prefs.colorTheme) { - prefs.colorTheme = "dark" + protected bindEvents() { + // refresh from file-system updates + this.storeConfig.onDidAnyChange((data, oldValue) => { + this.fromStore(data); + }); + + // refresh config file from runtime + reaction(() => this.toJSON(), model => { + this.storeConfig.store = model; + }); + + // track telemetry availability + reaction(() => this.preferences.allowTelemetry, allowed => { + tracker.event("telemetry", allowed ? "enabled" : "disabled"); + }); + } + + // todo: use "serializr" ? + protected fromStore(data: Partial = {}) { + const { lastSeenAppVersion, seenContexts, preferences } = data + if (lastSeenAppVersion) { + this.lastSeenAppVersion = lastSeenAppVersion; } - if (!prefs.downloadMirror) { - prefs.downloadMirror = "default" + if (seenContexts) { + this.seenContexts = observable.set(seenContexts) } - if (prefs.allowTelemetry === undefined) { - prefs.allowTelemetry = true + if (preferences) { + Object.assign(this.preferences, preferences); } + } - return prefs + protected toJSON(): UserStoreModel { + return toJS({ + lastSeenAppVersion: this.lastSeenAppVersion, + seenContexts: Array.from(this.seenContexts), + preferences: this.preferences, + }) } } diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index a5d2aa5ca3..6ecb89c1cf 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -6,7 +6,8 @@ * const usersStore: UsersStore = UsersStore.getInstance(); */ -export class Singleton { +// todo: maybe convert to @decorator +class Singleton { private static instances = new WeakMap(); // todo: figure out how to infer child class + arguments types @@ -21,3 +22,6 @@ export class Singleton { Singleton.instances.delete(this); } } + +export { Singleton } +export default Singleton; \ No newline at end of file diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 76c779b90d..8d436efa93 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -1,30 +1,26 @@ -import ElectronStore from "electron-store" -import { Singleton } from "./utils/singleton"; +import Config from "conf" +import Singleton from "./utils/singleton"; import { clusterStore } from "./cluster-store" +import { getAppVersion } from "./utils/app-version"; export type WorkspaceId = string; -export interface WorkspaceData { +export interface WorkspaceStoreModel { + workspaces: Workspace[] +} + +export interface Workspace { id: WorkspaceId; name: string; description?: string; } -export class Workspace implements WorkspaceData { - public id: string - public name: string - public description?: string - - public constructor(data: WorkspaceData) { - Object.assign(this, data) - } -} - export class WorkspaceStore extends Singleton { - static defaultId = "default" + static readonly defaultId = "default" - private store = new ElectronStore({ - name: "lens-workspace-store" + private storeConfig = new Config({ + configName: "lens-workspace-store", + projectVersion: getAppVersion(), }); private constructor() { @@ -46,11 +42,10 @@ export class WorkspaceStore extends Singleton { } public getAllWorkspaces(): Workspace[] { - const workspacesData: WorkspaceData[] = this.store.get("workspaces", []) - return workspacesData.map((wsd) => new Workspace(wsd)) + return this.storeConfig.get("workspaces", []) } - public saveWorkspace(workspace: WorkspaceData) { + public saveWorkspace(workspace: Workspace) { const workspaces = this.getAllWorkspaces() const index = workspaces.findIndex((w) => w.id === workspace.id) if (index !== -1) { @@ -58,7 +53,7 @@ export class WorkspaceStore extends Singleton { } else { workspaces.push(workspace) } - this.store.set("workspaces", workspaces) + this.storeConfig.set("workspaces", workspaces) } public removeWorkspace(workspace: Workspace) { @@ -70,7 +65,7 @@ export class WorkspaceStore extends Singleton { if (index !== -1) { clusterStore.removeClustersByWorkspace(workspace.id) workspaces.splice(index, 1) - this.store.set("workspaces", workspaces) + this.storeConfig.set("workspaces", workspaces) } } } diff --git a/src/main/index.ts b/src/main/index.ts index 15e0b12e7b..ebe5f4b187 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,7 +18,6 @@ import initMenu from "./menu" import * as proxy from "./proxy" import { WindowManager } from "./window-manager"; import { clusterStore } from "../common/cluster-store" -import { tracker } from "./tracker" import { ClusterManager } from "./cluster-manager"; import AppUpdater from "./app-updater" import { shellSync } from "./shell-sync" @@ -26,6 +25,7 @@ import { getFreePort } from "./port" import { mangleProxyEnv } from "./proxy-env" import { findMainWebContents } from "./webcontents" import { registerStaticProtocol } from "../common/register-static"; +import { tracker } from "../common/tracker"; mangleProxyEnv() if (app.commandLine.getSwitchValue("proxy-server") !== "") { diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index 73efc507b3..c0cfd2e8f5 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -285,7 +285,7 @@ export class Kubectl { } protected getDownloadMirror() { - const mirror = packageMirrors.get(userStore.getPreferences().downloadMirror) + const mirror = packageMirrors.get(userStore.preferences.downloadMirror) if (mirror) { return mirror } diff --git a/src/main/kubectl_spec.ts b/src/main/kubectl_spec.ts index 25d1da4676..005361dfa3 100644 --- a/src/main/kubectl_spec.ts +++ b/src/main/kubectl_spec.ts @@ -1,19 +1,7 @@ import packageInfo from "../../package.json" import { bundledKubectl, Kubectl } from "../../src/main/kubectl"; -import { UserStore } from "../common/user-store"; -jest.mock("../common/user-store", () => { - const userStoreMock: Partial = { - getPreferences() { - return { - downloadMirror: "default" - } - } - } - return { - userStore: userStoreMock, - } -}) +jest.mock("../common/user-store"); describe("kubectlVersion", () => { it("returns bundled version if exactly same version used", async () => { diff --git a/src/main/node-shell-session.ts b/src/main/node-shell-session.ts index 6369b6bc8b..931355691c 100644 --- a/src/main/node-shell-session.ts +++ b/src/main/node-shell-session.ts @@ -3,10 +3,10 @@ import * as pty from "node-pty" import { ShellSession } from "./shell-session"; import { v4 as uuid } from "uuid" import * as k8s from "@kubernetes/client-node" +import { KubeConfig } from "@kubernetes/client-node" +import { Cluster } from "./cluster" import logger from "./logger"; -import { KubeConfig, V1Pod } from "@kubernetes/client-node"; -import { tracker } from "./tracker" -import { Cluster, ClusterPreferences } from "./cluster" +import { tracker } from "../common/tracker"; export class NodeShellSession extends ShellSession { protected nodeName: string; @@ -107,7 +107,7 @@ export class NodeShellSession extends ShellSession { const watch = new k8s.Watch(kc); const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, - // callback is called for each received object. + // callback is called for each received object. (_type, obj) => { if (obj.metadata.name == podId && obj.status.phase === "Running") { resolve(true) @@ -119,9 +119,13 @@ export class NodeShellSession extends ShellSession { reject(false) } ); - setTimeout(() => { req.abort(); reject(false); }, 120 * 1000); + setTimeout(() => { + req.abort(); + reject(false); + }, 120 * 1000); }) } + protected deleteNodeShellPod() { const kc = this.getKubeConfig(); const k8sApi = kc.makeApiClient(k8s.CoreV1Api); @@ -130,12 +134,11 @@ export class NodeShellSession extends ShellSession { } export async function open(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName?: string): Promise { - return new Promise(async(resolve, reject) => { + return new Promise(async (resolve, reject) => { let shell = null if (nodeName) { shell = new NodeShellSession(socket, pathToKubeconfig, cluster, nodeName) - } - else { + } else { shell = new ShellSession(socket, pathToKubeconfig, cluster) } shell.open() diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 5e13d36c1b..eb7b2b5620 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -5,7 +5,7 @@ import path from "path"; import * as tempy from "tempy"; import logger from "./logger" import { Cluster } from "./cluster"; -import { tracker } from "./tracker"; +import { tracker } from "../common/tracker"; type KubeObject = { status: {}; diff --git a/src/main/routes/config.ts b/src/main/routes/config.ts index d726a928f8..02911889ae 100644 --- a/src/main/routes/config.ts +++ b/src/main/routes/config.ts @@ -94,7 +94,7 @@ class ConfigRoute extends LensApi { const data: IConfigRoutePayload = { clusterName: cluster.contextName, lensVersion: app.getVersion(), - lensTheme: `kontena-${userStore.getPreferences().colorTheme}`, + lensTheme: `kontena-${userStore.preferences.colorTheme}`, kubeVersion: cluster.version, chartsEnabled: true, isClusterAdmin: cluster.isAdmin, diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index dbcddcb125..8ab7baf8cc 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -5,10 +5,10 @@ import path from "path" import shellEnv from "shell-env" import { app } from "electron" import { Kubectl } from "./kubectl" -import { tracker } from "./tracker" import { Cluster, ClusterPreferences } from "./cluster" import { helmCli } from "./helm-cli" import { isWindows } from "../common/vars"; +import { tracker } from "../common/tracker"; export class ShellSession extends EventEmitter { static shellEnvs: Map = new Map() diff --git a/src/main/tracker.ts b/src/main/tracker.ts deleted file mode 100644 index 4a1e7c4267..0000000000 --- a/src/main/tracker.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Tracker } from "../common/tracker" -import { app, remote } from "electron" - -export const tracker = new Tracker(app || remote.app); diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index fce16a5190..fc2805f9cb 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,8 +1,8 @@ import { BrowserWindow, shell } from "electron" import { PromiseIpc } from "electron-promise-ipc" import windowStateKeeper from "electron-window-state" -import { tracker } from "./tracker"; import { getStaticUrl } from "../common/register-static"; +import { tracker } from "../common/tracker"; export class WindowManager { public mainWindow: BrowserWindow = null; diff --git a/src/migrations/migration-wrapper.ts b/src/migrations/migration-wrapper.ts index ff5e6739ef..c1527ed586 100644 --- a/src/migrations/migration-wrapper.ts +++ b/src/migrations/migration-wrapper.ts @@ -1,10 +1,10 @@ import path from "path"; -import ElectronStore from "electron-store"; +import Config from "conf"; import { isTestEnv } from "../common/vars"; export interface MigrationOpts { version: string; - run(store: ElectronStore, log: (...args: any[]) => void): void; + run(storeConfig: Config, log: (...args: any[]) => void): void; } function infoLog(...args: any[]) { @@ -12,12 +12,12 @@ function infoLog(...args: any[]) { console.log(...args); } -export function migration({ version, run }: MigrationOpts) { +export function migration({ version, run }: MigrationOpts) { return { - [version]: (store: ElectronStore) => { - const storeName = path.dirname(store.path); - infoLog(`STORE MIGRATION (${storeName}): ${version}`, ); - run(store, infoLog); + [version]: (storeConfig: Config) => { + const storeName = path.dirname(storeConfig.path); + infoLog(`STORE MIGRATION (${storeName}): ${version}`,); + run(storeConfig, infoLog); } }; } diff --git a/src/renderer/_vue/components/ClusterPage.vue b/src/renderer/_vue/components/ClusterPage.vue index 00823ee2f5..29235b1095 100644 --- a/src/renderer/_vue/components/ClusterPage.vue +++ b/src/renderer/_vue/components/ClusterPage.vue @@ -27,6 +27,8 @@