diff --git a/electron-builder.ts b/electron-builder.ts index 683cc2b..6e48290 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -1,6 +1,6 @@ import * as os from "node:os"; import * as fs from "node:fs"; -import { Configuration as BaseConfiguration } from "electron-builder"; +import { type Configuration as BaseConfiguration, type Protocol } from "electron-builder"; /** * This script has different outputs depending on your os platform. @@ -16,9 +16,13 @@ import { Configuration as BaseConfiguration } from "electron-builder"; * Passes $ED_DEBIAN_CHANGELOG to build.deb.fpm if specified */ +const DEFAULT_APP_ID = "im.riot.app"; const NIGHTLY_APP_ID = "im.riot.nightly"; const NIGHTLY_DEB_NAME = "element-nightly"; +const DEFAULT_PROTOCOL_SCHEME = "io.element.desktop"; +const NIGHTLY_PROTOCOL_SCHEME = "io.element.nightly"; + interface Pkg { name: string; productName: string; @@ -33,7 +37,11 @@ type Writable = NonNullable< const pkg: Pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); interface Configuration extends BaseConfiguration { - extraMetadata: Partial> & Omit; + extraMetadata: Partial> & + Omit & { + electron_appId: string; + electron_protocol: string; + }; linux: BaseConfiguration["linux"]; win: BaseConfiguration["win"]; mac: BaseConfiguration["mac"]; @@ -50,7 +58,7 @@ const config: Omit, "electronFuses"> & { // Make all fuses required to ensure they are all explicitly specified electronFuses: Required; } = { - appId: "im.riot.app", + appId: DEFAULT_APP_ID, asarUnpack: "**/*.node", electronFuses: { enableCookieEncryption: true, @@ -85,6 +93,8 @@ const config: Omit, "electronFuses"> & { name: pkg.name, productName: pkg.productName, description: pkg.description, + electron_appId: DEFAULT_APP_ID, + electron_protocol: DEFAULT_PROTOCOL_SCHEME, }, linux: { target: ["tar.gz", "deb"], @@ -142,12 +152,10 @@ const config: Omit, "electronFuses"> & { directories: { output: "dist", }, - protocols: [ - { - name: "element", - schemes: ["io.element.desktop", "element"], - }, - ], + protocols: { + name: "element", + schemes: [DEFAULT_PROTOCOL_SCHEME, "element"], + }, nativeRebuilder: "sequential", nodeGypRebuild: false, npmRebuild: true, @@ -170,11 +178,12 @@ if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT) if (process.env.ED_NIGHTLY) { config.deb.fpm = []; // Clear the fpm as the breaks deb fields don't apply to nightly - config.appId = NIGHTLY_APP_ID; + config.appId = config.extraMetadata.electron_appId = NIGHTLY_APP_ID; config.extraMetadata.productName += " Nightly"; config.extraMetadata.name += "-nightly"; config.extraMetadata.description += " (nightly unstable build)"; config.deb.fpm.push("--name", NIGHTLY_DEB_NAME); + (config.protocols as Protocol).schemes[0] = config.extraMetadata.electron_protocol = NIGHTLY_PROTOCOL_SCHEME; let version = process.env.ED_NIGHTLY; if (os.platform() === "win32") { diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 68514bc..722a9b8 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -13,11 +13,13 @@ import { type AppLocalization } from "../language-helper.js"; // global type extensions need to use var for whatever reason /* eslint-disable no-var */ declare global { + type IConfigOptions = Record; + var mainWindow: BrowserWindow | null; var appQuitting: boolean; var appLocalization: AppLocalization; var launcher: AutoLaunch; - var vectorConfig: Record; + var vectorConfig: IConfigOptions; var trayConfig: { // eslint-disable-next-line camelcase icon_path: string; diff --git a/src/build-config.ts b/src/build-config.ts new file mode 100644 index 0000000..09e83e0 --- /dev/null +++ b/src/build-config.ts @@ -0,0 +1,26 @@ +/* +Copyright 2025 New Vector 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, { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { type JsonObject, loadJsonFile } from "./utils.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface BuildConfig { + appId: string; + protocol: string; +} + +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", + }; +} diff --git a/src/electron-main.ts b/src/electron-main.ts index 8bb7f4b..a00166c 100644 --- a/src/electron-main.ts +++ b/src/electron-main.ts @@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details. // Squirrel on windows starts the app with various flags as hooks to tell us when we've been installed/uninstalled etc. import "./squirrelhooks.js"; -import { app, BrowserWindow, Menu, autoUpdater, protocol, dialog, type Input, type Event, session } from "electron"; +import { app, BrowserWindow, Menu, autoUpdater, dialog, type Input, type Event, session, protocol } from "electron"; // eslint-disable-next-line n/file-extension-in-import import * as Sentry from "@sentry/electron/main"; import AutoLaunch from "auto-launch"; @@ -28,12 +28,13 @@ import Store from "./store.js"; import { buildMenuTemplate } from "./vectormenu.js"; import webContentsHandler from "./webcontents-handler.js"; import * as updater from "./updater.js"; -import { getProfileFromDeeplink, protocolInit } from "./protocol.js"; +import ProtocolHandler from "./protocol.js"; import { _t, AppLocalization } from "./language-helper.js"; 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"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -72,10 +73,13 @@ function isRealUserDataDir(d: string): boolean { return fs.existsSync(path.join(d, "IndexedDB")); } +const buildConfig = readBuildConfig(); +const protocolHandler = new ProtocolHandler(buildConfig.protocol); + // check if we are passed a profile in the SSO callback url let userDataPath: string; -const userDataPathInProtocol = getProfileFromDeeplink(argv["_"]); +const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]); if (userDataPathInProtocol) { userDataPath = userDataPathInProtocol; } else if (argv["profile-dir"]) { @@ -311,7 +315,7 @@ if (!gotLock) { } // do this after we know we are the primary instance of the app -protocolInit(); +protocolHandler.initialise(userDataPath); // Register the scheme the app is served from as 'standard' // which allows things like relative URLs and IndexedDB to @@ -616,4 +620,4 @@ app.on("second-instance", (ev, commandLine, workingDirectory) => { // It must also match the ID found in 'electron-builder' // in order to get the title and icon to show up correctly. // Ref: https://stackoverflow.com/a/77314604/3525780 -app.setAppUserModelId("im.riot.app"); +app.setAppUserModelId(buildConfig.appId); diff --git a/src/ipc.ts b/src/ipc.ts index 6cbd49e..eee1c06 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details. import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron"; import IpcMainEvent = Electron.IpcMainEvent; -import { recordSSOSession } from "./protocol.js"; import { randomArray } from "./utils.js"; import { Settings } from "./settings.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; @@ -96,9 +95,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { global.mainWindow.focus(); } break; - case "getConfig": - ret = global.vectorConfig; - break; + case "navigateBack": if (global.mainWindow.webContents.canGoBack()) { global.mainWindow.webContents.goBack(); @@ -135,10 +132,6 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages; break; - case "startSSOFlow": - recordSSOSession(args[0]); - break; - case "getPickleKey": try { ret = await store.getSecret(`${args[0]}|${args[1]}`); @@ -248,3 +241,5 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { reply: ret, }); }); + +ipcMain.handle("getConfig", () => global.vectorConfig); diff --git a/src/preload.cts b/src/preload.cts index 5ab287d..e879ed5 100644 --- a/src/preload.cts +++ b/src/preload.cts @@ -49,4 +49,16 @@ contextBridge.exposeInMainWorld("electron", { } ipcRenderer.send(channel, ...args); }, + + async initialise(): Promise<{ + protocol: string; + sessionId: string; + config: IConfigOptions; + }> { + const [{ protocol, sessionId }, config] = await Promise.all([ + ipcRenderer.invoke("getProtocol"), + ipcRenderer.invoke("getConfig"), + ]); + return { protocol, sessionId, config }; + }, }); diff --git a/src/protocol.ts b/src/protocol.ts index 837f85d..292a4a0 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -6,119 +6,136 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { app } from "electron"; +import { app, ipcMain } from "electron"; import { URL } from "node:url"; import path from "node:path"; import fs from "node:fs"; +import { randomUUID } from "node:crypto"; const LEGACY_PROTOCOL = "element"; -const PROTOCOL = "io.element.desktop"; const SEARCH_PARAM = "element-desktop-ssoid"; const STORE_FILE_NAME = "sso-sessions.json"; // we getPath userData before electron-main changes it, so this is the default value const storePath = path.join(app.getPath("userData"), STORE_FILE_NAME); -function processUrl(url: string): void { - if (!global.mainWindow) return; +export default class ProtocolHandler { + private readonly store: Record = {}; + private readonly sessionId: string; - const parsed = new URL(url); - // sanity check: we only register for the one protocol, so we shouldn't - // be getting anything else unless the user is forcing a URL to open - // with the Element app. - if (parsed.protocol !== `${PROTOCOL}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) { - console.log("Ignoring unexpected protocol: ", parsed.protocol); - return; + public constructor(private readonly protocol: string) { + // get all args except `hidden` as it'd mean the app would not get focused + // XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking + // --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url + const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden"); + if (app.isPackaged) { + app.setAsDefaultProtocolClient(this.protocol, process.execPath, args); + app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args); + } else if (process.platform === "win32") { + // on Mac/Linux this would just cause the electron binary to open + // special handler for running without being packaged, e.g `electron .` by passing our app path to electron + app.setAsDefaultProtocolClient(this.protocol, process.execPath, [app.getAppPath(), ...args]); + app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]); + } + + if (process.platform === "darwin") { + // Protocol handler for macos + app.on("open-url", (ev, url) => { + ev.preventDefault(); + this.processUrl(url); + }); + } else { + // Protocol handler for win32/Linux + app.on("second-instance", (ev, commandLine) => { + const url = commandLine[commandLine.length - 1]; + if (!url.startsWith(`${this.protocol}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return; + this.processUrl(url); + }); + } + + this.store = this.readStore(); + this.sessionId = randomUUID(); + + ipcMain.handle("getProtocol", this.onGetProtocol); } - const urlToLoad = new URL("vector://vector/webapp/"); - // ignore anything other than the search (used for SSO login redirect) - // and the hash (for general element deep links) - // There's no reason to allow anything else, particularly other paths, - // since this would allow things like the internal jitsi wrapper to - // be loaded, which would get the app stuck on that page and generally - // be a bit strange and confusing. - urlToLoad.search = parsed.search; - urlToLoad.hash = parsed.hash; + private readonly onGetProtocol = (): { protocol: string; sessionId: string } => { + return { + protocol: this.protocol, + sessionId: this.sessionId, + }; + }; - console.log("Opening URL: ", urlToLoad.href); - void global.mainWindow.loadURL(urlToLoad.href); -} + private processUrl(url: string): void { + if (!global.mainWindow) return; -function readStore(): Record { - try { - const s = fs.readFileSync(storePath, { encoding: "utf8" }); - const o = JSON.parse(s); - return typeof o === "object" ? o : {}; - } catch { - return {}; + const parsed = new URL(url); + // sanity check: we only register for the one protocol, so we shouldn't + // be getting anything else unless the user is forcing a URL to open + // with the Element app. + if (parsed.protocol !== `${this.protocol}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) { + console.log("Ignoring unexpected protocol: ", parsed.protocol); + return; + } + + const urlToLoad = new URL("vector://vector/webapp/"); + // ignore anything other than the search (used for SSO login redirect) + // and the hash (for general element deep links) + // There's no reason to allow anything else, particularly other paths, + // since this would allow things like the internal jitsi wrapper to + // be loaded, which would get the app stuck on that page and generally + // be a bit strange and confusing. + urlToLoad.search = parsed.search; + urlToLoad.hash = parsed.hash; + + console.log("Opening URL: ", urlToLoad.href); + void global.mainWindow.loadURL(urlToLoad.href); } -} -function writeStore(data: Record): void { - fs.writeFileSync(storePath, JSON.stringify(data)); -} - -export function recordSSOSession(sessionID: string): void { - const userDataPath = app.getPath("userData"); - const store = readStore(); - for (const key in store) { - // ensure each instance only has one (the latest) session ID to prevent the file growing unbounded - if (store[key] === userDataPath) { - delete store[key]; - break; + private readStore(): Record { + try { + const s = fs.readFileSync(storePath, { encoding: "utf8" }); + const o = JSON.parse(s); + return typeof o === "object" ? o : {}; + } catch { + return {}; } } - store[sessionID] = userDataPath; - writeStore(store); -} -export function getProfileFromDeeplink(args: string[]): string | undefined { - // check if we are passed a profile in the SSO callback url - const deeplinkUrl = args.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${LEGACY_PROTOCOL}://`)); - if (deeplinkUrl?.includes(SEARCH_PARAM)) { - const parsedUrl = new URL(deeplinkUrl); - if (parsedUrl.protocol === `${PROTOCOL}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) { - const store = readStore(); - let ssoID = parsedUrl.searchParams.get(SEARCH_PARAM); - if (!ssoID) { - // In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid` - // We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ` - ssoID = parsedUrl.searchParams.get("state")!.split(`:${SEARCH_PARAM}:`)[1]; + private writeStore(): void { + fs.writeFileSync(storePath, JSON.stringify(this.store)); + } + + public initialise(userDataPath: string): void { + for (const key in this.store) { + // ensure each instance only has one (the latest) session ID to prevent the file growing unbounded + if (this.store[key] === userDataPath) { + delete this.store[key]; + break; + } + } + this.store[this.sessionId] = userDataPath; + this.writeStore(); + } + + public getProfileFromDeeplink(args: string[]): string | undefined { + // check if we are passed a profile in the SSO callback url + const deeplinkUrl = args.find( + (arg) => arg.startsWith(`${this.protocol}://`) || arg.startsWith(`${LEGACY_PROTOCOL}://`), + ); + if (deeplinkUrl?.includes(SEARCH_PARAM)) { + const parsedUrl = new URL(deeplinkUrl); + if (parsedUrl.protocol === `${this.protocol}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) { + const store = this.readStore(); + let sessionId = parsedUrl.searchParams.get(SEARCH_PARAM); + if (!sessionId) { + // In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid` + // We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ` + sessionId = parsedUrl.searchParams.get("state")!.split(`:${SEARCH_PARAM}:`)[1]; + } + console.log("Forwarding to profile: ", store[sessionId]); + return store[sessionId]; } - console.log("Forwarding to profile: ", store[ssoID]); - return store[ssoID]; } } } - -export function protocolInit(): void { - // get all args except `hidden` as it'd mean the app would not get focused - // XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking - // --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url - const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden"); - if (app.isPackaged) { - app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args); - app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args); - } else if (process.platform === "win32") { - // on Mac/Linux this would just cause the electron binary to open - // special handler for running without being packaged, e.g `electron .` by passing our app path to electron - app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [app.getAppPath(), ...args]); - app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]); - } - - if (process.platform === "darwin") { - // Protocol handler for macos - app.on("open-url", function (ev, url) { - ev.preventDefault(); - processUrl(url); - }); - } else { - // Protocol handler for win32/Linux - app.on("second-instance", (ev, commandLine) => { - const url = commandLine[commandLine.length - 1]; - if (!url.startsWith(`${PROTOCOL}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return; - processUrl(url); - }); - } -} diff --git a/src/utils.ts b/src/utils.ts index 77cefb8..5b89cf7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -23,7 +23,7 @@ export async function randomArray(size: number): Promise { type JsonValue = null | string | number; type JsonArray = Array; -interface JsonObject { +export interface JsonObject { [key: string]: JsonObject | JsonArray | JsonValue; } export type Json = JsonArray | JsonObject;