diff --git a/apps/desktop/src/@types/global.d.ts b/apps/desktop/src/@types/global.d.ts index 840cc92077..3314f37261 100644 --- a/apps/desktop/src/@types/global.d.ts +++ b/apps/desktop/src/@types/global.d.ts @@ -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; - var mainWindow: BrowserWindow | null; var appQuitting: boolean; var appLocalization: AppLocalization; - var vectorConfig: IConfigOptions; } /* eslint-enable no-var */ diff --git a/apps/desktop/src/auto-launch.ts b/apps/desktop/src/auto-launch.ts index 52c51bacc6..54271127c1 100644 --- a/apps/desktop/src/auto-launch.ts +++ b/apps/desktop/src/auto-launch.ts @@ -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, diff --git a/apps/desktop/src/config.test.ts b/apps/desktop/src/config.test.ts new file mode 100644 index 0000000000..36d0b07711 --- /dev/null +++ b/apps/desktop/src/config.test.ts @@ -0,0 +1,183 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dialog } from "electron"; + +import { type ConfigOptions } from "./config.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +vi.mock("node:fs", () => ({ default: memfs })); +vi.mock("node:fs/promises", () => ({ default: memfs.promises })); + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"), + whenReady: (): Promise => Promise.resolve(), + }, + dialog: { + showMessageBox: vi.fn(), + }, +})); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); +}); + +describe("loadConfig", () => { + let loadConfig: (localConfigPath: string | undefined) => Promise; + + beforeEach(async () => { + vol.fromJSON( + { + "../webapp.asar/config.json": JSON.stringify({ + web_base_url: "https://chat.org.com", + default_hs_url: "https://matrix.org.com", + }), + }, + __dirname, + ); + + vi.resetModules(); + ({ loadConfig } = await import("./config.js")); + }); + + it("should ignore localConfigPath if does not exist", async () => { + const config = await loadConfig(resolve(__dirname, "../custom-config.json")); + expect(config.brand).toBe("Element"); + expect(config.web_base_url).toBe("https://chat.org.com"); + expect(config.default_hs_url).toBe("https://matrix.org.com"); + }); + + it("should read localConfigPath if exists", async () => { + vol.fromJSON({ + "/home/custom-config.json": JSON.stringify({ + brand: "foobar", + }), + }); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.brand).toBe("foobar"); + }); + + it("should load default local config if exists", async () => { + vol.fromJSON({ + "/Users/name/Library/Application Support/Element/config.json": JSON.stringify({ + brand: "foobar", + }), + }); + + const config = await loadConfig(undefined); + expect(config.brand).toBe("foobar"); + }); + + it("should apply defaults to any missing fields", async () => { + vol.fromJSON({ + "/home/custom-config.json": JSON.stringify({ + brand: "foobar", + }), + }); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.help_url).toBe("https://element.io/help"); + expect(config.web_base_url).toBe("https://chat.org.com"); + }); + + it("should support all config files missing", async () => { + vol.reset(); + vol.fromJSON( + { + "../webapp.asar/version": "v1.2.3", + }, + __dirname, + ); + + const config = await loadConfig(undefined); + expect(config.help_url).toBe("https://element.io/help"); + expect(config.web_base_url).toBe("https://app.element.io/"); + }); + + it("should handle key conflicts around default homeserver config", async () => { + vol.fromJSON({ + "/home/custom-config.json": JSON.stringify({ + default_server_name: "other-org.com", + }), + }); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.default_server_name).toBe("other-org.com"); + expect(config.default_hs_url).toBeUndefined(); + expect(config.default_server_config).toBeUndefined(); + }); + + it("should map module paths correctly", async () => { + vol.fromJSON( + { + "../webapp.asar/config.json": JSON.stringify({ + web_base_url: "https://chat.org.com", + default_hs_url: "https://matrix.org.com", + modules: ["/modules/banner", "module2"], + }), + }, + __dirname, + ); + + const config = await loadConfig("/home/custom-config.json"); + expect(config.help_url).toBe("https://element.io/help"); + expect(config.web_base_url).toBe("https://chat.org.com"); + expect(config.modules).toStrictEqual(["/webapp/modules/banner", "module2"]); + }); + + it("should show a dialog when encountering a SyntaxError", async () => { + vol.fromJSON({ + "/home/custom-config.json": "NOT_JSON", + }); + + await loadConfig("/home/custom-config.json"); + expect(dialog.showMessageBox).toHaveBeenCalledWith({ + detail: "Unexpected token 'N', \"NOT_JSON\" is not valid JSON", + message: + "Your custom Element configuration contains invalid JSON. Please correct the problem and reopen Element.", + title: "Your Element is misconfigured", + type: "error", + }); + }); +}); + +describe("getConfig", () => { + let loadConfig: (localConfigPath: string | undefined) => Promise; + let getConfig: () => ConfigOptions; + + beforeEach(async () => { + vol.fromJSON( + { + "../webapp.asar/config.json": JSON.stringify({ + web_base_url: "https://chat.org.com", + }), + }, + __dirname, + ); + + vi.resetModules(); + ({ loadConfig, getConfig } = await import("./config.js")); + }); + + it("should return undefined if loadConfig has not been called", () => { + expect(getConfig()).toBeUndefined(); + }); + + it("should return the config once it is loaded", async () => { + const config = await loadConfig(undefined); + expect(config.web_base_url).toBe("https://chat.org.com"); + expect(config).toStrictEqual(getConfig()); + }); +}); diff --git a/apps/desktop/src/config.ts b/apps/desktop/src/config.ts index f2415738d9..5f25066b63 100644 --- a/apps/desktop/src/config.ts +++ b/apps/desktop/src/config.ts @@ -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 | 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 { + if (loadConfigPromise) return loadConfigPromise; + + async function actuallyLoadConfig(): Promise { + 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(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; } diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index 95f3c8ecdc..b3c89b717e 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -41,12 +41,12 @@ 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 { getArgs } from "./args.js"; +import { type ConfigOptions, loadConfig } from "./config.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -54,99 +54,12 @@ const buildConfig = getBuildConfig(); const protocolHandler = new ProtocolHandler(buildConfig.protocol); const args = getArgs(protocolHandler); -const LocalConfigLocation = args.localConfigPath; -const LocalConfigFilename = "config.json"; - app.setPath("userData", args.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 | 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 { - if (loadConfigPromise) return loadConfigPromise; - - async function actuallyLoadConfig(): Promise { - 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(k))) { - // Rip out all the homeserver options from the vector config - global.vectorConfig = Object.keys(global.vectorConfig) - .filter((k) => !homeserverProps.includes(k)) - .reduce( - (obj, key) => { - obj[key] = global.vectorConfig[key]; - return obj; - }, - {} as Omit, 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 { - await loadConfig(); - const { dsn, environment } = global.vectorConfig.sentry || {}; + const config = await loadConfig(args.localConfigPath); + const { dsn, environment } = config.sentry || {}; if (dsn) { console.log(`Enabling Sentry with dsn=${dsn} environment=${environment}`); Sentry.init({ @@ -232,10 +145,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(args.localConfigPath); } catch (e) { console.log("App setup failed: exiting", e); process.exit(1); @@ -311,8 +225,8 @@ app.on("ready", async () => { if (!args.update) { 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"); } @@ -413,7 +327,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"), diff --git a/apps/desktop/src/ipc.test.ts b/apps/desktop/src/ipc.test.ts new file mode 100644 index 0000000000..5b69168f75 --- /dev/null +++ b/apps/desktop/src/ipc.test.ts @@ -0,0 +1,36 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, describe, it, vi } from "vitest"; +import { ipcMain, type IpcMainInvokeEvent } from "electron"; + +import { getConfig } from "./config.js"; + +vi.mock("electron", () => ({ + ipcMain: { + on: vi.fn(), + once: vi.fn(), + handle: vi.fn(), + }, +})); + +vi.mock("./config.js"); + +describe("getConfig", () => { + it("should call config.getConfig and return the value", async () => { + const config = { brand: "BRAND", help_url: "HELP_URL", web_base_url: "WEB_BASE_URL" }; + vi.mocked(getConfig).mockReturnValue(config); + + await import("./ipc.js"); + + const handler = vi.mocked(ipcMain.handle).mock.calls.find(([channel]) => channel === "getConfig")?.[1]; + expect(handler).toBeDefined(); + + expect(handler!(new Event("test") as unknown as IpcMainInvokeEvent)).toStrictEqual(config); + expect(getConfig).toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index c59f70eb82..047b2199e1 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -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(); export const initialisePromise = initialisePromiseWithResolvers.promise; diff --git a/apps/desktop/src/preload.cts b/apps/desktop/src/preload.cts index 677f996397..18a9afe275 100644 --- a/apps/desktop/src/preload.cts +++ b/apps/desktop/src/preload.cts @@ -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; /** * Do we need to render badge overlays for new notifications? diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index 72598b888a..12ff4d9472 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -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 { 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 { 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", diff --git a/apps/desktop/src/tray.test.ts b/apps/desktop/src/tray.test.ts new file mode 100644 index 0000000000..9820d06639 --- /dev/null +++ b/apps/desktop/src/tray.test.ts @@ -0,0 +1,50 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, describe, it, beforeEach, vi } from "vitest"; +import { Tray } from "electron"; + +import { getConfig } from "./config.js"; + +vi.mock("electron", () => ({ + Tray: vi.fn( + class { + public setToolTip = vi.fn(); + public setContextMenu = vi.fn(); + public on = vi.fn(); + }, + ), + Menu: { + buildFromTemplate: vi.fn(), + }, + nativeImage: { + createFromPath: vi.fn(), + }, + app: { + isPackaged: true, + }, +})); + +vi.mock("./icon.js"); +vi.mock("./config.js"); + +describe("create", () => { + let create: () => Promise; + + beforeEach(async () => { + // The tray is disabled on macOS so test under win32 + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + ({ create } = await import("./tray.js")); + }); + + it("should use config.brand", async () => { + vi.mocked(getConfig).mockReturnValue({ brand: "ChatApp", help_url: "HELP_URL", web_base_url: "WEB_BASE_URL" }); + await create(); + const tray = vi.mocked(Tray).mock.instances[0]; + expect(tray.setToolTip).toHaveBeenCalledWith("ChatApp"); + }); +}); diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index f6833f2f2f..3d8015aba8 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -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 { trayIcon = new Tray(defaultIcon); } - trayIcon.setToolTip(getBrand()); + trayIcon.setToolTip(getConfig().brand); initApplicationMenu(); trayIcon.on("click", toggleWin); diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 5b35fc5a09..e6702d38c5 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -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 { 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 { initialisePromise.then(() => { ipcMain.emit("showToast", { title: _t("eol|title"), - description: _t("eol|warning", { brand: getBrand() }), + description: _t("eol|warning", { brand: getConfig().brand }), }); }); } diff --git a/apps/desktop/src/vectormenu.test.ts b/apps/desktop/src/vectormenu.test.ts new file mode 100644 index 0000000000..f0d1c15375 --- /dev/null +++ b/apps/desktop/src/vectormenu.test.ts @@ -0,0 +1,63 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { expect, describe, it, vi, beforeEach } from "vitest"; +import { type Menu, type MenuItemConstructorOptions, shell } from "electron"; + +import { type buildMenuTemplate as _buildMenuTemplate } from "./vectormenu.js"; +import { type ConfigOptions } from "./config.js"; + +vi.mock("electron", () => ({ + app: { + name: "ChatApp", + }, + shell: { + openExternal: vi.fn(), + }, + Menu: { buildFromTemplate: ((items) => ({ items })) as (typeof Menu)["buildFromTemplate"] } as unknown as Menu, +})); + +vi.mock("./config.js", () => ({ + getConfig: (): Partial => ({ + brand: "IAMBRAND", + help_url: "https://i.need.help", + }), +})); + +vi.mock("./language-helper.js", () => ({ + _t: (k: string): string => k, +})); + +describe("buildMenuTemplate", () => { + describe.each(["darwin", "linux", "win32"] as const)("on %s", (platform) => { + let buildMenuTemplate: typeof _buildMenuTemplate; + + beforeEach(async () => { + vi.spyOn(process, "platform", "get").mockReturnValue(platform); + vi.resetModules(); + ({ buildMenuTemplate } = await import("./vectormenu.js")); + }); + + if (platform === "darwin") { + it("should have an app-named item first", () => { + const menu = buildMenuTemplate(); + expect(menu.items[0].label).toBe("ChatApp"); + }); + } + + it("should include expected `help` menu", () => { + const menu = buildMenuTemplate(); + + const helpMenu = menu.items.at(-1)!; + expect(helpMenu.label).toBe("common|help"); + const helpSubmenu = helpMenu.submenu as unknown as MenuItemConstructorOptions[]; + expect(helpSubmenu[0]!.label).toBe("common|brand_help"); + helpSubmenu[0]!.click!(menu.items.at(-1)!, undefined, new Event("click") as KeyboardEvent); + expect(shell.openExternal).toHaveBeenCalledWith("https://i.need.help"); + }); + }); +}); diff --git a/apps/desktop/src/vectormenu.ts b/apps/desktop/src/vectormenu.ts index d5bab05ed5..6680396e90 100644 --- a/apps/desktop/src/vectormenu.ts +++ b/apps/desktop/src/vectormenu.ts @@ -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); }, }, ], diff --git a/apps/desktop/src/webcontents-handler.ts b/apps/desktop/src/webcontents-handler.ts index 78dbfd791b..c2e66ad5a0 100644 --- a/apps/desktop/src/webcontents-handler.ts +++ b/apps/desktop/src/webcontents-handler.ts @@ -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); } diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json index c9de5ead8d..ff4455db14 100644 --- a/apps/desktop/tsconfig.node.json +++ b/apps/desktop/tsconfig.node.json @@ -4,6 +4,7 @@ "module": "nodenext", "moduleResolution": "NodeNext", "target": "es2022", + "lib": ["es2024"], "sourceMap": false, "typeRoots": [], "types": [],