Refactor Desktop config to avoid global

and centralise defaults
This commit is contained in:
Michael Telatynski 2026-05-11 13:10:34 +01:00
parent 7af8beea40
commit e09c122824
No known key found for this signature in database
GPG Key ID: A2B008A5F49F5D0D
11 changed files with 154 additions and 111 deletions

View File

@ -12,11 +12,8 @@ import { type AppLocalization } from "../language-helper.js";
// global type extensions need to use var for whatever reason
/* eslint-disable no-var */
declare global {
type IConfigOptions = Record<string, any>;
var mainWindow: BrowserWindow | null;
var appQuitting: boolean;
var appLocalization: AppLocalization;
var vectorConfig: IConfigOptions;
}
/* eslint-enable no-var */

View File

@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
import BaseAutoLaunch from "auto-launch";
import Store from "./store.js";
import { getConfig } from "./config.js";
export type AutoLaunchState = "enabled" | "minimised" | "disabled";
@ -19,7 +20,7 @@ export class AutoLaunch extends BaseAutoLaunch {
if (!AutoLaunch.internalInstance) {
if (!Store.instance) throw new Error("Store not initialized");
AutoLaunch.internalInstance = new AutoLaunch({
name: global.vectorConfig.brand || "Element",
name: getConfig().brand,
isHidden: Store.instance.get("openAtLoginMinimised"),
mac: {
useLaunchAgent: true,

View File

@ -5,6 +5,131 @@ 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.
*/
export function getBrand(): string {
return global.vectorConfig.brand || "Element";
import { app, dialog } from "electron";
import path from "node:path";
import { getAsarPath } from "./asar.js";
import { type Json, loadJsonFile } from "./utils.js";
export interface ConfigOptions {
brand: string;
help_url: string;
web_base_url: string;
modules?: string[];
sentry?: {
dsn?: string;
environment?: string;
};
update_base_url?: string;
// homeserver props
default_is_url?: string;
default_hs_url?: string;
default_server_name?: string;
default_server_config?: object;
}
const ConfigFilename = "config.json";
let config: ConfigOptions;
const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const;
function loadLocalConfigFile(location: string | undefined): Json {
if (location) {
console.log("Loading local config: " + location);
return loadJsonFile(location);
} else {
const configDir = app.getPath("userData");
console.log(`Loading local config: ${path.join(configDir, ConfigFilename)}`);
return loadJsonFile(configDir, ConfigFilename);
}
}
const DEFAULTS = {
brand: "Element",
help_url: "https://element.io/help",
web_base_url: "https://app.element.io/",
} satisfies ConfigOptions;
function applyDefaults(conf: ConfigOptions): void {
for (const k in DEFAULTS) {
const key = k as keyof typeof DEFAULTS;
conf[key] ||= DEFAULTS[key];
}
}
let loadConfigPromise: Promise<ConfigOptions> | undefined;
// Loads the config from asar, and applies a config.json from userData atop if one exists
// Writes config to `global.vectorConfig`. Idempotent, returns the same promise on subsequent calls.
export function loadConfig(localConfigPath: string | undefined): Promise<ConfigOptions> {
if (loadConfigPromise) return loadConfigPromise;
async function actuallyLoadConfig(): Promise<ConfigOptions> {
const asarPath = await getAsarPath();
try {
console.log(`Loading app config: ${path.join(asarPath, ConfigFilename)}`);
// XXX: we trust that we built the package with a sane config, but should use something like zod here in future
config = loadJsonFile(asarPath, ConfigFilename) as unknown as ConfigOptions;
} catch {
// it would be nice to check the error code here and bail if the config
// is unparsable, but we get MODULE_NOT_FOUND in the case of a missing
// file or invalid json, so node is just very unhelpful.
// Continue with the defaults (ie. an empty config)
config = { ...DEFAULTS };
}
applyDefaults(config);
try {
// Load local config and use it to override values from the one baked with the build
const localConfig = loadLocalConfigFile(localConfigPath);
// If the local config has a homeserver defined, don't use the homeserver from the build
// config. This is to avoid a problem where Riot thinks there are multiple homeservers
// defined, and panics as a result.
if (Object.keys(localConfig).some((k) => homeserverProps.includes(<any>k))) {
for (const key of homeserverProps) {
delete config[key];
}
}
config = Object.assign(config, localConfig);
} catch (e) {
if (e instanceof SyntaxError) {
await app.whenReady();
void dialog.showMessageBox({
type: "error",
title: `Your ${config.brand} is misconfigured`,
message:
`Your custom ${config.brand} configuration contains invalid JSON. ` +
`Please correct the problem and reopen ${config.brand}.`,
detail: e.message || "",
});
}
// Could not load local config, this is expected in most cases.
}
// Tweak modules paths as they assume the root is at the same level as webapp, but for `vector://vector/webapp` it is not.
if (Array.isArray(config.modules)) {
config.modules = config.modules.map((m) => {
if (m.startsWith("/")) {
return "/webapp" + m;
}
return m;
});
}
// Apply defaults again in case the local config had an explicit null/undefined value for required keys.
applyDefaults(config);
return config;
}
loadConfigPromise = actuallyLoadConfig();
return loadConfigPromise;
}
export function getConfig(): ConfigOptions {
return config;
}

View File

@ -43,11 +43,11 @@ import ProtocolHandler from "./protocol.js";
import { _t, AppLocalization } from "./language-helper.js";
import { setDisplayMediaCallback } from "./displayMediaCallback.js";
import { setupMacosTitleBar } from "./macos-titlebar.js";
import { type Json, loadJsonFile } from "./utils.js";
import { setupMediaAuth } from "./media-auth.js";
import { getBuildConfig } from "./build-config.js";
import { getAsarPath } from "./asar.js";
import { getIconPath } from "./icon.js";
import { type ConfigOptions, loadConfig } from "./config.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -77,7 +77,6 @@ if (argv["help"]) {
}
const LocalConfigLocation = process.env.ELEMENT_DESKTOP_CONFIG_JSON ?? argv["config"];
const LocalConfigFilename = "config.json";
// Electron creates the user data directory (with just an empty 'Dictionaries' directory...)
// as soon as the app path is set, so pick a random path in it that must exist if it's a
@ -120,94 +119,10 @@ if (userDataPathInProtocol) {
}
app.setPath("userData", userDataPath);
const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const;
function loadLocalConfigFile(): Json {
if (LocalConfigLocation) {
console.log("Loading local config: " + LocalConfigLocation);
return loadJsonFile(LocalConfigLocation);
} else {
const configDir = app.getPath("userData");
console.log(`Loading local config: ${path.join(configDir, LocalConfigFilename)}`);
return loadJsonFile(configDir, LocalConfigFilename);
}
}
let loadConfigPromise: Promise<void> | undefined;
// Loads the config from asar, and applies a config.json from userData atop if one exists
// Writes config to `global.vectorConfig`. Idempotent, returns the same promise on subsequent calls.
function loadConfig(): Promise<void> {
if (loadConfigPromise) return loadConfigPromise;
async function actuallyLoadConfig(): Promise<void> {
const asarPath = await getAsarPath();
try {
console.log(`Loading app config: ${path.join(asarPath, LocalConfigFilename)}`);
global.vectorConfig = loadJsonFile(asarPath, LocalConfigFilename);
} catch {
// it would be nice to check the error code here and bail if the config
// is unparsable, but we get MODULE_NOT_FOUND in the case of a missing
// file or invalid json, so node is just very unhelpful.
// Continue with the defaults (ie. an empty config)
global.vectorConfig = {};
}
try {
// Load local config and use it to override values from the one baked with the build
const localConfig = loadLocalConfigFile();
// If the local config has a homeserver defined, don't use the homeserver from the build
// config. This is to avoid a problem where Riot thinks there are multiple homeservers
// defined, and panics as a result.
if (Object.keys(localConfig).find((k) => homeserverProps.includes(<any>k))) {
// Rip out all the homeserver options from the vector config
global.vectorConfig = Object.keys(global.vectorConfig)
.filter((k) => !homeserverProps.includes(<any>k))
.reduce(
(obj, key) => {
obj[key] = global.vectorConfig[key];
return obj;
},
{} as Omit<Partial<(typeof global)["vectorConfig"]>, keyof typeof homeserverProps>,
);
}
global.vectorConfig = Object.assign(global.vectorConfig, localConfig);
} catch (e) {
if (e instanceof SyntaxError) {
await app.whenReady();
void dialog.showMessageBox({
type: "error",
title: `Your ${global.vectorConfig.brand || "Element"} is misconfigured`,
message:
`Your custom ${global.vectorConfig.brand || "Element"} configuration contains invalid JSON. ` +
`Please correct the problem and reopen ${global.vectorConfig.brand || "Element"}.`,
detail: e.message || "",
});
}
// Could not load local config, this is expected in most cases.
}
// Tweak modules paths as they assume the root is at the same level as webapp, but for `vector://vector/webapp` it is not.
if (Array.isArray(global.vectorConfig.modules)) {
global.vectorConfig.modules = global.vectorConfig.modules.map((m) => {
if (m.startsWith("/")) {
return "/webapp" + m;
}
return m;
});
}
}
loadConfigPromise = actuallyLoadConfig();
return loadConfigPromise;
}
// Configure Electron Sentry and crashReporter using sentry.dsn in config.json if one is present.
async function configureSentry(): Promise<void> {
await loadConfig();
const { dsn, environment } = global.vectorConfig.sentry || {};
const config = await loadConfig(LocalConfigLocation);
const { dsn, environment } = config.sentry || {};
if (dsn) {
console.log(`Enabling Sentry with dsn=${dsn} environment=${environment}`);
Sentry.init({
@ -296,10 +211,11 @@ app.on("ready", async () => {
console.debug("Reached Electron ready state");
let asarPath: string;
let config: ConfigOptions;
try {
asarPath = await getAsarPath();
await loadConfig();
config = await loadConfig(LocalConfigLocation);
} catch (e) {
console.log("App setup failed: exiting", e);
process.exit(1);
@ -376,8 +292,8 @@ app.on("ready", async () => {
// Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim.
if (argv["update"] === false) {
console.log("Auto update disabled via command line flag");
} else if (global.vectorConfig["update_base_url"]) {
void updater.start(global.vectorConfig["update_base_url"]);
} else if (config.update_base_url) {
void updater.start(config.update_base_url);
} else {
console.log("No update_base_url is defined: auto update is disabled");
}
@ -474,7 +390,7 @@ app.on("ready", async () => {
buttons: [
_t("action|cancel"),
_t("action|close_brand", {
brand: global.vectorConfig.brand || "Element",
brand: config.brand,
}),
],
message: _t("confirm_quit"),

View File

@ -11,6 +11,7 @@ import IpcMainEvent = Electron.IpcMainEvent;
import { randomArray } from "./utils.js";
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
import Store, { clearDataAndRelaunch } from "./store.js";
import { getConfig } from "./config.js";
let focusHandlerAttached = false;
ipcMain.on("loudNotification", function (): void {
@ -217,7 +218,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
});
});
ipcMain.handle("getConfig", () => global.vectorConfig);
ipcMain.handle("getConfig", getConfig);
const initialisePromiseWithResolvers = Promise.withResolvers<void>();
export const initialisePromise = initialisePromiseWithResolvers.promise;

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
// This file is compiled to CommonJS rather than ESM otherwise the browser chokes on the import statement.
import { ipcRenderer, contextBridge, IpcRendererEvent } from "electron";
import type { ConfigOptions } from "./config.js" with { "resolution-mode": "import" };
// Expose only expected IPC wrapper APIs to the renderer process to avoid
// handing out generalised messaging access.
@ -54,7 +55,7 @@ contextBridge.exposeInMainWorld("electron", {
async initialise(): Promise<{
protocol: string;
sessionId: string;
config: IConfigOptions;
config: ConfigOptions;
supportedSettings: Record<string, boolean>;
/**
* Do we need to render badge overlays for new notifications?

View File

@ -18,6 +18,7 @@ import ElectronStore from "electron-store";
import { app, safeStorage, dialog, type SafeStorage, type Session } from "electron";
import { _t } from "./language-helper.js";
import { getConfig } from "./config.js";
/**
* String union type representing all the safeStorage backends.
@ -373,7 +374,7 @@ class Store extends ElectronStore<StoreData> {
message: _t("store|error|backend_no_encryption"),
detail: _t("store|error|backend_no_encryption_detail", {
backend: safeStorage.getSelectedStorageBackend(),
brand: global.vectorConfig.brand || "Element",
brand: getConfig().brand,
}),
type: "error",
buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_use_plaintext")],
@ -389,7 +390,7 @@ class Store extends ElectronStore<StoreData> {
title: _t("store|error|unsupported_keyring_title"),
message: _t("store|error|unsupported_keyring"),
detail: _t("store|error|unsupported_keyring_detail", {
brand: global.vectorConfig.brand || "Element",
brand: getConfig().brand,
link: "https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux",
}),
type: "error",

View File

@ -15,8 +15,8 @@ import path from "node:path";
import { _t } from "./language-helper.js";
import { getBuildConfig } from "./build-config.js";
import { getBrand } from "./config.js";
import { getIconPath } from "./icon.js";
import { getConfig } from "./config.js";
// This hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4
const UUID_NAMESPACE = "9fc9c6a0-9ffe-45c9-9cd7-5639ae38b232";
@ -62,7 +62,7 @@ export async function create(): Promise<void> {
trayIcon = new Tray(defaultIcon);
}
trayIcon.setToolTip(getBrand());
trayIcon.setToolTip(getConfig().brand);
initApplicationMenu();
trayIcon.on("click", toggleWin);

View File

@ -12,7 +12,7 @@ import os from "node:os";
import { getSquirrelExecutable } from "./squirrelhooks.js";
import { _t } from "./language-helper.js";
import { initialisePromise } from "./ipc.js";
import { getBrand } from "./config.js";
import { getConfig } from "./config.js";
const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
const INITIAL_UPDATE_DELAY_MS = 30 * 1000;
@ -150,7 +150,7 @@ async function available(): Promise<boolean> {
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|no_more_updates", { brand: getBrand() }),
description: _t("eol|no_more_updates", { brand: getConfig().brand }),
});
});
console.warn("Auto update not supported, macOS version too old");
@ -161,7 +161,7 @@ async function available(): Promise<boolean> {
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|warning", { brand: getBrand() }),
description: _t("eol|warning", { brand: getConfig().brand }),
});
});
}

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
import { app, shell, Menu, type MenuItem, type MenuItemConstructorOptions } from "electron";
import { _t } from "./language-helper.js";
import { getConfig } from "./config.js";
const isMac = process.platform === "darwin";
@ -129,10 +130,9 @@ export function buildMenuTemplate(): Menu {
role: "help",
submenu: [
{
// XXX: vectorConfig won't have defaults applied to it so we need to duplicate them here
label: _t("common|brand_help", { brand: global.vectorConfig?.brand || "Element" }),
label: _t("common|brand_help", { brand: getConfig().brand }),
click(): void {
void shell.openExternal(global.vectorConfig?.help_url || "https://element.io/help");
void shell.openExternal(getConfig().help_url);
},
},
],

View File

@ -27,6 +27,7 @@ import { pipeline } from "node:stream/promises";
import path from "node:path";
import { _t } from "./language-helper.js";
import { getConfig } from "./config.js";
const MAILTO_PREFIX = "mailto:";
@ -75,7 +76,7 @@ function onLinkContextMenu(ev: Event, params: ContextMenuParams, webContents: We
if (url.startsWith("vector://vector/webapp")) {
// Avoid showing a context menu for app icons
if (params.hasImageContents) return;
const baseUrl = vectorConfig.web_base_url ?? "https://app.element.io/";
const baseUrl = getConfig().web_base_url;
// Rewrite URL so that it can be used outside the app
url = baseUrl + url.substring(23);
}