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 {
// 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 {

View File

@ -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 />

View File

@ -2808,6 +2808,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)",

View File

@ -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>;
}
@ -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"),

View File

@ -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")) {
@ -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 };
@ -97,9 +88,11 @@ 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();
@ -107,15 +100,15 @@ export default class ElectronPlatform extends BasePlatform {
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),
@ -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({
@ -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
@ -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> {
@ -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 {
@ -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 {
@ -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> {
@ -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 {

View File

@ -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(),

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 { 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";
@ -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(),
@ -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");
@ -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",
});
});
});