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

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

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Increase coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Increase coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve coverage

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Delint

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2025-06-10 08:41:23 +01:00 committed by GitHub
parent 3e8599bba0
commit 2b24232f14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 144 additions and 40 deletions

View File

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

View File

@ -357,6 +357,12 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
appName: SdkConfig.get().brand, 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.alwaysShowMenuBar" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag name="Electron.autoLaunch" level={SettingLevel.PLATFORM} hideIfCannotSet /> <SettingsFlag name="Electron.autoLaunch" level={SettingLevel.PLATFORM} hideIfCannotSet />
<SettingsFlag name="Electron.warnBeforeExit" level={SettingLevel.PLATFORM} hideIfCannotSet /> <SettingsFlag name="Electron.warnBeforeExit" level={SettingLevel.PLATFORM} hideIfCannotSet />

View File

@ -2808,6 +2808,7 @@
"voip": "Audio and Video calls" "voip": "Audio and Video calls"
}, },
"preferences": { "preferences": {
"Electron.enableContentProtection": "Prevent the window contents from being captured by other apps",
"Electron.enableHardwareAcceleration": "Enable hardware acceleration (restart %(appName)s to take effect)", "Electron.enableHardwareAcceleration": "Enable hardware acceleration (restart %(appName)s to take effect)",
"always_show_menu_bar": "Always show the window menu bar", "always_show_menu_bar": "Always show the window menu bar",
"autocomplete_delay": "Autocomplete delay (ms)", "autocomplete_delay": "Autocomplete delay (ms)",

View File

@ -349,6 +349,7 @@ export interface Settings {
"Electron.alwaysShowMenuBar": IBaseSetting<boolean>; "Electron.alwaysShowMenuBar": IBaseSetting<boolean>;
"Electron.showTrayIcon": IBaseSetting<boolean>; "Electron.showTrayIcon": IBaseSetting<boolean>;
"Electron.enableHardwareAcceleration": IBaseSetting<boolean>; "Electron.enableHardwareAcceleration": IBaseSetting<boolean>;
"Electron.enableContentProtection": IBaseSetting<boolean>;
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>; "mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
"Developer.elementCallUrl": IBaseSetting<string>; "Developer.elementCallUrl": IBaseSetting<string>;
} }
@ -1383,6 +1384,11 @@ export const SETTINGS: Settings = {
displayName: _td("settings|preferences|enable_hardware_acceleration"), displayName: _td("settings|preferences|enable_hardware_acceleration"),
default: true, default: true,
}, },
"Electron.enableContentProtection": {
supportedLevels: [SettingLevel.PLATFORM],
displayName: _td("settings|preferences|enable_hardware_acceleration"),
default: false,
},
"Developer.elementCallUrl": { "Developer.elementCallUrl": {
supportedLevels: [SettingLevel.DEVICE], supportedLevels: [SettingLevel.DEVICE],
displayName: _td("devtools|settings|elementCallUrl"), displayName: _td("devtools|settings|elementCallUrl"),

View File

@ -52,8 +52,6 @@ interface SquirrelUpdate {
const SSO_ID_KEY = "element-desktop-ssoid"; const SSO_ID_KEY = "element-desktop-ssoid";
const isMac = navigator.platform.toUpperCase().includes("MAC");
function platformFriendlyName(): string { function platformFriendlyName(): string {
// used to use window.process but the same info is available here // used to use window.process but the same info is available here
if (navigator.userAgent.includes("Macintosh")) { if (navigator.userAgent.includes("Macintosh")) {
@ -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 { function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
if (status === true) { if (status === true) {
return { status: UpdateCheckStatus.Downloading }; return { status: UpdateCheckStatus.Downloading };
@ -97,9 +88,11 @@ export default class ElectronPlatform extends BasePlatform {
private readonly ipc = new IPCManager("ipcCall", "ipcReply"); private readonly ipc = new IPCManager("ipcCall", "ipcReply");
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager(); private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
private readonly initialised: Promise<void>; private readonly initialised: Promise<void>;
private readonly electron: Electron;
private protocol!: string; private protocol!: string;
private sessionId!: string; private sessionId!: string;
private config!: IConfigOptions; private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;
public constructor() { public constructor() {
super(); super();
@ -107,15 +100,15 @@ export default class ElectronPlatform extends BasePlatform {
if (!window.electron) { if (!window.electron) {
throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set"); throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set");
} }
this.electron = window.electron;
dis.register(onAction);
/* /*
IPC Call `check_updates` returns: IPC Call `check_updates` returns:
true if there is an update available true if there is an update available
false if there is not false if there is not
or the error if one is encountered or the error if one is encountered
*/ */
window.electron.on("check_updates", (event, status) => { this.electron.on("check_updates", (event, status) => {
dis.dispatch<CheckUpdatesPayload>({ dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates, action: Action.CheckUpdates,
...getUpdateCheckStatus(status), ...getUpdateCheckStatus(status),
@ -124,44 +117,44 @@ export default class ElectronPlatform extends BasePlatform {
// `userAccessToken` (IPC) is requested by the main process when appending authentication // `userAccessToken` (IPC) is requested by the main process when appending authentication
// to media downloads. A reply is sent over the same channel. // to media downloads. A reply is sent over the same channel.
window.electron.on("userAccessToken", () => { this.electron.on("userAccessToken", () => {
window.electron!.send("userAccessToken", MatrixClientPeg.get()?.getAccessToken()); this.electron.send("userAccessToken", MatrixClientPeg.get()?.getAccessToken());
}); });
// `homeserverUrl` (IPC) is requested by the main process. A reply is sent over the same channel. // `homeserverUrl` (IPC) is requested by the main process. A reply is sent over the same channel.
window.electron.on("homeserverUrl", () => { this.electron.on("homeserverUrl", () => {
window.electron!.send("homeserverUrl", MatrixClientPeg.get()?.getHomeserverUrl()); this.electron.send("homeserverUrl", MatrixClientPeg.get()?.getHomeserverUrl());
}); });
// `serverSupportedVersions` is requested by the main process when it needs to know if the // `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 // server supports a particular version. This is primarily used to detect authenticated media
// support. A reply is sent over the same channel. // support. A reply is sent over the same channel.
window.electron.on("serverSupportedVersions", async () => { this.electron.on("serverSupportedVersions", async () => {
window.electron!.send("serverSupportedVersions", await MatrixClientPeg.get()?.getVersions()); this.electron.send("serverSupportedVersions", await MatrixClientPeg.get()?.getVersions());
}); });
// try to flush the rageshake logs to indexeddb before quit. // 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"); logger.log("element-desktop closing");
rageshake.flush(); 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); dis.fire(Action.ViewUserSettings);
}); });
window.electron.on("userDownloadCompleted", (ev, { id, name }) => { this.electron.on("userDownloadCompleted", (ev, { id, name }) => {
const key = `DOWNLOAD_TOAST_${id}`; const key = `DOWNLOAD_TOAST_${id}`;
const onAccept = (): void => { const onAccept = (): void => {
window.electron!.send("userDownloadAction", { id, open: true }); this.electron.send("userDownloadAction", { id, open: true });
ToastStore.sharedInstance().dismissToast(key); ToastStore.sharedInstance().dismissToast(key);
}; };
const onDismiss = (): void => { const onDismiss = (): void => {
window.electron!.send("userDownloadAction", { id }); this.electron.send("userDownloadAction", { id });
}; };
ToastStore.sharedInstance().addOrReplaceToast({ ToastStore.sharedInstance().addOrReplaceToast({
@ -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 { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
// getDisplayMedia promise does not return if no dummy is passed here as source // getDisplayMedia promise does not return if no dummy is passed here as source
@ -192,11 +185,20 @@ export default class ElectronPlatform extends BasePlatform {
this.initialised = this.initialise(); 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> { 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.protocol = protocol;
this.sessionId = sessionId; this.sessionId = sessionId;
this.config = config; this.config = config;
this.supportedSettings = supportedSettings;
} }
public async getConfig(): Promise<IConfigOptions | undefined> { public async getConfig(): Promise<IConfigOptions | undefined> {
@ -248,7 +250,7 @@ export default class ElectronPlatform extends BasePlatform {
if (this.notificationCount === count) return; if (this.notificationCount === count) return;
super.setNotificationCount(count); super.setNotificationCount(count);
window.electron!.send("setBadgeCount", count); this.electron.send("setBadgeCount", count);
} }
public supportsNotifications(): boolean { public supportsNotifications(): boolean {
@ -288,7 +290,7 @@ export default class ElectronPlatform extends BasePlatform {
} }
public loudNotification(ev: MatrixEvent, room: Room): void { public loudNotification(ev: MatrixEvent, room: Room): void {
window.electron!.send("loudNotification"); this.electron.send("loudNotification");
} }
public needsUrlTooltips(): boolean { public needsUrlTooltips(): boolean {
@ -300,21 +302,16 @@ export default class ElectronPlatform extends BasePlatform {
} }
public supportsSetting(settingName?: string): boolean { public supportsSetting(settingName?: string): boolean {
switch (settingName) { if (settingName === undefined) return true;
case "Electron.showTrayIcon": // Things other than Mac support tray icons return this.supportedSettings?.[settingName] === true;
case "Electron.alwaysShowMenuBar": // This isn't relevant on Mac as Menu bars don't live in the app window
return !isMac;
default:
return true;
}
} }
public getSettingValue(settingName: string): Promise<any> { 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> { 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> { public async canSelfUpdate(): Promise<boolean> {
@ -324,14 +321,14 @@ export default class ElectronPlatform extends BasePlatform {
public startUpdateCheck(): void { public startUpdateCheck(): void {
super.startUpdateCheck(); super.startUpdateCheck();
window.electron!.send("check_updates"); this.electron.send("check_updates");
} }
public installUpdate(): void { public installUpdate(): void {
// IPC to the main process to install the update, since quitAndInstall // 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 // doesn't fire the before-quit event so the main process needs to know
// it should exit. // it should exit.
window.electron!.send("install_update"); this.electron.send("install_update");
} }
public getDefaultDeviceDisplayName(): string { public getDefaultDeviceDisplayName(): string {

View File

@ -99,6 +99,7 @@ export function createTestClient(): MatrixClient {
getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }), getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }),
getSessionId: jest.fn().mockReturnValue("iaszphgvfku"), getSessionId: jest.fn().mockReturnValue("iaszphgvfku"),
credentials: { userId: "@userId:matrix.org" }, credentials: { userId: "@userId:matrix.org" },
getAccessToken: jest.fn(),
secretStorage: { secretStorage: {
get: jest.fn(), get: jest.fn(),

View File

@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; 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 { UpdateCheckStatus } from "../../../../src/BasePlatform";
import { Action } from "../../../../src/dispatcher/actions"; import { Action } from "../../../../src/dispatcher/actions";
@ -19,6 +19,7 @@ import Modal from "../../../../src/Modal";
import DesktopCapturerSourcePicker from "../../../../src/components/views/elements/DesktopCapturerSourcePicker"; import DesktopCapturerSourcePicker from "../../../../src/components/views/elements/DesktopCapturerSourcePicker";
import ElectronPlatform from "../../../../src/vector/platform/ElectronPlatform"; import ElectronPlatform from "../../../../src/vector/platform/ElectronPlatform";
import { setupLanguageMock } from "../../../setup/setupLanguage"; import { setupLanguageMock } from "../../../setup/setupLanguage";
import { stubClient } from "../../../test-utils";
jest.mock("../../../../src/rageshake/rageshake", () => ({ jest.mock("../../../../src/rageshake/rageshake", () => ({
flush: jest.fn(), flush: jest.fn(),
@ -35,8 +36,11 @@ describe("ElectronPlatform", () => {
protocol: "io.element.desktop", protocol: "io.element.desktop",
sessionId: "session-id", sessionId: "session-id",
config: { _config: true }, 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 dispatchSpy = jest.spyOn(dispatcher, "dispatch");
const dispatchFireSpy = jest.spyOn(dispatcher, "fire"); const dispatchFireSpy = jest.spyOn(dispatcher, "fire");
@ -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",
});
});
}); });