From d509da2b099b2699e26da4bc40516d9e3afe2355 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 28 Mar 2025 22:45:13 +0000 Subject: [PATCH] Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/@types/global.d.ts | 5 +- src/electron-main.ts | 117 +++++++++--------------------- src/ipc.ts | 7 +- src/language-helper.ts | 7 +- src/safe-storage.ts | 95 ------------------------ src/seshat.ts | 5 +- src/store.ts | 160 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 193 deletions(-) delete mode 100644 src/safe-storage.ts create mode 100644 src/store.ts diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index e628ebac..8ee085f8 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -7,10 +7,9 @@ Please see LICENSE files in the repository root for full details. import { type BrowserWindow } from "electron"; -import type Store from "electron-store"; import type AutoLaunch from "auto-launch"; import { type AppLocalization } from "../language-helper.js"; -import { type StoreData } from "../electron-main.js"; +import { type Store } from "../store.js"; // global type extensions need to use var for whatever reason /* eslint-disable no-var */ @@ -25,6 +24,6 @@ declare global { icon_path: string; brand: string; }; - var store: Store; + var store: Store; } /* eslint-enable no-var */ diff --git a/src/electron-main.ts b/src/electron-main.ts index 54353fd6..e9a0ed67 100644 --- a/src/electron-main.ts +++ b/src/electron-main.ts @@ -16,7 +16,6 @@ import * as Sentry from "@sentry/electron/main"; import AutoLaunch from "auto-launch"; import path, { dirname } from "node:path"; import windowStateKeeper from "electron-window-state"; -import Store from "electron-store"; import fs, { promises as afs } from "node:fs"; import { URL, fileURLToPath } from "node:url"; import minimist from "minimist"; @@ -25,7 +24,7 @@ import "./ipc.js"; import "./seshat.js"; import "./settings.js"; import * as tray from "./tray.js"; -import { migrate as migrateSafeStorage } from "./safe-storage.js"; +import { Store } from "./store.js"; import { buildMenuTemplate } from "./vectormenu.js"; import webContentsHandler from "./webcontents-handler.js"; import * as updater from "./updater.js"; @@ -258,54 +257,6 @@ async function moveAutoLauncher(): Promise { } } -export interface StoreData { - warnBeforeExit: boolean; - minimizeToTray: boolean; - spellCheckerEnabled: boolean; - autoHideMenuBar: boolean; - locale?: string | string[]; - disableHardwareAcceleration: boolean; - migratedToSafeStorage: boolean; - safeStorage: Record; -} - -global.store = new Store({ - name: "electron-config", - schema: { - warnBeforeExit: { - type: "boolean", - default: true, - }, - minimizeToTray: { - type: "boolean", - default: true, - }, - spellCheckerEnabled: { - type: "boolean", - default: true, - }, - autoHideMenuBar: { - type: "boolean", - default: true, - }, - locale: { - anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], - }, - disableHardwareAcceleration: { - type: "boolean", - default: false, - }, - migratedToSafeStorage: { - type: "boolean", - default: false, - }, - safeStorage: { - type: "object", - additionalProperties: { type: "string" }, - }, - }, -}) as Store; - global.appQuitting = false; const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ @@ -315,32 +266,6 @@ const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ platform === "darwin" && input.meta && !input.control && input.key.toUpperCase() === "Q", ]; -const warnBeforeExit = (event: Event, input: Input): void => { - const shouldWarnBeforeExit = global.store.get("warnBeforeExit", true); - const exitShortcutPressed = - input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform)); - - if (shouldWarnBeforeExit && exitShortcutPressed && global.mainWindow) { - const shouldCancelCloseRequest = - dialog.showMessageBoxSync(global.mainWindow, { - type: "question", - buttons: [ - _t("action|cancel"), - _t("action|close_brand", { - brand: global.vectorConfig.brand || "Element", - }), - ], - message: _t("confirm_quit"), - defaultId: 1, - cancelId: 0, - }) === 0; - - if (shouldCancelCloseRequest) { - event.preventDefault(); - } - } -}; - void configureSentry(); // handle uncaught errors otherwise it displays @@ -397,15 +322,15 @@ app.enableSandbox(); // We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704 app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService"); +const store = new Store(); + // Disable hardware acceleration if the setting has been set. -if (global.store.get("disableHardwareAcceleration") === true) { +if (store.get("disableHardwareAcceleration") === true) { console.log("Disabling hardware acceleration."); app.disableHardwareAcceleration(); } app.on("ready", async () => { - await migrateSafeStorage(); - let asarPath: string; try { @@ -511,7 +436,7 @@ app.on("ready", async () => { icon: global.trayConfig.icon_path, show: false, - autoHideMenuBar: global.store.get("autoHideMenuBar"), + autoHideMenuBar: store.get("autoHideMenuBar"), x: mainWindowState.x, y: mainWindowState.y, @@ -533,10 +458,10 @@ app.on("ready", async () => { // Handle spellchecker // For some reason spellCheckerEnabled isn't persisted, so we have to use the store here - global.mainWindow.webContents.session.setSpellCheckerEnabled(global.store.get("spellCheckerEnabled", true)); + global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true)); // Create trayIcon icon - if (global.store.get("minimizeToTray")) tray.create(global.trayConfig); + if (store.get("minimizeToTray")) tray.create(global.trayConfig); global.mainWindow.once("ready-to-show", () => { if (!global.mainWindow) return; @@ -550,7 +475,31 @@ app.on("ready", async () => { } }); - global.mainWindow.webContents.on("before-input-event", warnBeforeExit); + global.mainWindow.webContents.on("before-input-event", (event: Event, input: Input): void => { + const shouldWarnBeforeExit = store.get("warnBeforeExit", true); + const exitShortcutPressed = + input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform)); + + if (shouldWarnBeforeExit && exitShortcutPressed && global.mainWindow) { + const shouldCancelCloseRequest = + dialog.showMessageBoxSync(global.mainWindow, { + type: "question", + buttons: [ + _t("action|cancel"), + _t("action|close_brand", { + brand: global.vectorConfig.brand || "Element", + }), + ], + message: _t("confirm_quit"), + defaultId: 1, + cancelId: 0, + }) === 0; + + if (shouldCancelCloseRequest) { + event.preventDefault(); + } + } + }); global.mainWindow.on("closed", () => { global.mainWindow = null; @@ -589,7 +538,7 @@ app.on("ready", async () => { webContentsHandler(global.mainWindow.webContents); global.appLocalization = new AppLocalization({ - store: global.store, + store, components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())], }); diff --git a/src/ipc.ts b/src/ipc.ts index 5ef269fe..ee24f040 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -13,7 +13,6 @@ import { recordSSOSession } from "./protocol.js"; import { randomArray } from "./utils.js"; import { Settings } from "./settings.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; -import { deletePassword, getPassword, setPassword } from "./safe-storage.js"; ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void { if (process.platform !== "win32") { @@ -141,7 +140,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "getPickleKey": try { - ret = await getPassword(`${args[0]}|${args[1]}`); + ret = await global.store.getSecret(`${args[0]}|${args[1]}`); } catch { // if an error is thrown (e.g. keytar can't connect to the keychain), // then return null, which means the default pickle key will be used @@ -152,7 +151,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "createPickleKey": try { const pickleKey = await randomArray(32); - await setPassword(`${args[0]}|${args[1]}`, pickleKey); + await global.store.setSecret(`${args[0]}|${args[1]}`, pickleKey); ret = pickleKey; } catch (e) { console.error("Failed to create pickle key", e); @@ -162,7 +161,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { case "destroyPickleKey": try { - await deletePassword(`${args[0]}|${args[1]}`); + await global.store.deleteSecret(`${args[0]}|${args[1]}`); } catch (e) { console.error("Failed to destroy pickle key", e); } diff --git a/src/language-helper.ts b/src/language-helper.ts index d4d7184e..301c3864 100644 --- a/src/language-helper.ts +++ b/src/language-helper.ts @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import type EN from "./i18n/strings/en_EN.json"; import { loadJsonFile } from "./utils.js"; +import { Store } from "./store.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -58,15 +59,13 @@ export function _t(text: TranslationKey, variables: Variables = {}): string { type Component = () => void; -type TypedStore = (typeof global)["store"]; - export class AppLocalization { private static readonly STORE_KEY = "locale"; - private readonly store: TypedStore; + private readonly store: Store; private readonly localizedComponents?: Set; - public constructor({ store, components = [] }: { store: TypedStore; components: Component[] }) { + public constructor({ store, components = [] }: { store: Store; components: Component[] }) { counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN")); counterpart.setFallbackLocale(FALLBACK_LOCALE); counterpart.setSeparator("|"); diff --git a/src/safe-storage.ts b/src/safe-storage.ts deleted file mode 100644 index 41c3e6ab..00000000 --- a/src/safe-storage.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* -Copyright 2022-2025 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { safeStorage } from "electron"; -import * as keytar from "keytar-forked"; - -const KEYTAR_SERVICE = "element.io"; -const LEGACY_KEYTAR_SERVICE = "riot.im"; - -const getStorageKey = (key: string) => `safeStorage.${key}` as const; - -/** - * Migrates keytar data to safeStorage, - * deletes data from legacy keytar but keeps it in the new keytar for downgrade compatibility. - */ -export async function migrate(): Promise { - if (global.store.get("migratedToSafeStorage")) return; // already done - - const credentials = [ - ...(await keytar.findCredentials(LEGACY_KEYTAR_SERVICE)), - ...(await keytar.findCredentials(KEYTAR_SERVICE)), - ]; - credentials.forEach((cred) => { - deletePassword(cred.account); // delete from keytar & keytar legacy - setPassword(cred.account, cred.password); // write to safeStorage & keytar for downgrade compatibility - }); - - global.store.set("migratedToSafeStorage", true); -} - -/** - * Get the stored password for the key. - * We read from safeStorage first, then keytar & keytar legacy. - * - * @param key The string key name. - * - * @returns A promise for the password string. - */ -export async function getPassword(key: string): Promise { - if (safeStorage.isEncryptionAvailable()) { - const encryptedValue = global.store.get(getStorageKey(key)); - if (typeof encryptedValue === "string") { - return safeStorage.decryptString(Buffer.from(encryptedValue)); - } - } - return (await keytar.getPassword(KEYTAR_SERVICE, key)) ?? (await keytar.getPassword(LEGACY_KEYTAR_SERVICE, key)); -} - -/** - * Add the password for the key to the keychain. - * We write to both safeStorage & keytar to support downgrading the application. - * - * @param key The string key name. - * @param password The string password. - * - * @returns A promise for the set password completion. - */ -export async function setPassword(key: string, password: string): Promise { - if (safeStorage.isEncryptionAvailable()) { - const encryptedValue = safeStorage.encryptString(password); - global.store.set(getStorageKey(key), encryptedValue.toString()); - } - await keytar.setPassword(KEYTAR_SERVICE, key, password); -} - -/** - * Delete the stored password for the key. - * Removes from safeStorage, keytar & keytar legacy. - * - * @param key The string key name. - * - * @returns A promise for the deletion status. True on success. - */ -export async function deletePassword(key: string): Promise { - if (safeStorage.isEncryptionAvailable()) { - global.store.delete(getStorageKey(key)); - await keytar.deletePassword(LEGACY_KEYTAR_SERVICE, key); - await keytar.deletePassword(KEYTAR_SERVICE, key); - return true; - } - return false; -} diff --git a/src/seshat.ts b/src/seshat.ts index 5f4d6b35..9b022551 100644 --- a/src/seshat.ts +++ b/src/seshat.ts @@ -16,7 +16,6 @@ import type { } from "matrix-seshat"; // Hak dependency type import IpcMainEvent = Electron.IpcMainEvent; import { randomArray } from "./utils.js"; -import { getPassword, setPassword } from "./safe-storage.js"; let seshatSupported = false; let Seshat: typeof SeshatType; @@ -42,12 +41,12 @@ let eventIndex: SeshatType | null = null; const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE"; async function getOrCreatePassphrase(key: string): Promise { try { - const storedPassphrase = await getPassword(key); + const storedPassphrase = await global.store.getSecret(key); if (storedPassphrase !== null) { return storedPassphrase; } else { const newPassphrase = await randomArray(32); - await setPassword(key, newPassphrase); + await global.store.setSecret(key, newPassphrase); return newPassphrase; } } catch (e) { diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 00000000..fc296299 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,160 @@ +/* +Copyright 2022-2025 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import _Store from "electron-store"; +import * as keytar from "keytar-forked"; +import { app, safeStorage } from "electron"; + +/** + * Legacy keytar service names for storing secrets. + */ +const KEYTAR_SERVICE = "element.io"; +const LEGACY_KEYTAR_SERVICE = "riot.im"; + +/** + * JSON-backed store for settings which need to be accessible by the main process. + * Secrets are stored within the `safeStorage` object, encrypted with safeStorage. + * Any secrets operations are blocked on Electron app ready emit, and keytar migration if still needed. + */ +export class Store extends _Store<{ + warnBeforeExit: boolean; + minimizeToTray: boolean; + spellCheckerEnabled: boolean; + autoHideMenuBar: boolean; + locale?: string | string[]; + disableHardwareAcceleration: boolean; + safeStorage?: Record; +}> { + public constructor() { + super({ + name: "electron-config", + clearInvalidConfig: false, + schema: { + warnBeforeExit: { + type: "boolean", + default: true, + }, + minimizeToTray: { + type: "boolean", + default: true, + }, + spellCheckerEnabled: { + type: "boolean", + default: true, + }, + autoHideMenuBar: { + type: "boolean", + default: true, + }, + locale: { + anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], + }, + disableHardwareAcceleration: { + type: "boolean", + default: false, + }, + safeStorage: { + type: "object", + }, + }, + }); + } + + private whenSafeStorageReadyPromise?: Promise; + private safeStorageReady(): Promise { + if (!this.whenSafeStorageReadyPromise) { + this.whenSafeStorageReadyPromise = app.whenReady().then(() => this.migrate()); + } + return this.whenSafeStorageReadyPromise; + } + + private getSecretStorageKey = (key: string) => `safeStorage.${key}` as const; + + /** + * Migrates keytar data to safeStorage, + * deletes data from legacy keytar but keeps it in the new keytar for downgrade compatibility. + */ + public async migrate(): Promise { + if (this.has("safeStorage")) return; + await this.safeStorageReady(); + const credentials = [ + ...(await keytar.findCredentials(LEGACY_KEYTAR_SERVICE)), + ...(await keytar.findCredentials(KEYTAR_SERVICE)), + ]; + credentials.forEach((cred) => { + this.deleteSecret(cred.account); // delete from keytar & keytar legacy + this.setSecret(cred.account, cred.password); // write to safeStorage & keytar for downgrade compatibility + }); + } + + /** + * Get the stored secret for the key. + * We read from safeStorage first, then keytar & keytar legacy. + * + * @param key The string key name. + * + * @returns A promise for the secret string. + */ + public async getSecret(key: string): Promise { + await this.safeStorageReady(); + if (safeStorage.isEncryptionAvailable()) { + const encryptedValue = this.get(this.getSecretStorageKey(key)); + if (typeof encryptedValue === "string") { + return safeStorage.decryptString(Buffer.from(encryptedValue)); + } + } + return ( + (await keytar.getPassword(KEYTAR_SERVICE, key)) ?? (await keytar.getPassword(LEGACY_KEYTAR_SERVICE, key)) + ); + } + + /** + * Add the secret for the key to the keychain. + * We write to both safeStorage & keytar to support downgrading the application. + * + * @param key The string key name. + * @param secret The string password. + * + * @returns A promise for the set password completion. + */ + public async setSecret(key: string, secret: string): Promise { + await this.safeStorageReady(); + if (safeStorage.isEncryptionAvailable()) { + const encryptedValue = safeStorage.encryptString(secret); + this.set(this.getSecretStorageKey(key), encryptedValue.toString()); + } + await keytar.setPassword(KEYTAR_SERVICE, key, secret); + } + + /** + * Delete the stored password for the key. + * Removes from safeStorage, keytar & keytar legacy. + * + * @param key The string key name. + * + * @returns A promise for the deletion status. True on success. + */ + public async deleteSecret(key: string): Promise { + await this.safeStorageReady(); + if (safeStorage.isEncryptionAvailable()) { + this.delete(this.getSecretStorageKey(key)); + await keytar.deletePassword(LEGACY_KEYTAR_SERVICE, key); + await keytar.deletePassword(KEYTAR_SERVICE, key); + return true; + } + return false; + } +}