Skip to content

Commit 9ea4079

Browse files
authored
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 #32907 * Delint * Iterate * Add missing imports
1 parent e640948 commit 9ea4079

11 files changed

Lines changed: 135 additions & 89 deletions

File tree

apps/desktop/electron-builder.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as os from "node:os";
22
import * as fs from "node:fs";
33
import * as path from "node:path";
4-
import { type Configuration as BaseConfiguration, type Protocol } from "electron-builder";
4+
import { type Configuration as BaseConfiguration } from "electron-builder";
55

66
/**
77
* This script has different outputs depending on your os platform.
@@ -38,6 +38,7 @@ interface Metadata {
3838
interface ExtraMetadata extends Metadata {
3939
electron_appId: string;
4040
electron_protocol: string;
41+
electron_windows_cert_sn?: string;
4142
}
4243

4344
/**
@@ -208,6 +209,7 @@ if (variant["linux.deb.name"]) {
208209
if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT) {
209210
config.win.signtoolOptions!.certificateSubjectName = process.env.ED_SIGNTOOL_SUBJECT_NAME;
210211
config.win.signtoolOptions!.certificateSha1 = process.env.ED_SIGNTOOL_THUMBPRINT;
212+
config.extraMetadata.electron_windows_cert_sn = config.win.signtoolOptions!.certificateSubjectName;
211213
}
212214

213215
if (os.platform() === "linux") {

apps/desktop/src/@types/global.d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,5 @@ declare global {
1818
var appQuitting: boolean;
1919
var appLocalization: AppLocalization;
2020
var vectorConfig: IConfigOptions;
21-
var trayConfig: {
22-
// eslint-disable-next-line camelcase
23-
icon_path: string;
24-
brand: string;
25-
};
2621
}
2722
/* eslint-enable no-var */

apps/desktop/src/asar.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { fileURLToPath } from "node:url";
9+
import { dirname } from "node:path";
10+
11+
import { tryPaths } from "./utils.js";
12+
13+
const __dirname = dirname(fileURLToPath(import.meta.url));
14+
15+
let asarPathPromise: Promise<string> | undefined;
16+
// Get the webapp resource file path, memoizes result
17+
export function getAsarPath(): Promise<string> {
18+
if (!asarPathPromise) {
19+
asarPathPromise = tryPaths("webapp", __dirname, [
20+
// If run from the source checkout, this will be in the directory above
21+
"../webapp.asar",
22+
// but if run from a packaged application, electron-main.js will be in
23+
// a different asar file, so it will be two levels above
24+
"../../webapp.asar",
25+
// also try without the 'asar' suffix to allow symlinking in a directory
26+
"../webapp",
27+
// from a packaged application
28+
"../../webapp",
29+
]);
30+
}
31+
32+
return asarPathPromise;
33+
}

apps/desktop/src/build-config.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,27 @@ import { type JsonObject, loadJsonFile } from "./utils.js";
1212

1313
const __dirname = dirname(fileURLToPath(import.meta.url));
1414

15+
let buildConfig: BuildConfig;
16+
1517
interface BuildConfig {
18+
// Application User Model ID
1619
appId: string;
20+
// Protocol string used for OIDC callbacks
1721
protocol: string;
22+
// Subject name of the code signing cert used for Windows packages, if signed
23+
// used as a basis for the Tray GUID which must be rolled if the certificate changes.
24+
windowsCertSubjectName: string | undefined;
1825
}
1926

20-
export function readBuildConfig(): BuildConfig {
21-
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
22-
return {
23-
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
24-
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
25-
};
27+
export function getBuildConfig(): BuildConfig {
28+
if (!buildConfig) {
29+
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
30+
buildConfig = {
31+
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
32+
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
33+
windowsCertSubjectName: packageJson["electron_windows_cert_sn"] as string,
34+
};
35+
}
36+
37+
return buildConfig;
2638
}

apps/desktop/src/config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
export function getBrand(): string {
9+
return global.vectorConfig.brand || "Element";
10+
}

apps/desktop/src/electron-main.ts

Lines changed: 8 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
import * as Sentry from "@sentry/electron/main";
2727
import path, { dirname } from "node:path";
2828
import windowStateKeeper from "electron-window-state";
29-
import fs, { promises as afs } from "node:fs";
29+
import fs from "node:fs";
3030
import { URL, fileURLToPath } from "node:url";
3131
import minimist from "minimist";
3232

@@ -45,7 +45,9 @@ import { setDisplayMediaCallback } from "./displayMediaCallback.js";
4545
import { setupMacosTitleBar } from "./macos-titlebar.js";
4646
import { type Json, loadJsonFile } from "./utils.js";
4747
import { setupMediaAuth } from "./media-auth.js";
48-
import { readBuildConfig } from "./build-config.js";
48+
import { getBuildConfig } from "./build-config.js";
49+
import { getAsarPath } from "./asar.js";
50+
import { getIconPath } from "./icon.js";
4951

5052
const __dirname = dirname(fileURLToPath(import.meta.url));
5153

@@ -84,7 +86,7 @@ function isRealUserDataDir(d: string): boolean {
8486
return fs.existsSync(path.join(d, "IndexedDB"));
8587
}
8688

87-
const buildConfig = readBuildConfig();
89+
const buildConfig = getBuildConfig();
8890
const protocolHandler = new ProtocolHandler(buildConfig.protocol);
8991

9092
// check if we are passed a profile in the SSO callback url
@@ -118,45 +120,8 @@ if (userDataPathInProtocol) {
118120
}
119121
app.setPath("userData", userDataPath);
120122

121-
async function tryPaths(name: string, root: string, rawPaths: string[]): Promise<string> {
122-
// Make everything relative to root
123-
const paths = rawPaths.map((p) => path.join(root, p));
124-
125-
for (const p of paths) {
126-
try {
127-
await afs.stat(p);
128-
return p + "/";
129-
} catch {}
130-
}
131-
console.log(`Couldn't find ${name} files in any of: `);
132-
for (const p of paths) {
133-
console.log("\t" + path.resolve(p));
134-
}
135-
throw new Error(`Failed to find ${name} files`);
136-
}
137-
138123
const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const;
139124

140-
let asarPathPromise: Promise<string> | undefined;
141-
// Get the webapp resource file path, memoizes result
142-
function getAsarPath(): Promise<string> {
143-
if (!asarPathPromise) {
144-
asarPathPromise = tryPaths("webapp", __dirname, [
145-
// If run from the source checkout, this will be in the directory above
146-
"../webapp.asar",
147-
// but if run from a packaged application, electron-main.js will be in
148-
// a different asar file, so it will be two levels above
149-
"../../webapp.asar",
150-
// also try without the 'asar' suffix to allow symlinking in a directory
151-
"../webapp",
152-
// from a packaged application
153-
"../../webapp",
154-
]);
155-
}
156-
157-
return asarPathPromise;
158-
}
159-
160125
function loadLocalConfigFile(): Json {
161126
if (LocalConfigLocation) {
162127
console.log("Loading local config: " + LocalConfigLocation);
@@ -254,19 +219,6 @@ async function configureSentry(): Promise<void> {
254219
}
255220
}
256221

257-
// Set up globals for Tray
258-
async function setupGlobals(): Promise<void> {
259-
const asarPath = await getAsarPath();
260-
await loadConfig();
261-
262-
// Figure out the tray icon path & brand name
263-
const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`;
264-
global.trayConfig = {
265-
icon_path: path.join(path.dirname(asarPath), "build", iconFile),
266-
brand: global.vectorConfig.brand || "Element",
267-
};
268-
}
269-
270222
global.appQuitting = false;
271223

272224
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
@@ -347,7 +299,7 @@ app.on("ready", async () => {
347299

348300
try {
349301
asarPath = await getAsarPath();
350-
await setupGlobals();
302+
await loadConfig();
351303
} catch (e) {
352304
console.log("App setup failed: exiting", e);
353305
process.exit(1);
@@ -451,7 +403,7 @@ app.on("ready", async () => {
451403
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
452404
trafficLightPosition: { x: 9, y: 8 },
453405

454-
icon: global.trayConfig.icon_path,
406+
icon: await getIconPath(),
455407
show: false,
456408
autoHideMenuBar: store.get("autoHideMenuBar"),
457409

@@ -489,7 +441,7 @@ app.on("ready", async () => {
489441
global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true));
490442

491443
// Create trayIcon icon
492-
if (store.get("minimizeToTray")) tray.create(global.trayConfig);
444+
if (store.get("minimizeToTray")) await tray.create();
493445

494446
global.mainWindow.once("ready-to-show", () => {
495447
if (!global.mainWindow) return;

apps/desktop/src/icon.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import path from "node:path";
9+
10+
import { getAsarPath } from "./asar.js";
11+
12+
export async function getIconPath(): Promise<string> {
13+
const asarPath = await getAsarPath();
14+
15+
const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`;
16+
return path.join(path.dirname(asarPath), "build", iconFile);
17+
}

apps/desktop/src/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const Settings: Record<string, Setting> = {
5959
async write(value: any): Promise<void> {
6060
if (value) {
6161
// Create trayIcon icon
62-
tray.create(global.trayConfig);
62+
await tray.create();
6363
} else {
6464
tray.destroy();
6565
}

apps/desktop/src/tray.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import pngToIco from "png-to-ico";
1414
import path from "node:path";
1515

1616
import { _t } from "./language-helper.js";
17+
import { getBuildConfig } from "./build-config.js";
18+
import { getBrand } from "./config.js";
19+
import { getIconPath } from "./icon.js";
20+
21+
// This hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4
22+
const UUID_NAMESPACE = "9fc9c6a0-9ffe-45c9-9cd7-5639ae38b232";
1723

1824
let trayIcon: Tray | null = null;
1925

@@ -38,31 +44,25 @@ function toggleWin(): void {
3844
}
3945
}
4046

41-
function getUuid(): string {
42-
// The uuid field is optional and only needed on unsigned Windows packages where the executable path changes
43-
// The hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4
44-
return global.vectorConfig["uuid"] || "eba84003-e499-4563-8e9d-166e34b5cc25";
45-
}
46-
47-
export function create(config: (typeof global)["trayConfig"]): void {
47+
export async function create(): Promise<void> {
4848
// no trays on darwin
4949
if (process.platform === "darwin" || trayIcon) return;
50-
const defaultIcon = nativeImage.createFromPath(config.icon_path);
50+
const iconPath = await getIconPath();
51+
const defaultIcon = nativeImage.createFromPath(iconPath);
5152

52-
let guid: string | undefined;
53-
if (process.platform === "win32" && app.isPackaged) {
53+
const buildConfig = getBuildConfig();
54+
if (process.platform === "win32" && app.isPackaged && buildConfig.windowsCertSubjectName) {
5455
// Providing a GUID lets Windows be smarter about maintaining user's tray preferences
5556
// https://github.com/electron/electron/pull/21891
56-
// Ideally we would only specify it for signed packages but determining whether the app is signed sufficiently
57-
// is non-trivial. So instead we have an escape hatch that unsigned packages can iterate the `uuid` in
58-
// config.json to prevent Windows refusing GUID-reuse if their executable path changes.
59-
guid = uuidv5(`${app.getName()}-${app.getPath("userData")}`, getUuid());
57+
// We generate the GUID in a custom arbitrary namespace and use the subject name & userData path
58+
// to differentiate different app builds on the same system.
59+
const guid = uuidv5(`${buildConfig.windowsCertSubjectName}:${app.getPath("userData")}`, UUID_NAMESPACE);
60+
trayIcon = new Tray(defaultIcon, guid);
61+
} else {
62+
trayIcon = new Tray(defaultIcon);
6063
}
6164

62-
// Passing guid=undefined on Windows will cause it to throw `Error: Invalid GUID format`
63-
// The type here is wrong, the param must be omitted, never undefined.
64-
trayIcon = guid ? new Tray(defaultIcon, guid) : new Tray(defaultIcon);
65-
trayIcon.setToolTip(config.brand);
65+
trayIcon.setToolTip(getBrand());
6666
initApplicationMenu();
6767
trayIcon.on("click", toggleWin);
6868

apps/desktop/src/updater.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import os from "node:os";
1212
import { getSquirrelExecutable } from "./squirrelhooks.js";
1313
import { _t } from "./language-helper.js";
1414
import { initialisePromise } from "./ipc.js";
15+
import { getBrand } from "./config.js";
1516

1617
const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
1718
const INITIAL_UPDATE_DELAY_MS = 30 * 1000;
@@ -149,7 +150,7 @@ async function available(): Promise<boolean> {
149150
initialisePromise.then(() => {
150151
ipcMain.emit("showToast", {
151152
title: _t("eol|title"),
152-
description: _t("eol|no_more_updates", { brand: global.trayConfig.brand }),
153+
description: _t("eol|no_more_updates", { brand: getBrand() }),
153154
});
154155
});
155156
console.warn("Auto update not supported, macOS version too old");
@@ -160,7 +161,7 @@ async function available(): Promise<boolean> {
160161
initialisePromise.then(() => {
161162
ipcMain.emit("showToast", {
162163
title: _t("eol|title"),
163-
description: _t("eol|warning", { brand: global.trayConfig.brand }),
164+
description: _t("eol|warning", { brand: getBrand() }),
164165
});
165166
});
166167
}

0 commit comments

Comments
 (0)