Skip to content

Add ability to prevent window content being captured by other apps (Desktop) #30098

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,19 @@ declare global {
}

interface Electron {
// Legacy
on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void;
send(channel: ElectronChannel, ...args: any[]): void;
// Initialisation
initialise(): Promise<{
protocol: string;
sessionId: string;
config: IConfigOptions;
supportedSettings: Record<string, boolean>;
}>;
// Settings
setSettingValue(settingName: string, value: any): Promise<void>;
getSettingValue(settingName: string): Promise<any>;
}

interface DesktopCapturerSource {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,12 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
appName: SdkConfig.get().brand,
})}
/>
<SettingsFlag
name="Electron.enableContentProtection"
level={SettingLevel.PLATFORM}
hideIfCannotSet
label={_t("settings|preferences|Electron.enableContentProtection")}
/>
<SettingsFlag name="Electron.alwaysShowMenuBar" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag name="Electron.autoLaunch" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag name="Electron.warnBeforeExit" level={SettingLevel.PLATFORM} hideIfCannotSet />
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2810,6 +2810,7 @@
"voip": "Audio and Video calls"
},
"preferences": {
"Electron.enableContentProtection": "Prevent the window contents from being captured by other apps",
"Electron.enableHardwareAcceleration": "Enable hardware acceleration (restart %(appName)s to take effect)",
"always_show_menu_bar": "Always show the window menu bar",
"autocomplete_delay": "Autocomplete delay (ms)",
Expand Down
6 changes: 6 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ export interface Settings {
"Electron.alwaysShowMenuBar": IBaseSetting<boolean>;
"Electron.showTrayIcon": IBaseSetting<boolean>;
"Electron.enableHardwareAcceleration": IBaseSetting<boolean>;
"Electron.enableContentProtection": IBaseSetting<boolean>;
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
"Developer.elementCallUrl": IBaseSetting<string>;
}
Expand Down Expand Up @@ -1383,6 +1384,11 @@ export const SETTINGS: Settings = {
displayName: _td("settings|preferences|enable_hardware_acceleration"),
default: true,
},
"Electron.enableContentProtection": {
supportedLevels: [SettingLevel.PLATFORM],
displayName: _td("settings|preferences|enable_hardware_acceleration"),
default: false,
},
"Developer.elementCallUrl": {
supportedLevels: [SettingLevel.DEVICE],
displayName: _td("devtools|settings|elementCallUrl"),
Expand Down
73 changes: 35 additions & 38 deletions src/vector/platform/ElectronPlatform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,6 @@ interface SquirrelUpdate {

const SSO_ID_KEY = "element-desktop-ssoid";

const isMac = navigator.platform.toUpperCase().includes("MAC");

function platformFriendlyName(): string {
// used to use window.process but the same info is available here
if (navigator.userAgent.includes("Macintosh")) {
Expand All @@ -73,13 +71,6 @@ function platformFriendlyName(): string {
}
}

function onAction(payload: ActionPayload): void {
// Whitelist payload actions, no point sending most across
if (["call_state"].includes(payload.action)) {
window.electron!.send("app_onAction", payload);
}
}

function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
if (status === true) {
return { status: UpdateCheckStatus.Downloading };
Expand All @@ -97,25 +88,27 @@ export default class ElectronPlatform extends BasePlatform {
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
private readonly initialised: Promise<void>;
private readonly electron: Electron;
private protocol!: string;
private sessionId!: string;
private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;

public constructor() {
super();

if (!window.electron) {
throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set");
}
this.electron = window.electron;

dis.register(onAction);
/*
IPC Call `check_updates` returns:
true if there is an update available
false if there is not
or the error if one is encountered
*/
window.electron.on("check_updates", (event, status) => {
this.electron.on("check_updates", (event, status) => {
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
...getUpdateCheckStatus(status),
Expand All @@ -124,44 +117,44 @@ export default class ElectronPlatform extends BasePlatform {

// `userAccessToken` (IPC) is requested by the main process when appending authentication
// to media downloads. A reply is sent over the same channel.
window.electron.on("userAccessToken", () => {
window.electron!.send("userAccessToken", MatrixClientPeg.get()?.getAccessToken());
this.electron.on("userAccessToken", () => {
this.electron.send("userAccessToken", MatrixClientPeg.get()?.getAccessToken());
});

// `homeserverUrl` (IPC) is requested by the main process. A reply is sent over the same channel.
window.electron.on("homeserverUrl", () => {
window.electron!.send("homeserverUrl", MatrixClientPeg.get()?.getHomeserverUrl());
this.electron.on("homeserverUrl", () => {
this.electron.send("homeserverUrl", MatrixClientPeg.get()?.getHomeserverUrl());
});

// `serverSupportedVersions` is requested by the main process when it needs to know if the
// server supports a particular version. This is primarily used to detect authenticated media
// support. A reply is sent over the same channel.
window.electron.on("serverSupportedVersions", async () => {
window.electron!.send("serverSupportedVersions", await MatrixClientPeg.get()?.getVersions());
this.electron.on("serverSupportedVersions", async () => {
this.electron.send("serverSupportedVersions", await MatrixClientPeg.get()?.getVersions());
});

// try to flush the rageshake logs to indexeddb before quit.
window.electron.on("before-quit", function () {
this.electron.on("before-quit", function () {
logger.log("element-desktop closing");
rageshake.flush();
});

window.electron.on("update-downloaded", this.onUpdateDownloaded);
this.electron.on("update-downloaded", this.onUpdateDownloaded);

window.electron.on("preferences", () => {
this.electron.on("preferences", () => {
dis.fire(Action.ViewUserSettings);
});

window.electron.on("userDownloadCompleted", (ev, { id, name }) => {
this.electron.on("userDownloadCompleted", (ev, { id, name }) => {
const key = `DOWNLOAD_TOAST_${id}`;

const onAccept = (): void => {
window.electron!.send("userDownloadAction", { id, open: true });
this.electron.send("userDownloadAction", { id, open: true });
ToastStore.sharedInstance().dismissToast(key);
};

const onDismiss = (): void => {
window.electron!.send("userDownloadAction", { id });
this.electron.send("userDownloadAction", { id });
};

ToastStore.sharedInstance().addOrReplaceToast({
Expand All @@ -180,7 +173,7 @@ export default class ElectronPlatform extends BasePlatform {
});
});

window.electron.on("openDesktopCapturerSourcePicker", async () => {
this.electron.on("openDesktopCapturerSourcePicker", async () => {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
// getDisplayMedia promise does not return if no dummy is passed here as source
Expand All @@ -192,11 +185,20 @@ export default class ElectronPlatform extends BasePlatform {
this.initialised = this.initialise();
}

protected onAction(payload: ActionPayload): void {
super.onAction(payload);
// Whitelist payload actions, no point sending most across
if (["call_state"].includes(payload.action)) {
this.electron.send("app_onAction", payload);
}
}

private async initialise(): Promise<void> {
const { protocol, sessionId, config } = await window.electron!.initialise();
const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
this.protocol = protocol;
this.sessionId = sessionId;
this.config = config;
this.supportedSettings = supportedSettings;
}

public async getConfig(): Promise<IConfigOptions | undefined> {
Expand Down Expand Up @@ -248,7 +250,7 @@ export default class ElectronPlatform extends BasePlatform {
if (this.notificationCount === count) return;
super.setNotificationCount(count);

window.electron!.send("setBadgeCount", count);
this.electron.send("setBadgeCount", count);
}

public supportsNotifications(): boolean {
Expand Down Expand Up @@ -288,7 +290,7 @@ export default class ElectronPlatform extends BasePlatform {
}

public loudNotification(ev: MatrixEvent, room: Room): void {
window.electron!.send("loudNotification");
this.electron.send("loudNotification");
}

public needsUrlTooltips(): boolean {
Expand All @@ -300,21 +302,16 @@ export default class ElectronPlatform extends BasePlatform {
}

public supportsSetting(settingName?: string): boolean {
switch (settingName) {
case "Electron.showTrayIcon": // Things other than Mac support tray icons
case "Electron.alwaysShowMenuBar": // This isn't relevant on Mac as Menu bars don't live in the app window
return !isMac;
default:
return true;
}
if (settingName === undefined) return true;
return this.supportedSettings?.[settingName] === true;
}

public getSettingValue(settingName: string): Promise<any> {
return this.ipc.call("getSettingValue", settingName);
return this.electron.getSettingValue(settingName);
}

public setSettingValue(settingName: string, value: any): Promise<void> {
return this.ipc.call("setSettingValue", settingName, value);
return this.electron.setSettingValue(settingName, value);
}

public async canSelfUpdate(): Promise<boolean> {
Expand All @@ -324,14 +321,14 @@ export default class ElectronPlatform extends BasePlatform {

public startUpdateCheck(): void {
super.startUpdateCheck();
window.electron!.send("check_updates");
this.electron.send("check_updates");
}

public installUpdate(): void {
// IPC to the main process to install the update, since quitAndInstall
// doesn't fire the before-quit event so the main process needs to know
// it should exit.
window.electron!.send("install_update");
this.electron.send("install_update");
}

public getDefaultDeviceDisplayName(): string {
Expand Down
1 change: 1 addition & 0 deletions test/test-utils/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function createTestClient(): MatrixClient {
getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }),
getSessionId: jest.fn().mockReturnValue("iaszphgvfku"),
credentials: { userId: "@userId:matrix.org" },
getAccessToken: jest.fn(),

secretStorage: {
get: jest.fn(),
Expand Down
91 changes: 89 additions & 2 deletions test/unit-tests/vector/platform/ElectronPlatform-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.

import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { mocked, type MockedObject } from "jest-mock";

import { UpdateCheckStatus } from "../../../../src/BasePlatform";
import { Action } from "../../../../src/dispatcher/actions";
Expand All @@ -19,6 +19,7 @@ import Modal from "../../../../src/Modal";
import DesktopCapturerSourcePicker from "../../../../src/components/views/elements/DesktopCapturerSourcePicker";
import ElectronPlatform from "../../../../src/vector/platform/ElectronPlatform";
import { setupLanguageMock } from "../../../setup/setupLanguage";
import { stubClient } from "../../../test-utils";

jest.mock("../../../../src/rageshake/rageshake", () => ({
flush: jest.fn(),
Expand All @@ -35,8 +36,11 @@ describe("ElectronPlatform", () => {
protocol: "io.element.desktop",
sessionId: "session-id",
config: { _config: true },
supportedSettings: { setting1: false, setting2: true },
}),
};
setSettingValue: jest.fn().mockResolvedValue(undefined),
getSettingValue: jest.fn().mockResolvedValue(undefined),
} as unknown as MockedObject<Electron>;

const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const dispatchFireSpy = jest.spyOn(dispatcher, "fire");
Expand Down Expand Up @@ -318,4 +322,87 @@ describe("ElectronPlatform", () => {
);
});
});

describe("authenticated media", () => {
it("should respond to relevant ipc requests", async () => {
const cli = stubClient();
mocked(cli.getAccessToken).mockReturnValue("access_token");
mocked(cli.getHomeserverUrl).mockReturnValue("homeserver_url");
mocked(cli.getVersions).mockResolvedValue({
versions: ["v1.1"],
unstable_features: {},
});

new ElectronPlatform();

const userAccessTokenCall = mockElectron.on.mock.calls.find((call) => call[0] === "userAccessToken");
userAccessTokenCall![1]({} as any);
const userAccessTokenResponse = mockElectron.send.mock.calls.find((call) => call[0] === "userAccessToken");
expect(userAccessTokenResponse![1]).toBe("access_token");

const homeserverUrlCall = mockElectron.on.mock.calls.find((call) => call[0] === "homeserverUrl");
homeserverUrlCall![1]({} as any);
const homeserverUrlResponse = mockElectron.send.mock.calls.find((call) => call[0] === "homeserverUrl");
expect(homeserverUrlResponse![1]).toBe("homeserver_url");

const serverSupportedVersionsCall = mockElectron.on.mock.calls.find(
(call) => call[0] === "serverSupportedVersions",
);
await (serverSupportedVersionsCall![1]({} as any) as unknown as Promise<unknown>);
const serverSupportedVersionsResponse = mockElectron.send.mock.calls.find(
(call) => call[0] === "serverSupportedVersions",
);
expect(serverSupportedVersionsResponse![1]).toEqual({ versions: ["v1.1"], unstable_features: {} });
});
});

describe("settings", () => {
let platform: ElectronPlatform;
beforeAll(async () => {
window.electron = mockElectron;
platform = new ElectronPlatform();
await platform.getConfig(); // await init
});

it("supportsSetting should return true for the platform", () => {
expect(platform.supportsSetting()).toBe(true);
});

it("supportsSetting should return true for available settings", () => {
expect(platform.supportsSetting("setting2")).toBe(true);
});

it("supportsSetting should return false for unavailable settings", () => {
expect(platform.supportsSetting("setting1")).toBe(false);
});

it("should read setting value over ipc", async () => {
mockElectron.getSettingValue.mockResolvedValue("value");
await expect(platform.getSettingValue("setting2")).resolves.toEqual("value");
expect(mockElectron.getSettingValue).toHaveBeenCalledWith("setting2");
});

it("should write setting value over ipc", async () => {
await platform.setSettingValue("setting2", "newValue");
expect(mockElectron.setSettingValue).toHaveBeenCalledWith("setting2", "newValue");
});
});

it("should forward call_state dispatcher events via ipc", async () => {
new ElectronPlatform();

dispatcher.dispatch(
{
action: "call_state",
state: "connected",
},
true,
);

const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "app_onAction");
expect(ipcMessage![1]).toEqual({
action: "call_state",
state: "connected",
});
});
});
Loading