Skip to content
Merged
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
4 changes: 3 additions & 1 deletion apps/desktop/electron-builder.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -38,6 +38,7 @@ interface Metadata {
interface ExtraMetadata extends Metadata {
electron_appId: string;
electron_protocol: string;
electron_windows_cert_sn?: string;
}

/**
Expand Down Expand Up @@ -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") {
Expand Down
5 changes: 0 additions & 5 deletions apps/desktop/src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 33 additions & 0 deletions apps/desktop/src/asar.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 18 additions & 6 deletions apps/desktop/src/build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 10 additions & 0 deletions apps/desktop/src/config.ts
Original file line number Diff line number Diff line change
@@ -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";
}
64 changes: 8 additions & 56 deletions apps/desktop/src/electron-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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));

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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> = [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"),

Expand Down Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/icon.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 1 addition & 1 deletion apps/desktop/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
36 changes: 18 additions & 18 deletions apps/desktop/src/tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);

Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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() }),
});
});
}
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/utils.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 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) => {
Expand Down Expand Up @@ -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`);
}
Loading