diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index ce9f3b800be..967a129c1c6 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -1,7 +1,7 @@ import * as os from "node:os"; import * as fs from "node:fs"; import * as path from "node:path"; -import { type Configuration as BaseConfiguration, type Protocol } from "electron-builder"; +import { type Configuration as BaseConfiguration } from "electron-builder"; /** * This script has different outputs depending on your os platform. @@ -38,6 +38,7 @@ interface Metadata { interface ExtraMetadata extends Metadata { electron_appId: string; electron_protocol: string; + electron_windows_cert_sn?: string; } /** @@ -208,6 +209,7 @@ if (variant["linux.deb.name"]) { if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT) { config.win.signtoolOptions!.certificateSubjectName = process.env.ED_SIGNTOOL_SUBJECT_NAME; config.win.signtoolOptions!.certificateSha1 = process.env.ED_SIGNTOOL_THUMBPRINT; + config.extraMetadata.electron_windows_cert_sn = config.win.signtoolOptions!.certificateSubjectName; } if (os.platform() === "linux") { diff --git a/apps/desktop/src/@types/global.d.ts b/apps/desktop/src/@types/global.d.ts index 6cee999b16c..840cc920776 100644 --- a/apps/desktop/src/@types/global.d.ts +++ b/apps/desktop/src/@types/global.d.ts @@ -18,10 +18,5 @@ declare global { var appQuitting: boolean; var appLocalization: AppLocalization; var vectorConfig: IConfigOptions; - var trayConfig: { - // eslint-disable-next-line camelcase - icon_path: string; - brand: string; - }; } /* eslint-enable no-var */ diff --git a/apps/desktop/src/asar.ts b/apps/desktop/src/asar.ts new file mode 100644 index 00000000000..ba9e9800dad --- /dev/null +++ b/apps/desktop/src/asar.ts @@ -0,0 +1,33 @@ +/* +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 { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +import { tryPaths } from "./utils.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let asarPathPromise: Promise | undefined; +// Get the webapp resource file path, memoizes result +export function getAsarPath(): Promise { + if (!asarPathPromise) { + asarPathPromise = tryPaths("webapp", __dirname, [ + // If run from the source checkout, this will be in the directory above + "../webapp.asar", + // but if run from a packaged application, electron-main.js will be in + // a different asar file, so it will be two levels above + "../../webapp.asar", + // also try without the 'asar' suffix to allow symlinking in a directory + "../webapp", + // from a packaged application + "../../webapp", + ]); + } + + return asarPathPromise; +} diff --git a/apps/desktop/src/build-config.ts b/apps/desktop/src/build-config.ts index 09e83e005c5..b4d52865a37 100644 --- a/apps/desktop/src/build-config.ts +++ b/apps/desktop/src/build-config.ts @@ -12,15 +12,27 @@ import { type JsonObject, loadJsonFile } from "./utils.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); +let buildConfig: BuildConfig; + interface BuildConfig { + // Application User Model ID appId: string; + // Protocol string used for OIDC callbacks protocol: string; + // Subject name of the code signing cert used for Windows packages, if signed + // used as a basis for the Tray GUID which must be rolled if the certificate changes. + windowsCertSubjectName: string | undefined; } -export function readBuildConfig(): BuildConfig { - const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject; - return { - appId: (packageJson["electron_appId"] as string) || "im.riot.app", - protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop", - }; +export function getBuildConfig(): BuildConfig { + if (!buildConfig) { + const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject; + buildConfig = { + appId: (packageJson["electron_appId"] as string) || "im.riot.app", + protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop", + windowsCertSubjectName: packageJson["electron_windows_cert_sn"] as string, + }; + } + + return buildConfig; } diff --git a/apps/desktop/src/config.ts b/apps/desktop/src/config.ts new file mode 100644 index 00000000000..f2415738d93 --- /dev/null +++ b/apps/desktop/src/config.ts @@ -0,0 +1,10 @@ +/* +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. +*/ + +export function getBrand(): string { + return global.vectorConfig.brand || "Element"; +} diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index bd1832ecc82..372c6fad244 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -26,7 +26,7 @@ import { import * as Sentry from "@sentry/electron/main"; import path, { dirname } from "node:path"; import windowStateKeeper from "electron-window-state"; -import fs, { promises as afs } from "node:fs"; +import fs from "node:fs"; import { URL, fileURLToPath } from "node:url"; import minimist from "minimist"; @@ -45,7 +45,9 @@ 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 { readBuildConfig } from "./build-config.js"; +import { getBuildConfig } from "./build-config.js"; +import { getAsarPath } from "./asar.js"; +import { getIconPath } from "./icon.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -84,7 +86,7 @@ function isRealUserDataDir(d: string): boolean { return fs.existsSync(path.join(d, "IndexedDB")); } -const buildConfig = readBuildConfig(); +const buildConfig = getBuildConfig(); const protocolHandler = new ProtocolHandler(buildConfig.protocol); // check if we are passed a profile in the SSO callback url @@ -118,45 +120,8 @@ if (userDataPathInProtocol) { } app.setPath("userData", userDataPath); -async function tryPaths(name: string, root: string, rawPaths: string[]): Promise { - // Make everything relative to root - const paths = rawPaths.map((p) => path.join(root, p)); - - for (const p of paths) { - try { - await afs.stat(p); - return p + "/"; - } catch {} - } - console.log(`Couldn't find ${name} files in any of: `); - for (const p of paths) { - console.log("\t" + path.resolve(p)); - } - throw new Error(`Failed to find ${name} files`); -} - const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const; -let asarPathPromise: Promise | undefined; -// Get the webapp resource file path, memoizes result -function getAsarPath(): Promise { - if (!asarPathPromise) { - asarPathPromise = tryPaths("webapp", __dirname, [ - // If run from the source checkout, this will be in the directory above - "../webapp.asar", - // but if run from a packaged application, electron-main.js will be in - // a different asar file, so it will be two levels above - "../../webapp.asar", - // also try without the 'asar' suffix to allow symlinking in a directory - "../webapp", - // from a packaged application - "../../webapp", - ]); - } - - return asarPathPromise; -} - function loadLocalConfigFile(): Json { if (LocalConfigLocation) { console.log("Loading local config: " + LocalConfigLocation); @@ -254,19 +219,6 @@ async function configureSentry(): Promise { } } -// Set up globals for Tray -async function setupGlobals(): Promise { - const asarPath = await getAsarPath(); - await loadConfig(); - - // Figure out the tray icon path & brand name - const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`; - global.trayConfig = { - icon_path: path.join(path.dirname(asarPath), "build", iconFile), - brand: global.vectorConfig.brand || "Element", - }; -} - global.appQuitting = false; const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ @@ -347,7 +299,7 @@ app.on("ready", async () => { try { asarPath = await getAsarPath(); - await setupGlobals(); + await loadConfig(); } catch (e) { console.log("App setup failed: exiting", e); process.exit(1); @@ -451,7 +403,7 @@ app.on("ready", async () => { titleBarStyle: process.platform === "darwin" ? "hidden" : "default", trafficLightPosition: { x: 9, y: 8 }, - icon: global.trayConfig.icon_path, + icon: await getIconPath(), show: false, autoHideMenuBar: store.get("autoHideMenuBar"), @@ -489,7 +441,7 @@ app.on("ready", async () => { global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true)); // Create trayIcon icon - if (store.get("minimizeToTray")) tray.create(global.trayConfig); + if (store.get("minimizeToTray")) await tray.create(); global.mainWindow.once("ready-to-show", () => { if (!global.mainWindow) return; diff --git a/apps/desktop/src/icon.ts b/apps/desktop/src/icon.ts new file mode 100644 index 00000000000..0a7457a4973 --- /dev/null +++ b/apps/desktop/src/icon.ts @@ -0,0 +1,17 @@ +/* +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 path from "node:path"; + +import { getAsarPath } from "./asar.js"; + +export async function getIconPath(): Promise { + const asarPath = await getAsarPath(); + + const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`; + return path.join(path.dirname(asarPath), "build", iconFile); +} diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts index 84f671f6694..d0bb1d2f091 100644 --- a/apps/desktop/src/settings.ts +++ b/apps/desktop/src/settings.ts @@ -59,7 +59,7 @@ const Settings: Record = { async write(value: any): Promise { if (value) { // Create trayIcon icon - tray.create(global.trayConfig); + await tray.create(); } else { tray.destroy(); } diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts index 94deec3547d..f6833f2f2fc 100644 --- a/apps/desktop/src/tray.ts +++ b/apps/desktop/src/tray.ts @@ -14,6 +14,12 @@ import pngToIco from "png-to-ico"; 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"; + +// This hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4 +const UUID_NAMESPACE = "9fc9c6a0-9ffe-45c9-9cd7-5639ae38b232"; let trayIcon: Tray | null = null; @@ -38,31 +44,25 @@ function toggleWin(): void { } } -function getUuid(): string { - // The uuid field is optional and only needed on unsigned Windows packages where the executable path changes - // The hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4 - return global.vectorConfig["uuid"] || "eba84003-e499-4563-8e9d-166e34b5cc25"; -} - -export function create(config: (typeof global)["trayConfig"]): void { +export async function create(): Promise { // no trays on darwin if (process.platform === "darwin" || trayIcon) return; - const defaultIcon = nativeImage.createFromPath(config.icon_path); + const iconPath = await getIconPath(); + const defaultIcon = nativeImage.createFromPath(iconPath); - let guid: string | undefined; - if (process.platform === "win32" && app.isPackaged) { + const buildConfig = getBuildConfig(); + if (process.platform === "win32" && app.isPackaged && buildConfig.windowsCertSubjectName) { // Providing a GUID lets Windows be smarter about maintaining user's tray preferences // https://github.com/electron/electron/pull/21891 - // Ideally we would only specify it for signed packages but determining whether the app is signed sufficiently - // is non-trivial. So instead we have an escape hatch that unsigned packages can iterate the `uuid` in - // config.json to prevent Windows refusing GUID-reuse if their executable path changes. - guid = uuidv5(`${app.getName()}-${app.getPath("userData")}`, getUuid()); + // We generate the GUID in a custom arbitrary namespace and use the subject name & userData path + // to differentiate different app builds on the same system. + const guid = uuidv5(`${buildConfig.windowsCertSubjectName}:${app.getPath("userData")}`, UUID_NAMESPACE); + trayIcon = new Tray(defaultIcon, guid); + } else { + trayIcon = new Tray(defaultIcon); } - // Passing guid=undefined on Windows will cause it to throw `Error: Invalid GUID format` - // The type here is wrong, the param must be omitted, never undefined. - trayIcon = guid ? new Tray(defaultIcon, guid) : new Tray(defaultIcon); - trayIcon.setToolTip(config.brand); + trayIcon.setToolTip(getBrand()); initApplicationMenu(); trayIcon.on("click", toggleWin); diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 438d841bdc6..5b35fc5a09b 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -12,6 +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"; const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; const INITIAL_UPDATE_DELAY_MS = 30 * 1000; @@ -149,7 +150,7 @@ async function available(): Promise { initialisePromise.then(() => { ipcMain.emit("showToast", { title: _t("eol|title"), - description: _t("eol|no_more_updates", { brand: global.trayConfig.brand }), + description: _t("eol|no_more_updates", { brand: getBrand() }), }); }); console.warn("Auto update not supported, macOS version too old"); @@ -160,7 +161,7 @@ async function available(): Promise { initialisePromise.then(() => { ipcMain.emit("showToast", { title: _t("eol|title"), - description: _t("eol|warning", { brand: global.trayConfig.brand }), + description: _t("eol|warning", { brand: getBrand() }), }); }); } diff --git a/apps/desktop/src/utils.ts b/apps/desktop/src/utils.ts index 5b89cf7162d..368211aba77 100644 --- a/apps/desktop/src/utils.ts +++ b/apps/desktop/src/utils.ts @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import afs from "node:fs/promises"; export async function randomArray(size: number): Promise { return new Promise((resolve, reject) => { @@ -44,3 +45,26 @@ export function loadJsonFile(...paths: string[]): T { const file = fs.readFileSync(joinedPaths, { encoding: "utf-8" }); return JSON.parse(file); } + +/** + * Looks for a given path relative to root + * @param name - dir name to use in logging + * @param root - the root to search from + * @param rawPaths - the paths to search, in order + */ +export async function tryPaths(name: string, root: string, rawPaths: string[]): Promise { + // Make everything relative to root + const paths = rawPaths.map((p) => path.join(root, p)); + + for (const p of paths) { + try { + await afs.stat(p); + return p + "/"; + } catch {} + } + console.log(`Couldn't find ${name} files in any of: `); + for (const p of paths) { + console.log("\t" + path.resolve(p)); + } + throw new Error(`Failed to find ${name} files`); +}