Migrate from keytar to safeStorage (#2227)
This commit is contained in:
committed by
GitHub
parent
570a8bdefe
commit
c72d23f995
2
.github/workflows/build_test.yaml
vendored
2
.github/workflows/build_test.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
|
uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
|
||||||
timeout-minutes: 5
|
timeout-minutes: 20
|
||||||
with:
|
with:
|
||||||
run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }}
|
run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
|||||||
Please see LICENSE files in the repository root for full details.
|
Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { platform } from "node:os";
|
import keytar from "keytar-forked";
|
||||||
|
|
||||||
import { test, expect } from "../../element-desktop-test.js";
|
import { test, expect } from "../../element-desktop-test.js";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ declare global {
|
|||||||
supportsEventIndexing(): Promise<boolean>;
|
supportsEventIndexing(): Promise<boolean>;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
getPickleKey(userId: string, deviceId: string): Promise<string | null>;
|
||||||
createPickleKey(userId: string, deviceId: string): Promise<string | null>;
|
createPickleKey(userId: string, deviceId: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,14 +49,46 @@ test.describe("App launch", () => {
|
|||||||
).resolves.toBeTruthy();
|
).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should launch and render the welcome view successfully and support keytar", async ({ page }) => {
|
test.describe("safeStorage", () => {
|
||||||
test.skip(platform() === "linux", "This test does not yet support Linux");
|
const userId = "@user:server";
|
||||||
|
const deviceId = "ABCDEF";
|
||||||
|
|
||||||
await expect(
|
test("should be supported", async ({ page }) => {
|
||||||
page.evaluate<string | null>(async () => {
|
await expect(
|
||||||
return await window.mxPlatformPeg.get().createPickleKey("@user:server", "ABCDEF");
|
page.evaluate(
|
||||||
}),
|
([userId, deviceId]) => window.mxPlatformPeg.get().createPickleKey(userId, deviceId),
|
||||||
).resolves.not.toBeNull();
|
[userId, deviceId],
|
||||||
|
),
|
||||||
|
).resolves.not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("migrate from keytar", () => {
|
||||||
|
test.skip(
|
||||||
|
process.env.GITHUB_ACTIONS && ["linux", "darwin"].includes(process.platform),
|
||||||
|
"GitHub Actions hosted runner are not a compatible environment for this test",
|
||||||
|
);
|
||||||
|
|
||||||
|
const pickleKey = "DEADBEEF1234";
|
||||||
|
const keytarService = "element.io";
|
||||||
|
const keytarKey = `${userId}|${deviceId}`;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await keytar.setPassword(keytarService, keytarKey, pickleKey);
|
||||||
|
await expect(keytar.getPassword(keytarService, keytarKey)).resolves.toBe(pickleKey);
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await keytar.deletePassword(keytarService, keytarKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should migrate successfully", async ({ page }) => {
|
||||||
|
await expect(
|
||||||
|
page.evaluate(
|
||||||
|
([userId, deviceId]) => window.mxPlatformPeg.get().getPickleKey(userId, deviceId),
|
||||||
|
[userId, deviceId],
|
||||||
|
),
|
||||||
|
).resolves.toBe(pickleKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("--no-update", () => {
|
test.describe("--no-update", () => {
|
||||||
|
|||||||
@@ -62,12 +62,21 @@ export const test = base.extend<Fixtures>({
|
|||||||
// eslint-disable-next-line no-empty-pattern
|
// eslint-disable-next-line no-empty-pattern
|
||||||
tmpDir: async ({}, use) => {
|
tmpDir: async ({}, use) => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-"));
|
||||||
console.log("Using temp profile directory: ", tmpDir);
|
|
||||||
await use(tmpDir);
|
await use(tmpDir);
|
||||||
await fs.rm(tmpDir, { recursive: true });
|
await fs.rm(tmpDir, { recursive: true });
|
||||||
},
|
},
|
||||||
app: async ({ tmpDir, extraEnv, extraArgs, stdout, stderr }, use) => {
|
app: async ({ tmpDir, extraEnv, extraArgs, stdout, stderr }, use) => {
|
||||||
const args = ["--profile-dir", tmpDir];
|
const args = ["--profile-dir", tmpDir, ...extraArgs];
|
||||||
|
|
||||||
|
if (process.env.GITHUB_ACTIONS) {
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
// GitHub Actions hosted runner lacks dbus and a compatible keyring, so we need to force plaintext storage
|
||||||
|
args.push("--storage-mode", "force-plaintext");
|
||||||
|
} else if (process.platform === "darwin") {
|
||||||
|
// GitHub Actions hosted runner has no working default keychain, so allow plaintext storage
|
||||||
|
args.push("--storage-mode", "allow-plaintext");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const executablePath = process.env["ELEMENT_DESKTOP_EXECUTABLE"];
|
const executablePath = process.env["ELEMENT_DESKTOP_EXECUTABLE"];
|
||||||
if (!executablePath) {
|
if (!executablePath) {
|
||||||
@@ -75,13 +84,15 @@ export const test = base.extend<Fixtures>({
|
|||||||
args.unshift(path.join(__dirname, "..", "lib", "electron-main.js"));
|
args.unshift(path.join(__dirname, "..", "lib", "electron-main.js"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`Launching '${executablePath}' with args ${args.join(" ")}`);
|
||||||
|
|
||||||
const app = await electron.launch({
|
const app = await electron.launch({
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
...extraEnv,
|
...extraEnv,
|
||||||
},
|
},
|
||||||
executablePath,
|
executablePath,
|
||||||
args: [...args, ...extraArgs],
|
args,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.process().stdout.pipe(stdout).pipe(process.stdout);
|
app.process().stdout.pipe(stdout).pipe(process.stdout);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"module": "node16",
|
"module": "node16",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"lib": ["es2020"]
|
"lib": ["es2021"]
|
||||||
},
|
},
|
||||||
"include": ["../src/@types", "./**/*.ts"]
|
"include": ["../src/@types", "./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/@types/global.d.ts
vendored
9
src/@types/global.d.ts
vendored
@@ -7,7 +7,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
|
|
||||||
import { type BrowserWindow } from "electron";
|
import { type BrowserWindow } from "electron";
|
||||||
|
|
||||||
import type Store from "electron-store";
|
|
||||||
import type AutoLaunch from "auto-launch";
|
import type AutoLaunch from "auto-launch";
|
||||||
import { type AppLocalization } from "../language-helper.js";
|
import { type AppLocalization } from "../language-helper.js";
|
||||||
|
|
||||||
@@ -24,13 +23,5 @@ declare global {
|
|||||||
icon_path: string;
|
icon_path: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
};
|
};
|
||||||
var store: Store<{
|
|
||||||
warnBeforeExit?: boolean;
|
|
||||||
minimizeToTray?: boolean;
|
|
||||||
spellCheckerEnabled?: boolean;
|
|
||||||
autoHideMenuBar?: boolean;
|
|
||||||
locale?: string | string[];
|
|
||||||
disableHardwareAcceleration?: boolean;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
/* eslint-enable no-var */
|
/* eslint-enable no-var */
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import * as Sentry from "@sentry/electron/main";
|
|||||||
import AutoLaunch from "auto-launch";
|
import AutoLaunch from "auto-launch";
|
||||||
import path, { dirname } from "node:path";
|
import path, { dirname } from "node:path";
|
||||||
import windowStateKeeper from "electron-window-state";
|
import windowStateKeeper from "electron-window-state";
|
||||||
import Store from "electron-store";
|
|
||||||
import fs, { promises as afs } from "node:fs";
|
import fs, { promises as afs } from "node:fs";
|
||||||
import { URL, fileURLToPath } from "node:url";
|
import { URL, fileURLToPath } from "node:url";
|
||||||
import minimist from "minimist";
|
import minimist from "minimist";
|
||||||
@@ -25,6 +24,7 @@ import "./ipc.js";
|
|||||||
import "./seshat.js";
|
import "./seshat.js";
|
||||||
import "./settings.js";
|
import "./settings.js";
|
||||||
import * as tray from "./tray.js";
|
import * as tray from "./tray.js";
|
||||||
|
import Store from "./store.js";
|
||||||
import { buildMenuTemplate } from "./vectormenu.js";
|
import { buildMenuTemplate } from "./vectormenu.js";
|
||||||
import webContentsHandler from "./webcontents-handler.js";
|
import webContentsHandler from "./webcontents-handler.js";
|
||||||
import * as updater from "./updater.js";
|
import * as updater from "./updater.js";
|
||||||
@@ -273,8 +273,6 @@ async function moveAutoLauncher(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
global.store = new Store({ name: "electron-config" });
|
|
||||||
|
|
||||||
global.appQuitting = false;
|
global.appQuitting = false;
|
||||||
|
|
||||||
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
|
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
|
||||||
@@ -284,32 +282,6 @@ const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
|
|||||||
platform === "darwin" && input.meta && !input.control && input.key.toUpperCase() === "Q",
|
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();
|
void configureSentry();
|
||||||
|
|
||||||
// handle uncaught errors otherwise it displays
|
// handle uncaught errors otherwise it displays
|
||||||
@@ -366,13 +338,17 @@ 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
|
// 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");
|
app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService");
|
||||||
|
|
||||||
|
const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions
|
||||||
|
|
||||||
// Disable hardware acceleration if the setting has been set.
|
// Disable hardware acceleration if the setting has been set.
|
||||||
if (global.store.get("disableHardwareAcceleration", false) === true) {
|
if (store.get("disableHardwareAcceleration")) {
|
||||||
console.log("Disabling hardware acceleration.");
|
console.log("Disabling hardware acceleration.");
|
||||||
app.disableHardwareAcceleration();
|
app.disableHardwareAcceleration();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on("ready", async () => {
|
app.on("ready", async () => {
|
||||||
|
console.debug("Reached Electron ready state");
|
||||||
|
|
||||||
let asarPath: string;
|
let asarPath: string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -462,12 +438,27 @@ app.on("ready", async () => {
|
|||||||
console.log("No update_base_url is defined: auto update is disabled");
|
console.log("No update_base_url is defined: auto update is disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up i18n before loading storage as we need translations for dialogs
|
||||||
|
global.appLocalization = new AppLocalization({
|
||||||
|
components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())],
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.debug("Ensuring storage is ready");
|
||||||
|
await store.safeStorageReady();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
app.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Load the previous window state with fallback to defaults
|
// Load the previous window state with fallback to defaults
|
||||||
const mainWindowState = windowStateKeeper({
|
const mainWindowState = windowStateKeeper({
|
||||||
defaultWidth: 1024,
|
defaultWidth: 1024,
|
||||||
defaultHeight: 768,
|
defaultHeight: 768,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.debug("Opening main window");
|
||||||
const preloadScript = path.normalize(`${__dirname}/preload.cjs`);
|
const preloadScript = path.normalize(`${__dirname}/preload.cjs`);
|
||||||
global.mainWindow = new BrowserWindow({
|
global.mainWindow = new BrowserWindow({
|
||||||
// https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
|
// https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
|
||||||
@@ -478,7 +469,7 @@ app.on("ready", async () => {
|
|||||||
|
|
||||||
icon: global.trayConfig.icon_path,
|
icon: global.trayConfig.icon_path,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: global.store.get("autoHideMenuBar", true),
|
autoHideMenuBar: store.get("autoHideMenuBar"),
|
||||||
|
|
||||||
x: mainWindowState.x,
|
x: mainWindowState.x,
|
||||||
y: mainWindowState.y,
|
y: mainWindowState.y,
|
||||||
@@ -500,10 +491,10 @@ app.on("ready", async () => {
|
|||||||
|
|
||||||
// Handle spellchecker
|
// Handle spellchecker
|
||||||
// For some reason spellCheckerEnabled isn't persisted, so we have to use the store here
|
// 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
|
// Create trayIcon icon
|
||||||
if (global.store.get("minimizeToTray", true)) tray.create(global.trayConfig);
|
if (store.get("minimizeToTray")) tray.create(global.trayConfig);
|
||||||
|
|
||||||
global.mainWindow.once("ready-to-show", () => {
|
global.mainWindow.once("ready-to-show", () => {
|
||||||
if (!global.mainWindow) return;
|
if (!global.mainWindow) return;
|
||||||
@@ -517,7 +508,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.on("closed", () => {
|
||||||
global.mainWindow = null;
|
global.mainWindow = null;
|
||||||
@@ -555,11 +570,6 @@ app.on("ready", async () => {
|
|||||||
|
|
||||||
webContentsHandler(global.mainWindow.webContents);
|
webContentsHandler(global.mainWindow.webContents);
|
||||||
|
|
||||||
global.appLocalization = new AppLocalization({
|
|
||||||
store: global.store,
|
|
||||||
components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())],
|
|
||||||
});
|
|
||||||
|
|
||||||
session.defaultSession.setDisplayMediaRequestHandler((_, callback) => {
|
session.defaultSession.setDisplayMediaRequestHandler((_, callback) => {
|
||||||
global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker");
|
global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker");
|
||||||
setDisplayMediaCallback(callback);
|
setDisplayMediaCallback(callback);
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"brand_help": "%(brand)s Help",
|
"brand_help": "%(brand)s Help",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"preferences": "Preferences"
|
"no": "No",
|
||||||
|
"preferences": "Preferences",
|
||||||
|
"yes": "Yes"
|
||||||
},
|
},
|
||||||
"confirm_quit": "Are you sure you want to quit?",
|
"confirm_quit": "Are you sure you want to quit?",
|
||||||
"edit_menu": {
|
"edit_menu": {
|
||||||
@@ -49,6 +51,20 @@
|
|||||||
"save_image_as_error_description": "The image failed to save",
|
"save_image_as_error_description": "The image failed to save",
|
||||||
"save_image_as_error_title": "Failed to save image"
|
"save_image_as_error_title": "Failed to save image"
|
||||||
},
|
},
|
||||||
|
"store": {
|
||||||
|
"error": {
|
||||||
|
"backend_changed": "Clear data and reload?",
|
||||||
|
"backend_changed_detail": "Unable to access secret from system keyring, it appears to have changed.",
|
||||||
|
"backend_changed_title": "Failed to load database",
|
||||||
|
"unknown_backend_override": "Your system has an unsupported keyring meaning the database cannot be opened.",
|
||||||
|
"unknown_backend_override_details": "Please check the logs for more details.",
|
||||||
|
"unknown_backend_override_title": "Failed to load database",
|
||||||
|
"unsupported_keyring": "Your system has an unsupported keyring meaning the database cannot be opened.",
|
||||||
|
"unsupported_keyring_cta": "Use weaker encryption",
|
||||||
|
"unsupported_keyring_detail": "Electron's keyring detection did not find a supported backend, you may be able to convince it to use one on your system anyway, see %(link)s.",
|
||||||
|
"unsupported_keyring_title": "System unsupported"
|
||||||
|
}
|
||||||
|
},
|
||||||
"view_menu": {
|
"view_menu": {
|
||||||
"actual_size": "Actual Size",
|
"actual_size": "Actual Size",
|
||||||
"toggle_developer_tools": "Toggle Developer Tools",
|
"toggle_developer_tools": "Toggle Developer Tools",
|
||||||
|
|||||||
35
src/ipc.ts
35
src/ipc.ts
@@ -6,14 +6,13 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
|
import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
|
||||||
import { relaunchApp } from "@standardnotes/electron-clear-data";
|
|
||||||
import keytar from "keytar-forked";
|
|
||||||
|
|
||||||
import IpcMainEvent = Electron.IpcMainEvent;
|
import IpcMainEvent = Electron.IpcMainEvent;
|
||||||
import { recordSSOSession } from "./protocol.js";
|
import { recordSSOSession } from "./protocol.js";
|
||||||
import { randomArray } from "./utils.js";
|
import { randomArray } from "./utils.js";
|
||||||
import { Settings } from "./settings.js";
|
import { Settings } from "./settings.js";
|
||||||
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
|
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
|
||||||
|
import Store, { clearDataAndRelaunch } from "./store.js";
|
||||||
|
|
||||||
ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void {
|
ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
@@ -61,7 +60,8 @@ ipcMain.on("app_onAction", function (_ev: IpcMainEvent, payload) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
||||||
if (!global.mainWindow) return;
|
const store = Store.instance;
|
||||||
|
if (!global.mainWindow || !store) return;
|
||||||
|
|
||||||
const args = payload.args || [];
|
const args = payload.args || [];
|
||||||
let ret: any;
|
let ret: any;
|
||||||
@@ -113,11 +113,11 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
|||||||
if (typeof args[0] !== "boolean") return;
|
if (typeof args[0] !== "boolean") return;
|
||||||
|
|
||||||
global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]);
|
global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]);
|
||||||
global.store.set("spellCheckerEnabled", args[0]);
|
store.set("spellCheckerEnabled", args[0]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "getSpellCheckEnabled":
|
case "getSpellCheckEnabled":
|
||||||
ret = global.store.get("spellCheckerEnabled", true);
|
ret = store.get("spellCheckerEnabled");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "setSpellCheckLanguages":
|
case "setSpellCheckLanguages":
|
||||||
@@ -141,12 +141,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
|||||||
|
|
||||||
case "getPickleKey":
|
case "getPickleKey":
|
||||||
try {
|
try {
|
||||||
ret = await keytar.getPassword("element.io", `${args[0]}|${args[1]}`);
|
ret = await store.getSecret(`${args[0]}|${args[1]}`);
|
||||||
// migrate from riot.im (remove once we think there will no longer be
|
|
||||||
// logins from the time of riot.im)
|
|
||||||
if (ret === null) {
|
|
||||||
ret = await keytar.getPassword("riot.im", `${args[0]}|${args[1]}`);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// if an error is thrown (e.g. keytar can't connect to the keychain),
|
// 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
|
// then return null, which means the default pickle key will be used
|
||||||
@@ -157,9 +152,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
|||||||
case "createPickleKey":
|
case "createPickleKey":
|
||||||
try {
|
try {
|
||||||
const pickleKey = await randomArray(32);
|
const pickleKey = await randomArray(32);
|
||||||
// We purposefully throw if keytar is not available so the caller can handle it
|
await store.setSecret(`${args[0]}|${args[1]}`, pickleKey);
|
||||||
// rather than sending them a pickle key we did not store on their behalf.
|
|
||||||
await keytar!.setPassword("element.io", `${args[0]}|${args[1]}`, pickleKey);
|
|
||||||
ret = pickleKey;
|
ret = pickleKey;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to create pickle key", e);
|
console.error("Failed to create pickle key", e);
|
||||||
@@ -169,11 +162,10 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
|||||||
|
|
||||||
case "destroyPickleKey":
|
case "destroyPickleKey":
|
||||||
try {
|
try {
|
||||||
await keytar.deletePassword("element.io", `${args[0]}|${args[1]}`);
|
await store.deleteSecret(`${args[0]}|${args[1]}`);
|
||||||
// migrate from riot.im (remove once we think there will no longer be
|
} catch (e) {
|
||||||
// logins from the time of riot.im)
|
console.error("Failed to destroy pickle key", e);
|
||||||
await keytar.deletePassword("riot.im", `${args[0]}|${args[1]}`);
|
}
|
||||||
} catch {}
|
|
||||||
break;
|
break;
|
||||||
case "getDesktopCapturerSources":
|
case "getDesktopCapturerSources":
|
||||||
ret = (await desktopCapturer.getSources(args[0])).map((source) => ({
|
ret = (await desktopCapturer.getSources(args[0])).map((source) => ({
|
||||||
@@ -189,10 +181,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "clearStorage":
|
case "clearStorage":
|
||||||
global.store.clear();
|
await clearDataAndRelaunch();
|
||||||
global.mainWindow.webContents.session.flushStorageData();
|
|
||||||
await global.mainWindow.webContents.session.clearStorageData();
|
|
||||||
relaunchApp();
|
|
||||||
return; // the app is about to stop, we don't need to reply to the IPC
|
return; // the app is about to stop, we don't need to reply to the IPC
|
||||||
|
|
||||||
case "breadcrumbs": {
|
case "breadcrumbs": {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { type TranslationKey as TKey } from "matrix-web-i18n";
|
|||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import type Store from "electron-store";
|
|
||||||
import type EN from "./i18n/strings/en_EN.json";
|
import type EN from "./i18n/strings/en_EN.json";
|
||||||
import { loadJsonFile } from "./utils.js";
|
import { loadJsonFile } from "./utils.js";
|
||||||
|
import type Store from "./store.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -59,26 +59,24 @@ export function _t(text: TranslationKey, variables: Variables = {}): string {
|
|||||||
|
|
||||||
type Component = () => void;
|
type Component = () => void;
|
||||||
|
|
||||||
type TypedStore = Store<{ locale?: string | string[] }>;
|
|
||||||
|
|
||||||
export class AppLocalization {
|
export class AppLocalization {
|
||||||
private static readonly STORE_KEY = "locale";
|
private static readonly STORE_KEY = "locale";
|
||||||
|
|
||||||
private readonly store: TypedStore;
|
|
||||||
private readonly localizedComponents?: Set<Component>;
|
private readonly localizedComponents?: Set<Component>;
|
||||||
|
private readonly store: Store;
|
||||||
|
|
||||||
public constructor({ store, components = [] }: { store: TypedStore; components: Component[] }) {
|
public constructor({ components = [], store }: { components: Component[]; store: Store }) {
|
||||||
counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN"));
|
counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN"));
|
||||||
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
counterpart.setFallbackLocale(FALLBACK_LOCALE);
|
||||||
counterpart.setSeparator("|");
|
counterpart.setSeparator("|");
|
||||||
|
|
||||||
|
this.store = store;
|
||||||
if (Array.isArray(components)) {
|
if (Array.isArray(components)) {
|
||||||
this.localizedComponents = new Set(components);
|
this.localizedComponents = new Set(components);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.store = store;
|
if (store.has(AppLocalization.STORE_KEY)) {
|
||||||
if (this.store.has(AppLocalization.STORE_KEY)) {
|
const locales = store.get(AppLocalization.STORE_KEY);
|
||||||
const locales = this.store.get(AppLocalization.STORE_KEY);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
this.setAppLocale(locales!);
|
this.setAppLocale(locales!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import { app, ipcMain } from "electron";
|
import { app, ipcMain } from "electron";
|
||||||
import { promises as afs } from "node:fs";
|
import { promises as afs } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import keytar from "keytar-forked";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Seshat as SeshatType,
|
Seshat as SeshatType,
|
||||||
@@ -17,6 +16,7 @@ import type {
|
|||||||
} from "matrix-seshat"; // Hak dependency type
|
} from "matrix-seshat"; // Hak dependency type
|
||||||
import IpcMainEvent = Electron.IpcMainEvent;
|
import IpcMainEvent = Electron.IpcMainEvent;
|
||||||
import { randomArray } from "./utils.js";
|
import { randomArray } from "./utils.js";
|
||||||
|
import Store from "./store.js";
|
||||||
|
|
||||||
let seshatSupported = false;
|
let seshatSupported = false;
|
||||||
let Seshat: typeof SeshatType;
|
let Seshat: typeof SeshatType;
|
||||||
@@ -40,21 +40,24 @@ try {
|
|||||||
let eventIndex: SeshatType | null = null;
|
let eventIndex: SeshatType | null = null;
|
||||||
|
|
||||||
const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE";
|
const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE";
|
||||||
async function getOrCreatePassphrase(key: string): Promise<string> {
|
async function getOrCreatePassphrase(store: Store, key: string): Promise<string> {
|
||||||
if (keytar) {
|
try {
|
||||||
try {
|
const storedPassphrase = await store.getSecret(key);
|
||||||
const storedPassphrase = await keytar.getPassword("element.io", key);
|
if (storedPassphrase !== null) {
|
||||||
if (storedPassphrase !== null) {
|
return storedPassphrase;
|
||||||
return storedPassphrase;
|
|
||||||
} else {
|
|
||||||
const newPassphrase = await randomArray(32);
|
|
||||||
await keytar.setPassword("element.io", key, newPassphrase);
|
|
||||||
return newPassphrase;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Error getting the event index passphrase out of the secret store", e);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error getting the event index passphrase out of the secret store", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newPassphrase = await randomArray(32);
|
||||||
|
await store.setSecret(key, newPassphrase);
|
||||||
|
return newPassphrase;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error creating new event index passphrase, using default", e);
|
||||||
|
}
|
||||||
|
|
||||||
return seshatDefaultPassphrase;
|
return seshatDefaultPassphrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +77,8 @@ const deleteContents = async (p: string): Promise<void> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> {
|
ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> {
|
||||||
if (!global.mainWindow) return;
|
const store = Store.instance;
|
||||||
|
if (!global.mainWindow || !store) return;
|
||||||
|
|
||||||
// We do this here to ensure we get the path after --profile has been resolved
|
// We do this here to ensure we get the path after --profile has been resolved
|
||||||
const eventStorePath = path.join(app.getPath("userData"), "EventStore");
|
const eventStorePath = path.join(app.getPath("userData"), "EventStore");
|
||||||
@@ -101,7 +105,7 @@ ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void>
|
|||||||
const deviceId = args[1];
|
const deviceId = args[1];
|
||||||
const passphraseKey = `seshat|${userId}|${deviceId}`;
|
const passphraseKey = `seshat|${userId}|${deviceId}`;
|
||||||
|
|
||||||
const passphrase = await getOrCreatePassphrase(passphraseKey);
|
const passphrase = await getOrCreatePassphrase(store, passphraseKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await afs.mkdir(eventStorePath, { recursive: true });
|
await afs.mkdir(eventStorePath, { recursive: true });
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as tray from "./tray.js";
|
import * as tray from "./tray.js";
|
||||||
|
import Store from "./store.js";
|
||||||
|
|
||||||
interface Setting {
|
interface Setting {
|
||||||
read(): Promise<any>;
|
read(): Promise<any>;
|
||||||
@@ -27,10 +28,10 @@ export const Settings: Record<string, Setting> = {
|
|||||||
},
|
},
|
||||||
"Electron.warnBeforeExit": {
|
"Electron.warnBeforeExit": {
|
||||||
async read(): Promise<any> {
|
async read(): Promise<any> {
|
||||||
return global.store.get("warnBeforeExit", true);
|
return Store.instance?.get("warnBeforeExit");
|
||||||
},
|
},
|
||||||
async write(value: any): Promise<void> {
|
async write(value: any): Promise<void> {
|
||||||
global.store.set("warnBeforeExit", value);
|
Store.instance?.set("warnBeforeExit", value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Electron.alwaysShowMenuBar": {
|
"Electron.alwaysShowMenuBar": {
|
||||||
@@ -39,7 +40,7 @@ export const Settings: Record<string, Setting> = {
|
|||||||
return !global.mainWindow!.autoHideMenuBar;
|
return !global.mainWindow!.autoHideMenuBar;
|
||||||
},
|
},
|
||||||
async write(value: any): Promise<void> {
|
async write(value: any): Promise<void> {
|
||||||
global.store.set("autoHideMenuBar", !value);
|
Store.instance?.set("autoHideMenuBar", !value);
|
||||||
global.mainWindow!.autoHideMenuBar = !value;
|
global.mainWindow!.autoHideMenuBar = !value;
|
||||||
global.mainWindow!.setMenuBarVisibility(value);
|
global.mainWindow!.setMenuBarVisibility(value);
|
||||||
},
|
},
|
||||||
@@ -56,15 +57,15 @@ export const Settings: Record<string, Setting> = {
|
|||||||
} else {
|
} else {
|
||||||
tray.destroy();
|
tray.destroy();
|
||||||
}
|
}
|
||||||
global.store.set("minimizeToTray", value);
|
Store.instance?.set("minimizeToTray", value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Electron.enableHardwareAcceleration": {
|
"Electron.enableHardwareAcceleration": {
|
||||||
async read(): Promise<any> {
|
async read(): Promise<any> {
|
||||||
return !global.store.get("disableHardwareAcceleration", false);
|
return !Store.instance?.get("disableHardwareAcceleration");
|
||||||
},
|
},
|
||||||
async write(value: any): Promise<void> {
|
async write(value: any): Promise<void> {
|
||||||
global.store.set("disableHardwareAcceleration", !value);
|
Store.instance?.set("disableHardwareAcceleration", !value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
455
src/store.ts
Normal file
455
src/store.ts
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
/*
|
||||||
|
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 ElectronStore from "electron-store";
|
||||||
|
import keytar from "keytar-forked";
|
||||||
|
import { app, safeStorage, dialog, type SafeStorage } from "electron";
|
||||||
|
import { clearAllUserData, relaunchApp } from "@standardnotes/electron-clear-data";
|
||||||
|
|
||||||
|
import { _t } from "./language-helper.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy keytar service name for storing secrets.
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const KEYTAR_SERVICE = "element.io";
|
||||||
|
/**
|
||||||
|
* Super legacy keytar service name for reading secrets.
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const LEGACY_KEYTAR_SERVICE = "riot.im";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String union type representing all the safeStorage backends.
|
||||||
|
* + The "unknown" backend shouldn't exist in practice once the app is ready
|
||||||
|
* + The "plaintext" is the temporarily-unencrypted backend for migration, data is wholly unencrypted - uses PlaintextStorageWriter
|
||||||
|
* + The "basic_text" backend is the 'plaintext' backend on Linux, data is encrypted but not using the keychain
|
||||||
|
* + The "system" backend is the encrypted backend on Windows & macOS, data is encrypted using system keychain
|
||||||
|
* + All other backends are linux-specific and are encrypted using the keychain
|
||||||
|
*/
|
||||||
|
type SafeStorageBackend = ReturnType<SafeStorage["getSelectedStorageBackend"]> | "system" | "plaintext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of safeStorage backends to their command line arguments.
|
||||||
|
* kwallet6 cannot be specified via command line
|
||||||
|
* https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux
|
||||||
|
*/
|
||||||
|
const safeStorageBackendMap: Omit<
|
||||||
|
Record<SafeStorageBackend, string>,
|
||||||
|
"unknown" | "kwallet6" | "system" | "plaintext"
|
||||||
|
> = {
|
||||||
|
basic_text: "basic",
|
||||||
|
gnome_libsecret: "gnome-libsecret",
|
||||||
|
kwallet: "kwallet",
|
||||||
|
kwallet5: "kwallet5",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data and relaunch the app.
|
||||||
|
*/
|
||||||
|
export async function clearDataAndRelaunch(): Promise<void> {
|
||||||
|
Store.instance?.clear();
|
||||||
|
clearAllUserData();
|
||||||
|
relaunchApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoreData {
|
||||||
|
warnBeforeExit: boolean;
|
||||||
|
minimizeToTray: boolean;
|
||||||
|
spellCheckerEnabled: boolean;
|
||||||
|
autoHideMenuBar: boolean;
|
||||||
|
locale?: string | string[];
|
||||||
|
disableHardwareAcceleration: boolean;
|
||||||
|
safeStorage?: Record<string, string>;
|
||||||
|
/** the safeStorage backend used for the safeStorage data as written */
|
||||||
|
safeStorageBackend?: SafeStorageBackend;
|
||||||
|
/** whether to explicitly override the safeStorage backend, used for migration */
|
||||||
|
safeStorageBackendOverride?: boolean;
|
||||||
|
/** whether to perform a migration of the safeStorage data */
|
||||||
|
safeStorageBackendMigrate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback storage writer for secrets, mainly used for automated tests and systems without any safeStorage support.
|
||||||
|
*/
|
||||||
|
class PlaintextStorageWriter {
|
||||||
|
public constructor(protected readonly store: ElectronStore<StoreData>) {}
|
||||||
|
|
||||||
|
public getKey(key: string): `safeStorage.${string}` {
|
||||||
|
return `safeStorage.${key.replaceAll(".", "-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set(key: string, secret: string): void {
|
||||||
|
this.store.set(this.getKey(key), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(key: string): string | null {
|
||||||
|
return this.store.get(this.getKey(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
public delete(key: string): void {
|
||||||
|
this.store.delete(this.getKey(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage writer for secrets using safeStorage.
|
||||||
|
*/
|
||||||
|
class SafeStorageWriter extends PlaintextStorageWriter {
|
||||||
|
public set(key: string, secret: string): void {
|
||||||
|
this.store.set(this.getKey(key), safeStorage.encryptString(secret).toString("base64"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public get(key: string): string | null {
|
||||||
|
const ciphertext = this.store.get<string, string | undefined>(this.getKey(key));
|
||||||
|
if (ciphertext) {
|
||||||
|
try {
|
||||||
|
return safeStorage.decryptString(Buffer.from(ciphertext, "base64"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to decrypt secret", e);
|
||||||
|
console.error("...ciphertext:", JSON.stringify(ciphertext));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enum Mode {
|
||||||
|
Encrypted = "encrypted", // default
|
||||||
|
AllowPlaintext = "allow-plaintext",
|
||||||
|
ForcePlaintext = "force-plaintext",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
class Store extends ElectronStore<StoreData> {
|
||||||
|
private static internalInstance?: Store;
|
||||||
|
|
||||||
|
public static get instance(): Store | undefined {
|
||||||
|
return Store.internalInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the store, does not prepare safeStorage, which needs to be done after the app is ready.
|
||||||
|
* Must be executed in the first tick of the event loop so that it can call Electron APIs before ready state.
|
||||||
|
*/
|
||||||
|
public static initialize(mode: Mode | undefined): Store {
|
||||||
|
if (Store.internalInstance) {
|
||||||
|
throw new Error("Store already initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Store(mode ?? Mode.Encrypted);
|
||||||
|
Store.internalInstance = store;
|
||||||
|
|
||||||
|
if (process.platform === "linux" && store.get("safeStorageBackendOverride")) {
|
||||||
|
const backend = store.get("safeStorageBackend")!;
|
||||||
|
if (backend in safeStorageBackendMap) {
|
||||||
|
// If the safeStorage backend which was used to write the data is one we can specify via the commandLine
|
||||||
|
// then do so to ensure we use the same backend for reading the data.
|
||||||
|
app.commandLine.appendSwitch(
|
||||||
|
"password-store",
|
||||||
|
safeStorageBackendMap[backend as keyof typeof safeStorageBackendMap],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provides "raw" access to the underlying secrets storage,
|
||||||
|
// should be avoided in favour of the getSecret/setSecret/deleteSecret methods.
|
||||||
|
private secrets?: PlaintextStorageWriter | SafeStorageWriter;
|
||||||
|
|
||||||
|
private constructor(private mode: Mode) {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
safeStorageBackend: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
safeStorageBackendOverride: {
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
safeStorageBackendMigrate: {
|
||||||
|
type: "boolean",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeStorageReadyPromise?: Promise<unknown>;
|
||||||
|
public async safeStorageReady(): Promise<void> {
|
||||||
|
if (!this.safeStorageReadyPromise) {
|
||||||
|
this.safeStorageReadyPromise = this.prepareSafeStorage();
|
||||||
|
}
|
||||||
|
await this.safeStorageReadyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the safeStorage backend for use.
|
||||||
|
* We don't eagerly import from keytar as that would bring in data for all Element profiles and not just the current one,
|
||||||
|
* so we import lazily in getSecret.
|
||||||
|
*/
|
||||||
|
private async prepareSafeStorage(): Promise<void> {
|
||||||
|
await app.whenReady();
|
||||||
|
|
||||||
|
let safeStorageBackend = this.get("safeStorageBackend");
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
// Linux safeStorage support is hellish, the support varies on the Desktop Environment used rather than the store itself.
|
||||||
|
// https://github.com/electron/electron/issues/39789 https://github.com/microsoft/vscode/issues/185212
|
||||||
|
const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend();
|
||||||
|
console.info(
|
||||||
|
`safeStorage backend '${selectedSafeStorageBackend}' selected, '${safeStorageBackend}' in config.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedSafeStorageBackend === "unknown") {
|
||||||
|
// This should never happen but good to be safe
|
||||||
|
await dialog.showMessageBox({
|
||||||
|
title: _t("store|error|unknown_backend_override_title"),
|
||||||
|
message: _t("store|error|unknown_backend_override"),
|
||||||
|
detail: _t("store|error|unknown_backend_override_details"),
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
throw new Error("safeStorage backend unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.get("safeStorageBackendMigrate")) {
|
||||||
|
return this.upgradeLinuxBackend2();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeStorageBackend) {
|
||||||
|
if (selectedSafeStorageBackend === "basic_text" && this.mode === Mode.Encrypted) {
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
title: _t("store|error|unsupported_keyring_title"),
|
||||||
|
message: _t("store|error|unsupported_keyring"),
|
||||||
|
detail: _t("store|error|unsupported_keyring_detail", {
|
||||||
|
link: "https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux",
|
||||||
|
}),
|
||||||
|
type: "error",
|
||||||
|
buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_cta")],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
});
|
||||||
|
if (response === 0) {
|
||||||
|
throw new Error("safeStorage backend basic_text and user rejected it");
|
||||||
|
}
|
||||||
|
this.mode = Mode.AllowPlaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the backend used for the safeStorage data so we can detect if it changes
|
||||||
|
this.recordSafeStorageBackend(selectedSafeStorageBackend);
|
||||||
|
safeStorageBackend = selectedSafeStorageBackend;
|
||||||
|
} else if (safeStorageBackend !== selectedSafeStorageBackend) {
|
||||||
|
console.warn(`safeStorage backend changed from ${safeStorageBackend} to ${selectedSafeStorageBackend}`);
|
||||||
|
|
||||||
|
if (safeStorageBackend === "basic_text") {
|
||||||
|
return this.upgradeLinuxBackend1();
|
||||||
|
} else if (safeStorageBackend === "plaintext") {
|
||||||
|
this.upgradeLinuxBackend3();
|
||||||
|
} else if (safeStorageBackend in safeStorageBackendMap) {
|
||||||
|
this.set("safeStorageBackendOverride", true);
|
||||||
|
relaunchApp();
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Warn the user that the backend has changed and tell them that we cannot migrate
|
||||||
|
const { response } = await dialog.showMessageBox({
|
||||||
|
title: _t("store|error|backend_changed_title"),
|
||||||
|
message: _t("store|error|backend_changed"),
|
||||||
|
detail: _t("store|error|backend_changed_detail"),
|
||||||
|
type: "question",
|
||||||
|
buttons: [_t("common|no"), _t("common|yes")],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
});
|
||||||
|
if (response === 0) {
|
||||||
|
throw new Error("safeStorage backend changed and cannot migrate");
|
||||||
|
}
|
||||||
|
await clearDataAndRelaunch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We do not check allowPlaintextStorage here as it was already checked above if the storage is new
|
||||||
|
// and if the storage is existing then we should continue to honour the backend used to write the data
|
||||||
|
if (safeStorageBackend === "basic_text" && selectedSafeStorageBackend === safeStorageBackend) {
|
||||||
|
safeStorage.setUsePlainTextEncryption(true);
|
||||||
|
}
|
||||||
|
} else if (!safeStorageBackend) {
|
||||||
|
safeStorageBackend = this.mode === Mode.Encrypted ? "system" : "plaintext";
|
||||||
|
this.recordSafeStorageBackend(safeStorageBackend);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode !== Mode.ForcePlaintext && safeStorage.isEncryptionAvailable()) {
|
||||||
|
this.secrets = new SafeStorageWriter(this);
|
||||||
|
} else if (this.mode !== Mode.Encrypted) {
|
||||||
|
this.secrets = new PlaintextStorageWriter(this);
|
||||||
|
} else {
|
||||||
|
throw new Error(`safeStorage is not available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`Using storage mode '${this.mode}' with backend '${safeStorageBackend}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordSafeStorageBackend(backend: SafeStorageBackend): void {
|
||||||
|
this.set("safeStorageBackend", backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux support for upgrading the backend from basic_text to one of the encrypted backends,
|
||||||
|
* this is quite a tricky process as the backend is not known until the app is ready & cannot be changed once it is.
|
||||||
|
* First we restart the app in basic_text backend mode, then decrypt the data & restart back in default backend mode,
|
||||||
|
* and re-encrypt the data.
|
||||||
|
*/
|
||||||
|
private upgradeLinuxBackend1(): void {
|
||||||
|
console.info(`Starting safeStorage migration to ${safeStorage.getSelectedStorageBackend()}`);
|
||||||
|
this.set("safeStorageBackendMigrate", true);
|
||||||
|
relaunchApp();
|
||||||
|
}
|
||||||
|
private upgradeLinuxBackend2(): void {
|
||||||
|
if (!this.secrets) throw new Error("safeStorage not ready");
|
||||||
|
console.info("Performing safeStorage migration");
|
||||||
|
const data = this.get("safeStorage");
|
||||||
|
if (data) {
|
||||||
|
for (const key in data) {
|
||||||
|
this.set(this.secrets.getKey(key), this.secrets!.get(key));
|
||||||
|
}
|
||||||
|
this.recordSafeStorageBackend("plaintext");
|
||||||
|
}
|
||||||
|
this.set("safeStorageBackendMigrate", false);
|
||||||
|
relaunchApp();
|
||||||
|
}
|
||||||
|
private upgradeLinuxBackend3(): void {
|
||||||
|
if (!this.secrets) throw new Error("safeStorage not ready");
|
||||||
|
const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend();
|
||||||
|
console.info(`Finishing safeStorage migration to ${selectedSafeStorageBackend}`);
|
||||||
|
const data = this.get("safeStorage");
|
||||||
|
if (data) {
|
||||||
|
for (const key in data) {
|
||||||
|
this.secrets.set(key, data[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.recordSafeStorageBackend(selectedSafeStorageBackend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the stored secret for the key.
|
||||||
|
* Lazily migrates keys from keytar if they are not yet in the store.
|
||||||
|
*
|
||||||
|
* @param key The string key name.
|
||||||
|
*
|
||||||
|
* @returns A promise for the secret string.
|
||||||
|
*/
|
||||||
|
public async getSecret(key: string): Promise<string | null> {
|
||||||
|
await this.safeStorageReady();
|
||||||
|
let secret = this.secrets!.get(key);
|
||||||
|
if (secret) return secret;
|
||||||
|
|
||||||
|
try {
|
||||||
|
secret = await this.getSecretKeytar(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to read data from keytar with key='${key}'`, e);
|
||||||
|
}
|
||||||
|
if (secret) {
|
||||||
|
console.debug("Migrating secret from keytar", key);
|
||||||
|
this.secrets!.set(key, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
await this.safeStorageReady();
|
||||||
|
this.secrets!.set(key, secret);
|
||||||
|
try {
|
||||||
|
await keytar.setPassword(KEYTAR_SERVICE, key, secret);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to write safeStorage backwards-compatibility key='${key}' data to keytar`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the stored password for the key.
|
||||||
|
* Removes from safeStorage, keytar & keytar legacy.
|
||||||
|
*
|
||||||
|
* @param key The string key name.
|
||||||
|
*/
|
||||||
|
public async deleteSecret(key: string): Promise<void> {
|
||||||
|
await this.safeStorageReady();
|
||||||
|
this.secrets!.delete(key);
|
||||||
|
try {
|
||||||
|
await this.deleteSecretKeytar(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to delete secret with key='${key}' from keytar`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated will be removed in the near future
|
||||||
|
*/
|
||||||
|
private async getSecretKeytar(key: string): Promise<string | null> {
|
||||||
|
return (
|
||||||
|
(await keytar.getPassword(KEYTAR_SERVICE, key)) ?? (await keytar.getPassword(LEGACY_KEYTAR_SERVICE, key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated will be removed in the near future
|
||||||
|
*/
|
||||||
|
private async deleteSecretKeytar(key: string): Promise<void> {
|
||||||
|
await keytar.deletePassword(LEGACY_KEYTAR_SERVICE, key);
|
||||||
|
await keytar.deletePassword(KEYTAR_SERVICE, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Store;
|
||||||
Reference in New Issue
Block a user