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:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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["_"],
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -126,7 +126,7 @@ class SafeStorageWriter extends StorageWriter {
|
||||
}
|
||||
}
|
||||
|
||||
const enum Mode {
|
||||
export const enum Mode {
|
||||
Encrypted = "encrypted", // default
|
||||
AllowPlaintext = "allow-plaintext",
|
||||
ForcePlaintext = "force-plaintext",
|
||||
|
||||
Reference in New Issue
Block a user