Refactor Desktop config to avoid global (#33468)
* Fix desktop registering protocol handler wrong Was previously registering with too many args and was also only handling deeplinks if the app was already open, on a cold start they would be blindly ignored. Tests aplenty * Refactor Desktop config to avoid global and centralise defaults * Improve coverage * Fix tests * Improve coverage * Fix test * Rename field * Improve coverage * Rename field * Move protocolHandler initialisation to after mainWindow is navigating * Add comment * Avoid double call to loadURL * Improve coverage * Improve coverage * Fix tsc
This commit is contained in:
Vendored
-3
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> => 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<ConfigOptions>;
|
||||
|
||||
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<ConfigOptions>;
|
||||
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());
|
||||
});
|
||||
});
|
||||
+127
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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(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"),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<ConfigOptions> => ({
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "es2022",
|
||||
"lib": ["es2024"],
|
||||
"sourceMap": false,
|
||||
"typeRoots": [],
|
||||
"types": [],
|
||||
|
||||
Reference in New Issue
Block a user