Fix handling of deeplinks on Element Desktop (#33827)

* Fix desktop registering protocol handler wrong

Was previously registering with too many args and was also only handling deeplinks if the app was already open, on a cold start they would be blindly ignored.

Tests aplenty

* Rename field

* Move protocolHandler initialisation to after mainWindow is navigating

* Add comment

* Avoid double call to loadURL
This commit is contained in:
Michael Telatynski
2026-06-17 10:19:37 +00:00
committed by GitHub
parent 1094ab256c
commit 2ae7d90190
6 changed files with 652 additions and 118 deletions
+323
View File
@@ -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",
});
});
});
});
+165
View File
@@ -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["_"],
};
}
+14 -75
View File
@@ -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
+104 -18
View File
@@ -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");
});
});
});
+45 -24
View File
@@ -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<string, string> {
@@ -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 {
+1 -1
View File
@@ -126,7 +126,7 @@ class SafeStorageWriter extends StorageWriter {
}
}
const enum Mode {
export const enum Mode {
Encrypted = "encrypted", // default
AllowPlaintext = "allow-plaintext",
ForcePlaintext = "force-plaintext",