Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions apps/desktop/src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
3 changes: 2 additions & 1 deletion apps/desktop/src/auto-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down
129 changes: 127 additions & 2 deletions apps/desktop/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,131 @@
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({

Check failure on line 102 in apps/desktop/src/config.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this use of the "void" operator.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ4XDaDZCThLedJ1EUTK&open=AZ4XDaDZCThLedJ1EUTK&pullRequest=33468
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;
}
100 changes: 8 additions & 92 deletions apps/desktop/src/electron-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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?
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")],
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading