diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index f19a8591cb2..d4ae6c897be 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -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; }>; + // Settings + setSettingValue(settingName: string, value: any): Promise; + getSettingValue(settingName: string): Promise; } interface DesktopCapturerSource { diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 913feafba34..1fb14f96f83 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -357,6 +357,12 @@ export default class PreferencesUserSettingsTab extends React.Component + diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 459b4fb188f..da777eacf3e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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)", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index befc13478fa..cba85003099 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -349,6 +349,7 @@ export interface Settings { "Electron.alwaysShowMenuBar": IBaseSetting; "Electron.showTrayIcon": IBaseSetting; "Electron.enableHardwareAcceleration": IBaseSetting; + "Electron.enableContentProtection": IBaseSetting; "mediaPreviewConfig": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; } @@ -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"), diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx index c8b544daf15..2bacbe337d3 100644 --- a/src/vector/platform/ElectronPlatform.tsx +++ b/src/vector/platform/ElectronPlatform.tsx @@ -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; + private readonly electron: Electron; private protocol!: string; private sessionId!: string; private config!: IConfigOptions; + private supportedSettings?: Record; 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({ 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 { - 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 { @@ -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 { - return this.ipc.call("getSettingValue", settingName); + return this.electron.getSettingValue(settingName); } public setSettingValue(settingName: string, value: any): Promise { - return this.ipc.call("setSettingValue", settingName, value); + return this.electron.setSettingValue(settingName, value); } public async canSelfUpdate(): Promise { @@ -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 { diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 8243fe5083e..4fd6d094bfd 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -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(), diff --git a/test/unit-tests/vector/platform/ElectronPlatform-test.ts b/test/unit-tests/vector/platform/ElectronPlatform-test.ts index c4fda4a20f5..40168231ac3 100644 --- a/test/unit-tests/vector/platform/ElectronPlatform-test.ts +++ b/test/unit-tests/vector/platform/ElectronPlatform-test.ts @@ -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; 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); + 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", + }); + }); });