Use the code signing Subject Name as basis for Tray GUID on Windows (#32939)

* Use the code signing Subject Name as basis for Tray GUID on Windows

Fixes https://github.com/element-hq/element-web/issues/32907

* Delint

* Iterate

* Add missing imports
This commit is contained in:
Michael Telatynski
2026-03-31 10:22:27 +02:00
committed by GitHub
parent e640948fc8
commit 9ea4079c3b
11 changed files with 135 additions and 89 deletions
+3 -1
View File
@@ -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") {
-5
View File
@@ -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 */
+33
View File
@@ -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<string> | undefined;
// Get the webapp resource file path, memoizes result
export function getAsarPath(): Promise<string> {
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;
}
+18 -6
View File
@@ -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;
}
+10
View File
@@ -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";
}
+8 -56
View File
@@ -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<string> {
// 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<string> | undefined;
// Get the webapp resource file path, memoizes result
function getAsarPath(): Promise<string> {
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<void> {
}
}
// Set up globals for Tray
async function setupGlobals(): Promise<void> {
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;
+17
View File
@@ -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<string> {
const asarPath = await getAsarPath();
const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`;
return path.join(path.dirname(asarPath), "build", iconFile);
}
+1 -1
View File
@@ -59,7 +59,7 @@ const Settings: Record<string, Setting> = {
async write(value: any): Promise<void> {
if (value) {
// Create trayIcon icon
tray.create(global.trayConfig);
await tray.create();
} else {
tray.destroy();
}
+18 -18
View File
@@ -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<void> {
// 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);
+3 -2
View File
@@ -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<boolean> {
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<boolean> {
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|warning", { brand: global.trayConfig.brand }),
description: _t("eol|warning", { brand: getBrand() }),
});
});
}
+24
View File
@@ -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<string> {
return new Promise((resolve, reject) => {
@@ -44,3 +45,26 @@ export function loadJsonFile<T extends Json>(...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<string> {
// 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`);
}