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:
committed by
GitHub
parent
e640948fc8
commit
9ea4079c3b
@@ -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") {
|
||||
|
||||
Vendored
-5
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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() }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user