Skip to content

Commit 2b24232

Browse files
authored
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 <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Increase coverage Signed-off-by: Michael Telatynski <[email protected]> * Increase coverage Signed-off-by: Michael Telatynski <[email protected]> * Improve coverage Signed-off-by: Michael Telatynski <[email protected]> * Delint Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent 3e8599b commit 2b24232

File tree

7 files changed

+144
-40
lines changed

7 files changed

+144
-40
lines changed

src/@types/global.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,19 @@ declare global {
128128
}
129129

130130
interface Electron {
131+
// Legacy
131132
on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void;
132133
send(channel: ElectronChannel, ...args: any[]): void;
134+
// Initialisation
133135
initialise(): Promise<{
134136
protocol: string;
135137
sessionId: string;
136138
config: IConfigOptions;
139+
supportedSettings: Record<string, boolean>;
137140
}>;
141+
// Settings
142+
setSettingValue(settingName: string, value: any): Promise<void>;
143+
getSettingValue(settingName: string): Promise<any>;
138144
}
139145

140146
interface DesktopCapturerSource {

src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
357357
appName: SdkConfig.get().brand,
358358
})}
359359
/>
360+
<SettingsFlag
361+
name="Electron.enableContentProtection"
362+
level={SettingLevel.PLATFORM}
363+
hideIfCannotSet
364+
label={_t("settings|preferences|Electron.enableContentProtection")}
365+
/>
360366
<SettingsFlag name="Electron.alwaysShowMenuBar" level={SettingLevel.PLATFORM} hideIfCannotSet />
361367
<SettingsFlag name="Electron.autoLaunch" level={SettingLevel.PLATFORM} hideIfCannotSet />
362368
<SettingsFlag name="Electron.warnBeforeExit" level={SettingLevel.PLATFORM} hideIfCannotSet />

src/i18n/strings/en_EN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2808,6 +2808,7 @@
28082808
"voip": "Audio and Video calls"
28092809
},
28102810
"preferences": {
2811+
"Electron.enableContentProtection": "Prevent the window contents from being captured by other apps",
28112812
"Electron.enableHardwareAcceleration": "Enable hardware acceleration (restart %(appName)s to take effect)",
28122813
"always_show_menu_bar": "Always show the window menu bar",
28132814
"autocomplete_delay": "Autocomplete delay (ms)",

src/settings/Settings.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ export interface Settings {
349349
"Electron.alwaysShowMenuBar": IBaseSetting<boolean>;
350350
"Electron.showTrayIcon": IBaseSetting<boolean>;
351351
"Electron.enableHardwareAcceleration": IBaseSetting<boolean>;
352+
"Electron.enableContentProtection": IBaseSetting<boolean>;
352353
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
353354
"Developer.elementCallUrl": IBaseSetting<string>;
354355
}
@@ -1383,6 +1384,11 @@ export const SETTINGS: Settings = {
13831384
displayName: _td("settings|preferences|enable_hardware_acceleration"),
13841385
default: true,
13851386
},
1387+
"Electron.enableContentProtection": {
1388+
supportedLevels: [SettingLevel.PLATFORM],
1389+
displayName: _td("settings|preferences|enable_hardware_acceleration"),
1390+
default: false,
1391+
},
13861392
"Developer.elementCallUrl": {
13871393
supportedLevels: [SettingLevel.DEVICE],
13881394
displayName: _td("devtools|settings|elementCallUrl"),

src/vector/platform/ElectronPlatform.tsx

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ interface SquirrelUpdate {
5252

5353
const SSO_ID_KEY = "element-desktop-ssoid";
5454

55-
const isMac = navigator.platform.toUpperCase().includes("MAC");
56-
5755
function platformFriendlyName(): string {
5856
// used to use window.process but the same info is available here
5957
if (navigator.userAgent.includes("Macintosh")) {
@@ -73,13 +71,6 @@ function platformFriendlyName(): string {
7371
}
7472
}
7573

76-
function onAction(payload: ActionPayload): void {
77-
// Whitelist payload actions, no point sending most across
78-
if (["call_state"].includes(payload.action)) {
79-
window.electron!.send("app_onAction", payload);
80-
}
81-
}
82-
8374
function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
8475
if (status === true) {
8576
return { status: UpdateCheckStatus.Downloading };
@@ -97,25 +88,27 @@ export default class ElectronPlatform extends BasePlatform {
9788
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
9889
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
9990
private readonly initialised: Promise<void>;
91+
private readonly electron: Electron;
10092
private protocol!: string;
10193
private sessionId!: string;
10294
private config!: IConfigOptions;
95+
private supportedSettings?: Record<string, boolean>;
10396

10497
public constructor() {
10598
super();
10699

107100
if (!window.electron) {
108101
throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set");
109102
}
103+
this.electron = window.electron;
110104

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

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

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

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

143136
// try to flush the rageshake logs to indexeddb before quit.
144-
window.electron.on("before-quit", function () {
137+
this.electron.on("before-quit", function () {
145138
logger.log("element-desktop closing");
146139
rageshake.flush();
147140
});
148141

149-
window.electron.on("update-downloaded", this.onUpdateDownloaded);
142+
this.electron.on("update-downloaded", this.onUpdateDownloaded);
150143

151-
window.electron.on("preferences", () => {
144+
this.electron.on("preferences", () => {
152145
dis.fire(Action.ViewUserSettings);
153146
});
154147

155-
window.electron.on("userDownloadCompleted", (ev, { id, name }) => {
148+
this.electron.on("userDownloadCompleted", (ev, { id, name }) => {
156149
const key = `DOWNLOAD_TOAST_${id}`;
157150

158151
const onAccept = (): void => {
159-
window.electron!.send("userDownloadAction", { id, open: true });
152+
this.electron.send("userDownloadAction", { id, open: true });
160153
ToastStore.sharedInstance().dismissToast(key);
161154
};
162155

163156
const onDismiss = (): void => {
164-
window.electron!.send("userDownloadAction", { id });
157+
this.electron.send("userDownloadAction", { id });
165158
};
166159

167160
ToastStore.sharedInstance().addOrReplaceToast({
@@ -180,7 +173,7 @@ export default class ElectronPlatform extends BasePlatform {
180173
});
181174
});
182175

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

188+
protected onAction(payload: ActionPayload): void {
189+
super.onAction(payload);
190+
// Whitelist payload actions, no point sending most across
191+
if (["call_state"].includes(payload.action)) {
192+
this.electron.send("app_onAction", payload);
193+
}
194+
}
195+
195196
private async initialise(): Promise<void> {
196-
const { protocol, sessionId, config } = await window.electron!.initialise();
197+
const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
197198
this.protocol = protocol;
198199
this.sessionId = sessionId;
199200
this.config = config;
201+
this.supportedSettings = supportedSettings;
200202
}
201203

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

251-
window.electron!.send("setBadgeCount", count);
253+
this.electron.send("setBadgeCount", count);
252254
}
253255

254256
public supportsNotifications(): boolean {
@@ -288,7 +290,7 @@ export default class ElectronPlatform extends BasePlatform {
288290
}
289291

290292
public loudNotification(ev: MatrixEvent, room: Room): void {
291-
window.electron!.send("loudNotification");
293+
this.electron.send("loudNotification");
292294
}
293295

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

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

312309
public getSettingValue(settingName: string): Promise<any> {
313-
return this.ipc.call("getSettingValue", settingName);
310+
return this.electron.getSettingValue(settingName);
314311
}
315312

316313
public setSettingValue(settingName: string, value: any): Promise<void> {
317-
return this.ipc.call("setSettingValue", settingName, value);
314+
return this.electron.setSettingValue(settingName, value);
318315
}
319316

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

325322
public startUpdateCheck(): void {
326323
super.startUpdateCheck();
327-
window.electron!.send("check_updates");
324+
this.electron.send("check_updates");
328325
}
329326

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

337334
public getDefaultDeviceDisplayName(): string {

test/test-utils/test-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export function createTestClient(): MatrixClient {
9999
getDevices: jest.fn().mockResolvedValue({ devices: [{ device_id: "ABCDEFGHI" }] }),
100100
getSessionId: jest.fn().mockReturnValue("iaszphgvfku"),
101101
credentials: { userId: "@userId:matrix.org" },
102+
getAccessToken: jest.fn(),
102103

103104
secretStorage: {
104105
get: jest.fn(),

test/unit-tests/vector/platform/ElectronPlatform-test.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details.
88

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

1313
import { UpdateCheckStatus } from "../../../../src/BasePlatform";
1414
import { Action } from "../../../../src/dispatcher/actions";
@@ -19,6 +19,7 @@ import Modal from "../../../../src/Modal";
1919
import DesktopCapturerSourcePicker from "../../../../src/components/views/elements/DesktopCapturerSourcePicker";
2020
import ElectronPlatform from "../../../../src/vector/platform/ElectronPlatform";
2121
import { setupLanguageMock } from "../../../setup/setupLanguage";
22+
import { stubClient } from "../../../test-utils";
2223

2324
jest.mock("../../../../src/rageshake/rageshake", () => ({
2425
flush: jest.fn(),
@@ -35,8 +36,11 @@ describe("ElectronPlatform", () => {
3536
protocol: "io.element.desktop",
3637
sessionId: "session-id",
3738
config: { _config: true },
39+
supportedSettings: { setting1: false, setting2: true },
3840
}),
39-
};
41+
setSettingValue: jest.fn().mockResolvedValue(undefined),
42+
getSettingValue: jest.fn().mockResolvedValue(undefined),
43+
} as unknown as MockedObject<Electron>;
4044

4145
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
4246
const dispatchFireSpy = jest.spyOn(dispatcher, "fire");
@@ -318,4 +322,87 @@ describe("ElectronPlatform", () => {
318322
);
319323
});
320324
});
325+
326+
describe("authenticated media", () => {
327+
it("should respond to relevant ipc requests", async () => {
328+
const cli = stubClient();
329+
mocked(cli.getAccessToken).mockReturnValue("access_token");
330+
mocked(cli.getHomeserverUrl).mockReturnValue("homeserver_url");
331+
mocked(cli.getVersions).mockResolvedValue({
332+
versions: ["v1.1"],
333+
unstable_features: {},
334+
});
335+
336+
new ElectronPlatform();
337+
338+
const userAccessTokenCall = mockElectron.on.mock.calls.find((call) => call[0] === "userAccessToken");
339+
userAccessTokenCall![1]({} as any);
340+
const userAccessTokenResponse = mockElectron.send.mock.calls.find((call) => call[0] === "userAccessToken");
341+
expect(userAccessTokenResponse![1]).toBe("access_token");
342+
343+
const homeserverUrlCall = mockElectron.on.mock.calls.find((call) => call[0] === "homeserverUrl");
344+
homeserverUrlCall![1]({} as any);
345+
const homeserverUrlResponse = mockElectron.send.mock.calls.find((call) => call[0] === "homeserverUrl");
346+
expect(homeserverUrlResponse![1]).toBe("homeserver_url");
347+
348+
const serverSupportedVersionsCall = mockElectron.on.mock.calls.find(
349+
(call) => call[0] === "serverSupportedVersions",
350+
);
351+
await (serverSupportedVersionsCall![1]({} as any) as unknown as Promise<unknown>);
352+
const serverSupportedVersionsResponse = mockElectron.send.mock.calls.find(
353+
(call) => call[0] === "serverSupportedVersions",
354+
);
355+
expect(serverSupportedVersionsResponse![1]).toEqual({ versions: ["v1.1"], unstable_features: {} });
356+
});
357+
});
358+
359+
describe("settings", () => {
360+
let platform: ElectronPlatform;
361+
beforeAll(async () => {
362+
window.electron = mockElectron;
363+
platform = new ElectronPlatform();
364+
await platform.getConfig(); // await init
365+
});
366+
367+
it("supportsSetting should return true for the platform", () => {
368+
expect(platform.supportsSetting()).toBe(true);
369+
});
370+
371+
it("supportsSetting should return true for available settings", () => {
372+
expect(platform.supportsSetting("setting2")).toBe(true);
373+
});
374+
375+
it("supportsSetting should return false for unavailable settings", () => {
376+
expect(platform.supportsSetting("setting1")).toBe(false);
377+
});
378+
379+
it("should read setting value over ipc", async () => {
380+
mockElectron.getSettingValue.mockResolvedValue("value");
381+
await expect(platform.getSettingValue("setting2")).resolves.toEqual("value");
382+
expect(mockElectron.getSettingValue).toHaveBeenCalledWith("setting2");
383+
});
384+
385+
it("should write setting value over ipc", async () => {
386+
await platform.setSettingValue("setting2", "newValue");
387+
expect(mockElectron.setSettingValue).toHaveBeenCalledWith("setting2", "newValue");
388+
});
389+
});
390+
391+
it("should forward call_state dispatcher events via ipc", async () => {
392+
new ElectronPlatform();
393+
394+
dispatcher.dispatch(
395+
{
396+
action: "call_state",
397+
state: "connected",
398+
},
399+
true,
400+
);
401+
402+
const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "app_onAction");
403+
expect(ipcMessage![1]).toEqual({
404+
action: "call_state",
405+
state: "connected",
406+
});
407+
});
321408
});

0 commit comments

Comments
 (0)