diff --git a/apps/desktop/src/args.test.ts b/apps/desktop/src/args.test.ts new file mode 100644 index 0000000000..58e189185a --- /dev/null +++ b/apps/desktop/src/args.test.ts @@ -0,0 +1,323 @@ +/* +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 { expect, describe, it, beforeEach, vi } from "vitest"; +import { fs as memfs, vol } from "memfs"; +import { app } from "electron"; + +import { type Args, getArgs, getArgsForProtocolRegistration } from "./args.js"; +import type ProtocolHandler from "./protocol.js"; + +vi.mock("node:fs", () => ({ default: memfs })); +vi.mock("electron", () => ({ + app: { + getPath: vi.fn().mockImplementation((dirName) => { + if (dirName === "userData") return "/Users/name/Library/Application Support/Element"; + if (dirName === "appData") return "/Users/name/Library/Application Support"; + throw new Error("Not implemented"); + }), + getName: vi.fn().mockReturnValue("Element"), + exit: vi.fn(), + }, +})); + +beforeEach(() => { + // Reset the state of the in-memory fs + vol.reset(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); + +describe("getArgsForProtocolRegistration", () => { + it("should return an empty array for default args", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: [], + }), + ).toStrictEqual([]); + }); + + it("should handle standard args", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Custom", + localConfigPath: "/root/config.json", + devtools: true, + update: false, + hidden: false, + positional: [], + }), + ).toStrictEqual([ + "--no-update", + "--config", + "/root/config.json", + "--profile-dir", + "/Users/name/Library/Application Support/Custom", + ]); + }); + + it("should ignore hidden=true", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: true, + positional: [], + }), + ).toStrictEqual([]); + }); + + it("should ignore positional args", () => { + expect( + getArgsForProtocolRegistration({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["element://foobar"], + }), + ).toStrictEqual([]); + }); +}); + +describe("getArgs", () => { + function run(...args: string[]): Args { + vi.spyOn(process, "argv", "get").mockReturnValue(["/path/to/app", ...args]); + const mockProtocolHandler = { + getProfileFromDeeplink: vi.fn(), + } as unknown as ProtocolHandler; + return getArgs(mockProtocolHandler); + } + + it("should handle '--help'", () => { + const args = run("--help"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + expect(app.exit).toHaveBeenCalled(); + }); + + it("should handle no command line args", () => { + const args = run(); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + expect(app.exit).not.toHaveBeenCalled(); + }); + + it("should handle '--hidden'", () => { + const args = run("--hidden"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: true, + positional: ["/path/to/app"], + }); + }); + + it("should handle '--no-update'", () => { + const args = run("--no-update"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: false, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + describe("storageMode", () => { + it("should handle valid '--storage-mode'", () => { + const args = run("--storage-mode=force-plaintext"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + storageMode: "force-plaintext", + }); + }); + + it("should ignore invalid '--storage-mode'", () => { + const args = run("--storage-mode=magic"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + storageMode: undefined, + }); + }); + }); + + describe("userDataPath", () => { + it("should handle deeplinks", () => { + vi.spyOn(process, "argv", "get").mockReturnValue([ + "/path/to/app", + "protocol:/#state=foo:element-desktop-ssoid:XXYYZZ&code=bar", + ]); + const mockProtocolHandler = { + getProfileFromDeeplink: vi.fn().mockReturnValue("/path/to/deeplinked-profile"), + } as unknown as ProtocolHandler; + const args = getArgs(mockProtocolHandler); + + expect(mockProtocolHandler.getProfileFromDeeplink).toHaveBeenCalledWith(process.argv); + expect(args).toEqual({ + userDataPath: "/path/to/deeplinked-profile", + devtools: false, + update: true, + hidden: false, + positional: [...process.argv], + }); + }); + + it("should handle '--profile-dir'", () => { + const args = run("--profile-dir", "/path/to/profile"); + expect(args).toEqual({ + userDataPath: "/path/to/profile", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should handle '--profile'", () => { + const args = run("--profile", "work"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element-work", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should handle ELEMENT_PROFILE_DIR", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_PROFILE_DIR: "/mnt/foo/profile", + }); + const args = run(); + expect(args).toEqual({ + userDataPath: "/mnt/foo/profile", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should prefer deeplink over ELEMENT_PROFILE_DIR", () => { + vi.spyOn(process, "argv", "get").mockReturnValue(["/path/to/app", "protocol:/#state=foo&code=bar"]); + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_PROFILE_DIR: "/mnt/foo/profile", + }); + const mockProtocolHandler = { + getProfileFromDeeplink: vi.fn().mockReturnValue("/path/to/deeplinked-profile"), + } as unknown as ProtocolHandler; + const args = getArgs(mockProtocolHandler); + + expect(mockProtocolHandler.getProfileFromDeeplink).toHaveBeenCalledWith(process.argv); + expect(args).toEqual({ + userDataPath: "/path/to/deeplinked-profile", + devtools: false, + update: true, + hidden: false, + positional: [...process.argv], + }); + }); + + it("should combine ELEMENT_PROFILE_DIR with '--profile'", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_PROFILE_DIR: "/mnt/foo/profile", + }); + const args = run("--profile", "play"); + expect(args).toEqual({ + userDataPath: "/mnt/foo/profile-play", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + + it("should handle old Riot data dirs", () => { + vol.fromJSON({ + "/Users/name/Library/Application Support/Riot/IndexedDB": "This is a real IDB. I promise.", + }); + + const args = run(); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Riot", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + }); + }); + }); + + describe("localConfigPath", () => { + it("should handle '--config'", () => { + const args = run("--config", "/path/to/config.json"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + localConfigPath: "/path/to/config.json", + }); + }); + + it("should handle ELEMENT_DESKTOP_CONFIG_JSON", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_DESKTOP_CONFIG_JSON: "/path/for/config.json", + }); + const args = run(); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + localConfigPath: "/path/for/config.json", + }); + }); + + it("should prefer arg over env", () => { + vi.spyOn(process, "env", "get").mockReturnValue({ + ELEMENT_DESKTOP_CONFIG_JSON: "/path/for/config.json", + }); + const args = run("--config", "/path/to/config.json"); + expect(args).toEqual({ + userDataPath: "/Users/name/Library/Application Support/Element", + devtools: false, + update: true, + hidden: false, + positional: ["/path/to/app"], + localConfigPath: "/path/to/config.json", + }); + }); + }); +}); diff --git a/apps/desktop/src/args.ts b/apps/desktop/src/args.ts new file mode 100644 index 0000000000..4f4f8fec52 --- /dev/null +++ b/apps/desktop/src/args.ts @@ -0,0 +1,165 @@ +/* +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 minimist, { type ParsedArgs } from "minimist"; +import { app } from "electron"; +import fs from "node:fs"; +import path from "node:path"; + +import { Mode } from "./store.js"; +import type ProtocolHandler from "./protocol.js"; + +const defaultUserDataDir = app.getPath("userData"); + +/** + * Calculates the command line arguments to include in the protocol registration, + * some parameters, e.g. '--hidden' are omitted as it'd cause the app to not be focused. + * Excludes all positional arguments as those are only relevant once, e.g. for OIDC auth callbacks. + * Includes unknown parameters as they are sometimes handled by Electron, e.g. `--proxy`. + * @param parsedArgs - the args the application was started with + */ +export function getArgsForProtocolRegistration(parsedArgs: Args): string[] { + const args: string[] = []; + + if (!parsedArgs.update) { + args.push("--no-update"); + } + + if (parsedArgs.localConfigPath) { + args.push("--config", parsedArgs.localConfigPath); + } + + if (parsedArgs.userDataPath != defaultUserDataDir) { + args.push("--profile-dir", parsedArgs.userDataPath); + } + + return args; +} + +/** + * Element Desktop launch args, returned by {@link getArgs} + */ +export interface Args { + /** + * Path to user data, root of all persistent data for this profile. + */ + userDataPath: string; + /** + * Path to local override config.json file. + */ + localConfigPath?: string; + /** + * The store {@link Mode} to use. + */ + storageMode?: Mode; + /** + * Whether to install devtools. + */ + devtools: boolean; + /** + * Whether to start the auto-updater. + */ + update: boolean; + /** + * Whether to start the app hidden. + */ + hidden: boolean; + /** + * Additional positional arguments found. + */ + positional: string[]; +} + +/** + * Electron creates the user data directory (with just an empty 'Dictionaries' directory...) + * as soon as the app path is set, so pick a random path in it that must exist if it's a + * real user data directory. + */ +function isRealUserDataDir(d: string): boolean { + return fs.existsSync(path.join(d, "IndexedDB")); +} + +function getUserDataPath(argv: ParsedArgs, protocolHandler: ProtocolHandler): string { + // check if we are passed a profile in the SSO callback url + const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]); + if (userDataPathInProtocol) { + return userDataPathInProtocol; + } + + if (argv["profile-dir"]) { + return argv["profile-dir"]; + } + + let newUserDataPath = process.env.ELEMENT_PROFILE_DIR ?? defaultUserDataDir; + if (argv["profile"]) { + newUserDataPath += "-" + argv["profile"]; + } + + const newUserDataPathExists = isRealUserDataDir(newUserDataPath); + let oldUserDataPath = path.join(app.getPath("appData"), app.getName().replace("Element", "Riot")); + if (argv["profile"]) { + oldUserDataPath += "-" + argv["profile"]; + } + + const oldUserDataPathExists = isRealUserDataDir(oldUserDataPath); + console.log(`${newUserDataPath} exists: ${newUserDataPathExists ? "yes" : "no"}`); + console.log(`${oldUserDataPath} exists: ${oldUserDataPathExists ? "yes" : "no"}`); + + if (!newUserDataPathExists && oldUserDataPathExists) { + console.log(`Using legacy user data path: ${oldUserDataPath}`); + return oldUserDataPath; + } + return newUserDataPath; +} + +/** + * Parses command line arguments and handles the `--help` flag. + * If `--help` is present, it prints usage information and exits the application. + * Must be called before Electron's userData is set. + */ +export function getArgs(protocolHandler: ProtocolHandler): Args { + const argv = minimist(process.argv, { + alias: { help: "h" }, + }); + + if (argv["help"]) { + console.log("Options:"); + console.log(" --profile-dir {path}: Path to where to store the profile."); + console.log( + ` --profile {name}: Name of alternate profile to use, allows for running multiple accounts.\n` + + ` Ignored if --profile-dir is specified.\n` + + ` The ELEMENT_PROFILE_DIR environment variable may be used to change the default profile path.\n` + + ` It is overridden by --profile-dir, but can be combined with --profile.`, + ); + console.log(" --devtools: Install and use react-devtools and react-perf."); + console.log( + ` --config: Path to the config.json file. May also be specified via the ELEMENT_DESKTOP_CONFIG_JSON environment variable.\n` + + ` Otherwise use the default user location '${defaultUserDataDir}'`, + ); + console.log(" --no-update: Disable automatic updating."); + console.log(" --hidden: Start the application hidden in the system tray."); + console.log(" --help: Displays this help message."); + console.log("And more such as --proxy, see: https://electronjs.org/docs/api/command-line-switches"); + app.exit(); + } + + let storageMode: Mode | undefined; + if ([Mode.Encrypted, Mode.ForcePlaintext, Mode.AllowPlaintext].includes(argv["storage-mode"])) { + storageMode = argv["storage-mode"]; + } + + return { + userDataPath: getUserDataPath(argv, protocolHandler), + localConfigPath: argv["config"] ?? process.env.ELEMENT_DESKTOP_CONFIG_JSON, + storageMode, + devtools: argv["devtools"] || false, + // Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim. + update: argv["update"] ?? true, + hidden: argv["hidden"] || false, + positional: argv["_"], + }; +} diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index 372c6fad24..95f3c8ecdc 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -26,9 +26,7 @@ import { import * as Sentry from "@sentry/electron/main"; import path, { dirname } from "node:path"; import windowStateKeeper from "electron-window-state"; -import fs from "node:fs"; import { URL, fileURLToPath } from "node:url"; -import minimist from "minimist"; import "./ipc.js"; import "./seshat.js"; @@ -48,77 +46,18 @@ import { setupMediaAuth } from "./media-auth.js"; import { getBuildConfig } from "./build-config.js"; import { getAsarPath } from "./asar.js"; import { getIconPath } from "./icon.js"; +import { getArgs } from "./args.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const argv = minimist(process.argv, { - alias: { help: "h" }, -}); - -if (argv["help"]) { - console.log("Options:"); - console.log(" --profile-dir {path}: Path to where to store the profile."); - console.log( - ` --profile {name}: Name of alternate profile to use, allows for running multiple accounts.\n` + - ` Ignored if --profile-dir is specified.\n` + - ` The ELEMENT_PROFILE_DIR environment variable may be used to change the default profile path.\n` + - ` It is overridden by --profile-dir, but can be combined with --profile.`, - ); - console.log(" --devtools: Install and use react-devtools and react-perf."); - console.log( - ` --config: Path to the config.json file. May also be specified via the ELEMENT_DESKTOP_CONFIG_JSON environment variable.\n` + - ` Otherwise use the default user location '${app.getPath("userData")}'`, - ); - console.log(" --no-update: Disable automatic updating."); - console.log(" --hidden: Start the application hidden in the system tray."); - console.log(" --help: Displays this help message."); - console.log("And more such as --proxy, see: https://electronjs.org/docs/api/command-line-switches"); - app.exit(); -} - -const LocalConfigLocation = process.env.ELEMENT_DESKTOP_CONFIG_JSON ?? argv["config"]; -const LocalConfigFilename = "config.json"; - -// Electron creates the user data directory (with just an empty 'Dictionaries' directory...) -// as soon as the app path is set, so pick a random path in it that must exist if it's a -// real user data directory. -function isRealUserDataDir(d: string): boolean { - return fs.existsSync(path.join(d, "IndexedDB")); -} - const buildConfig = getBuildConfig(); const protocolHandler = new ProtocolHandler(buildConfig.protocol); +const args = getArgs(protocolHandler); -// check if we are passed a profile in the SSO callback url -let userDataPath: string; +const LocalConfigLocation = args.localConfigPath; +const LocalConfigFilename = "config.json"; -const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]); -if (userDataPathInProtocol) { - userDataPath = userDataPathInProtocol; -} else if (argv["profile-dir"]) { - userDataPath = argv["profile-dir"]; -} else { - let newUserDataPath = process.env.ELEMENT_PROFILE_DIR ?? app.getPath("userData"); - if (argv["profile"]) { - newUserDataPath += "-" + argv["profile"]; - } - const newUserDataPathExists = isRealUserDataDir(newUserDataPath); - let oldUserDataPath = path.join(app.getPath("appData"), app.getName().replace("Element", "Riot")); - if (argv["profile"]) { - oldUserDataPath += "-" + argv["profile"]; - } - - const oldUserDataPathExists = isRealUserDataDir(oldUserDataPath); - console.log(newUserDataPath + " exists: " + (newUserDataPathExists ? "yes" : "no")); - console.log(oldUserDataPath + " exists: " + (oldUserDataPathExists ? "yes" : "no")); - if (!newUserDataPathExists && oldUserDataPathExists) { - console.log("Using legacy user data path: " + oldUserDataPath); - userDataPath = oldUserDataPath; - } else { - userDataPath = newUserDataPath; - } -} -app.setPath("userData", userDataPath); +app.setPath("userData", args.userDataPath); const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const; @@ -251,9 +190,6 @@ if (!gotLock) { app.exit(); } -// do this after we know we are the primary instance of the app -protocolHandler.initialise(userDataPath); - // Register the scheme the app is served from as 'standard' // which allows things like relative URLs and IndexedDB to // work. @@ -284,7 +220,7 @@ app.enableSandbox(); // We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704 app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService"); -const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions +const store = Store.initialize(args.storageMode); // must be called before any async actions // Disable hardware acceleration if the setting has been set. if (store.get("disableHardwareAcceleration")) { @@ -310,7 +246,7 @@ app.on("ready", async () => { return; } - if (argv["devtools"]) { + if (args.devtools) { try { const { installExtension, REACT_DEVELOPER_TOOLS } = await import("electron-devtools-installer"); installExtension(REACT_DEVELOPER_TOOLS) @@ -373,8 +309,7 @@ app.on("ready", async () => { }); }); - // Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim. - if (argv["update"] === false) { + if (!args.update) { console.log("Auto update disabled via command line flag"); } else if (global.vectorConfig["update_base_url"]) { void updater.start(global.vectorConfig["update_base_url"]); @@ -430,7 +365,11 @@ app.on("ready", async () => { app.exit(1); } - void global.mainWindow.loadURL("vector://vector/webapp/"); + // do this after we know we are the primary instance of the app + const hasDeeplink = protocolHandler.initialise(args); + if (!hasDeeplink) { + void global.mainWindow.loadURL("vector://vector/webapp/"); + } if (process.platform === "darwin") { setupMacosTitleBar(global.mainWindow); @@ -447,7 +386,7 @@ app.on("ready", async () => { if (!global.mainWindow) return; mainWindowState.manage(global.mainWindow); - if (!argv["hidden"]) { + if (!args.hidden) { global.mainWindow.show(); } else { // hide here explicitly because window manage above sometimes shows it diff --git a/apps/desktop/src/protocol.test.ts b/apps/desktop/src/protocol.test.ts index 396a59f065..2f85301623 100644 --- a/apps/desktop/src/protocol.test.ts +++ b/apps/desktop/src/protocol.test.ts @@ -7,6 +7,8 @@ Please see LICENSE files in the repository root for full details. import { expect, describe, it, beforeEach, vi } from "vitest"; import { fs as memfs, vol } from "memfs"; +import EventEmitter from "node:events"; +import { app } from "electron"; import ProtocolHandler from "./protocol.js"; @@ -15,34 +17,45 @@ const TEST_SESSION_ID = "test_session_id"; const USER_DATA_DIR = "/Users/name/Library/Application Support/Element"; vi.mock("node:fs", () => ({ default: memfs })); -vi.mock("electron", () => ({ - app: { - getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"), - on: vi.fn(), - }, - ipcMain: { - handle: vi.fn(), - }, -})); +vi.mock("electron", () => { + const emitter = new EventEmitter(); + + return { + app: { + isPackaged: true, + getPath: vi.fn().mockReturnValue("/Users/name/Library/Application Support/Element"), + getAppPath: vi.fn().mockReturnValue("/bin/element-desktop"), + setAsDefaultProtocolClient: vi.fn(), + on: emitter.on.bind(emitter), + emit: emitter.emit.bind(emitter), + removeAllListeners: emitter.removeAllListeners.bind(emitter), + }, + ipcMain: { + handle: vi.fn(), + }, + }; +}); beforeEach(() => { // Reset the state of the in-memory fs vol.reset(); + // Clear the event emitter + app.removeAllListeners(); }); describe("ProtocolHandler", () => { + beforeEach(() => { + vol.fromJSON( + { + "./sso-sessions.json": JSON.stringify({ [TEST_SESSION_ID]: USER_DATA_DIR }), + }, + USER_DATA_DIR, + ); + }); + describe("getProfileFromDeeplink", () => { const handler = new ProtocolHandler(TEST_PROTOCOL); - beforeEach(() => { - vol.fromJSON( - { - "./sso-sessions.json": JSON.stringify({ [TEST_SESSION_ID]: USER_DATA_DIR }), - }, - USER_DATA_DIR, - ); - }); - it("should handle legacy SSO URIs", () => { expect( handler.getProfileFromDeeplink([ @@ -84,4 +97,77 @@ describe("ProtocolHandler", () => { expect(handler.getProfileFromDeeplink(["Element.app", `test.unrelated:/vector/webapp/`])).toBeUndefined(); }); }); + + it.each(["darwin", "linux", "win32"] as const)("should handle deeplink on %s", (platform) => { + vi.spyOn(process, "platform", "get").mockReturnValue(platform); + vi.stubGlobal("mainWindow", { + loadURL: vi.fn(), + }); + + const handler = new ProtocolHandler(TEST_PROTOCOL); + expect(handler).toBeTruthy(); + + const incomingUri = "test.proto:/#/room/#matrix:matrix.org"; + const expectedUri = "vector://vector/webapp/#/room/#matrix:matrix.org"; + + if (platform === "darwin") { + app.emit("open-url", new Event("test"), incomingUri); + } else { + app.emit("second-instance", new Event("test"), ["/path/to/app", incomingUri]); + } + + expect(global.mainWindow!.loadURL).toHaveBeenCalledWith(expectedUri); + }); + + it("should safely deal with wrong protocol deeplinks", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + vi.stubGlobal("mainWindow", { + loadURL: vi.fn(), + }); + + const handler = new ProtocolHandler(TEST_PROTOCOL); + expect(handler).toBeTruthy(); + + app.emit("open-url", new Event("test"), "random.proto:/#/room/#matrix:matrix.org"); + + expect(global.mainWindow!.loadURL).not.toHaveBeenCalled(); + }); + + describe("initialise", () => { + beforeEach(() => { + vi.spyOn(process, "execPath", "get").mockReturnValue("/bin/element-desktop"); + }); + + it("should set as default protocol client", () => { + const handler = new ProtocolHandler(TEST_PROTOCOL); + handler.initialise({ + userDataPath: USER_DATA_DIR, + devtools: false, + update: false, + hidden: false, + positional: ["/bin/element-desktop"], + }); + + const args = ["--no-update"]; + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith(TEST_PROTOCOL, "/bin/element-desktop", args); + expect(app.setAsDefaultProtocolClient).toHaveBeenCalledWith("element", "/bin/element-desktop", args); + }); + + it("should handle deeplink", () => { + vi.stubGlobal("mainWindow", { + loadURL: vi.fn(), + }); + + const handler = new ProtocolHandler(TEST_PROTOCOL); + handler.initialise({ + userDataPath: "/data", + devtools: false, + update: false, + hidden: false, + positional: ["/bin/element-desktop", "test.proto:/#/room/#matrix:matrix.org"], + }); + + expect(global.mainWindow!.loadURL).toHaveBeenCalledWith("vector://vector/webapp/#/room/#matrix:matrix.org"); + }); + }); }); diff --git a/apps/desktop/src/protocol.ts b/apps/desktop/src/protocol.ts index ad2558fc2a..7d2cb527f1 100644 --- a/apps/desktop/src/protocol.ts +++ b/apps/desktop/src/protocol.ts @@ -12,6 +12,8 @@ import path from "node:path"; import fs from "node:fs"; import { randomUUID } from "node:crypto"; +import { type Args, getArgsForProtocolRegistration } from "./args.js"; + const LEGACY_PROTOCOL = "element"; const SEARCH_PARAM = "element-desktop-ssoid"; const STORE_FILE_NAME = "sso-sessions.json"; @@ -24,32 +26,19 @@ export default class ProtocolHandler { private readonly sessionId: string; 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); + this.handleDeeplink(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); + const url = commandLine.at(-1); + if (url && this.checkArgIsUrl(url)) { + this.handleDeeplink(url); + } }); } @@ -59,6 +48,23 @@ export default class ProtocolHandler { ipcMain.handle("getProtocol", this.onGetProtocol); } + private checkArgIsUrl = (arg: string): boolean => { + return arg.startsWith(`${this.protocol}:/`) || arg.startsWith(`${LEGACY_PROTOCOL}://`); + }; + + private setAsDefaultProtocolClient(parsedArgs: Args): void { + const args = getArgsForProtocolRegistration(parsedArgs); + 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]); + } + } + private readonly onGetProtocol = (): { protocol: string; sessionId: string } => { return { protocol: this.protocol, @@ -66,8 +72,8 @@ export default class ProtocolHandler { }; }; - private processUrl(url: string): void { - if (!global.mainWindow) return; + private handleDeeplink(url: string): boolean { + if (!global.mainWindow) return false; const parsed = new URL(url); // sanity check: we only register for the one protocol, so we shouldn't @@ -75,7 +81,7 @@ export default class ProtocolHandler { // with the Element app. if (parsed.protocol !== `${this.protocol}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) { console.log("Ignoring unexpected protocol: ", parsed.protocol); - return; + return false; } const urlToLoad = new URL("vector://vector/webapp/"); @@ -90,6 +96,7 @@ export default class ProtocolHandler { console.log("Opening URL: ", urlToLoad.href); void global.mainWindow.loadURL(urlToLoad.href); + return true; } private readStore(): Record { @@ -107,16 +114,30 @@ export default class ProtocolHandler { fs.writeFileSync(storePath, JSON.stringify(this.store)); } - public initialise(userDataPath: string): void { + /** + * Initialises the ProtocolHandler + * Registers the app as the default protocol client for deeplink handling. + * Handles any deeplink passed in via args on app start. + * Must be called after mainWindow is set up and any initial navigation is fired. + * @returns whether a deeplink was present in args and navigated to. + */ + public initialise(args: Args): boolean { + this.setAsDefaultProtocolClient(args); + + const url = args.positional.find(this.checkArgIsUrl); + const hasDeeplink = url ? this.handleDeeplink(url) : false; + 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) { + if (this.store[key] === args.userDataPath) { delete this.store[key]; break; } } - this.store[this.sessionId] = userDataPath; + this.store[this.sessionId] = args.userDataPath; this.writeStore(); + + return hasDeeplink; } public getProfileFromDeeplink(args: string[]): string | undefined { diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index dee629be92..72598b888a 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -126,7 +126,7 @@ class SafeStorageWriter extends StorageWriter { } } -const enum Mode { +export const enum Mode { Encrypted = "encrypted", // default AllowPlaintext = "allow-plaintext", ForcePlaintext = "force-plaintext",