diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index cf9af5befc4..be440333e27 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -40,6 +40,10 @@ import { isSecureBackupRequired } from './utils/WellKnownUtils'; import { ActionPayload } from "./dispatcher/payloads"; import { Action } from "./dispatcher/actions"; import { isLoggedIn } from "./utils/login"; +import SdkConfig from "./SdkConfig"; +import PlatformPeg from "./PlatformPeg"; +import { recordClientInformation } from "./utils/device/clientInformation"; +import SettingsStore, { CallbackFn } from "./settings/SettingsStore"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; @@ -60,6 +64,8 @@ export default class DeviceListener { // The set of device IDs we're currently displaying toasts for private displayingToastsForDeviceIds = new Set(); private running = false; + private shouldRecordClientInformation = false; + private deviceClientInformationSettingWatcherRef: string | undefined; public static sharedInstance() { if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); @@ -76,8 +82,15 @@ export default class DeviceListener { MatrixClientPeg.get().on(ClientEvent.AccountData, this.onAccountData); MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); + this.shouldRecordClientInformation = SettingsStore.getValue('deviceClientInformationOptIn'); + this.deviceClientInformationSettingWatcherRef = SettingsStore.watchSetting( + 'deviceClientInformationOptIn', + null, + this.onRecordClientInformationSettingChange, + ); this.dispatcherRef = dis.register(this.onAction); this.recheck(); + this.recordClientInformation(); } public stop() { @@ -95,6 +108,9 @@ export default class DeviceListener { MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents); } + if (this.deviceClientInformationSettingWatcherRef) { + SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef); + } if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); this.dispatcherRef = null; @@ -200,6 +216,7 @@ export default class DeviceListener { private onAction = ({ action }: ActionPayload) => { if (action !== Action.OnLoggedIn) return; this.recheck(); + this.recordClientInformation(); }; // The server doesn't tell us when key backup is set up, so we poll @@ -343,4 +360,33 @@ export default class DeviceListener { dis.dispatch({ action: Action.ReportKeyBackupNotEnabled }); } }; + + private onRecordClientInformationSettingChange: CallbackFn = ( + _originalSettingName, _roomId, _level, _newLevel, newValue, + ) => { + const prevValue = this.shouldRecordClientInformation; + + this.shouldRecordClientInformation = !!newValue; + + if (this.shouldRecordClientInformation && !prevValue) { + this.recordClientInformation(); + } + }; + + private recordClientInformation = async () => { + if (!this.shouldRecordClientInformation) { + return; + } + try { + await recordClientInformation( + MatrixClientPeg.get(), + SdkConfig.get(), + PlatformPeg.get(), + ); + } catch (error) { + // this is a best effort operation + // log the error without rethrowing + logger.error('Failed to record client information', error); + } + }; } diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 177c3b4f5ea..91b448eb3b0 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -319,6 +319,12 @@ export default class SecurityUserSettingsTab extends React.Component ) } +
+ { _t("Sessions") } + +
; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9bd1dd8124f..4e59a214265 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -955,6 +955,7 @@ "System font name": "System font name", "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)": "Allow Peer-to-Peer for 1:1 calls (if you enable this, the other party might be able to see your IP address)", "Send analytics data": "Send analytics data", + "Record the client name, version, and url to recognise sessions more easily in session manager": "Record the client name, version, and url to recognise sessions more easily in session manager", "Never send encrypted messages to unverified sessions from this session": "Never send encrypted messages to unverified sessions from this session", "Never send encrypted messages to unverified sessions in this room from this session": "Never send encrypted messages to unverified sessions in this room from this session", "Enable inline URL previews by default": "Enable inline URL previews by default", @@ -1569,9 +1570,9 @@ "Okay": "Okay", "Privacy": "Privacy", "Share anonymous data to help us identify issues. Nothing personal. No third parties.": "Share anonymous data to help us identify issues. Nothing personal. No third parties.", + "Sessions": "Sessions", "Where you're signed in": "Where you're signed in", "Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.", - "Sessions": "Sessions", "Other sessions": "Other sessions", "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.", "Sidebar": "Sidebar", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 5220f9d0604..d62e3c44916 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -739,6 +739,14 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td('Send analytics data'), default: null, }, + "deviceClientInformationOptIn": { + supportedLevels: [SettingLevel.ACCOUNT], + displayName: _td( + `Record the client name, version, and url ` + + `to recognise sessions more easily in session manager`, + ), + default: false, + }, "FTUE.useCaseSelection": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts new file mode 100644 index 00000000000..32445334f5a --- /dev/null +++ b/src/utils/device/clientInformation.ts @@ -0,0 +1,60 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import BasePlatform from "../../BasePlatform"; +import { IConfigOptions } from "../../IConfigOptions"; + +const formatUrl = (): string | undefined => { + // don't record url for electron clients + if (window.electron) { + return undefined; + } + + // strip query-string and fragment from uri + const url = new URL(window.location.href); + + return [ + url.host, + url.pathname.replace(/\/$/, ""), // Remove trailing slash if present + ].join(""); +}; + +const getClientInformationEventType = (deviceId: string): string => + `io.element.matrix_client_information.${deviceId}`; + +/** + * Record extra client information for the current device + * https://github.com/vector-im/element-meta/blob/develop/spec/matrix_client_information.md + */ +export const recordClientInformation = async ( + matrixClient: MatrixClient, + sdkConfig: IConfigOptions, + platform: BasePlatform, +): Promise => { + const deviceId = matrixClient.getDeviceId(); + const { brand } = sdkConfig; + const version = await platform.getAppVersion(); + const type = getClientInformationEventType(deviceId); + const url = formatUrl(); + + await matrixClient.setAccountData(type, { + name: brand, + version, + url, + }); +}; diff --git a/test/DeviceListener-test.ts b/test/DeviceListener-test.ts index 06405674416..46f2abcd28d 100644 --- a/test/DeviceListener-test.ts +++ b/test/DeviceListener-test.ts @@ -18,6 +18,7 @@ limitations under the License. import { EventEmitter } from "events"; import { mocked } from "jest-mock"; import { Room } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; import DeviceListener from "../src/DeviceListener"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; @@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio import { isSecretStorageBeingAccessed } from "../src/SecurityManager"; import dis from "../src/dispatcher/dispatcher"; import { Action } from "../src/dispatcher/actions"; +import SettingsStore from "../src/settings/SettingsStore"; +import { mockPlatformPeg } from "./test-utils"; +import { SettingLevel } from "../src/settings/SettingLevel"; // don't litter test console with logs jest.mock("matrix-js-sdk/src/logger"); @@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({ isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), })); +const deviceId = 'my-device-id'; + class MockClient extends EventEmitter { + isGuest = jest.fn(); getUserId = jest.fn(); getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); getRooms = jest.fn().mockReturnValue([]); @@ -57,6 +64,8 @@ class MockClient extends EventEmitter { downloadKeys = jest.fn(); isRoomEncrypted = jest.fn(); getClientWellKnown = jest.fn(); + getDeviceId = jest.fn().mockReturnValue(deviceId); + setAccountData = jest.fn(); } const mockDispatcher = mocked(dis); const flushPromises = async () => await new Promise(process.nextTick); @@ -75,8 +84,12 @@ describe('DeviceListener', () => { beforeEach(() => { jest.resetAllMocks(); + mockPlatformPeg({ + getAppVersion: jest.fn().mockResolvedValue('1.2.3'), + }); mockClient = new MockClient(); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); }); const createAndStart = async (): Promise => { @@ -86,6 +99,115 @@ describe('DeviceListener', () => { return instance; }; + describe('client information', () => { + it('watches device client information setting', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting'); + const deviceListener = await createAndStart(); + + expect(watchSettingSpy).toHaveBeenCalledWith( + 'deviceClientInformationOptIn', null, expect.any(Function), + ); + + deviceListener.stop(); + + expect(unwatchSettingSpy).toHaveBeenCalled(); + }); + + describe('when device client information feature is enabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockImplementation( + settingName => settingName === 'deviceClientInformationOptIn', + ); + }); + it('saves client information on start', async () => { + await createAndStart(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('catches error and logs when saving client information fails', async () => { + const errorLogSpy = jest.spyOn(logger, 'error'); + const error = new Error('oups'); + mockClient.setAccountData.mockRejectedValue(error); + + // doesn't throw + await createAndStart(); + + expect(errorLogSpy).toHaveBeenCalledWith( + 'Failed to record client information', + error, + ); + }); + + it('saves client information on logged in action', async () => { + const instance = await createAndStart(); + + mockClient.setAccountData.mockClear(); + + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); + + await flushPromises(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + }); + + describe('when device client information feature is disabled', () => { + beforeEach(() => { + jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false); + }); + + it('does not save client information on start', async () => { + await createAndStart(); + + expect(mockClient.setAccountData).not.toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('does not save client information on logged in action', async () => { + const instance = await createAndStart(); + + // @ts-ignore calling private function + instance.onAction({ action: Action.OnLoggedIn }); + + await flushPromises(); + + expect(mockClient.setAccountData).not.toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + + it('saves client information after setting is enabled', async () => { + const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting'); + await createAndStart(); + + const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0]; + expect(settingName).toEqual('deviceClientInformationOptIn'); + expect(roomId).toBeNull(); + + callback('deviceClientInformationOptIn', null, SettingLevel.DEVICE, SettingLevel.DEVICE, true); + + await flushPromises(); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { name: 'Element', url: 'localhost', version: '1.2.3' }, + ); + }); + }); + }); + describe('recheck', () => { it('does nothing when cross signing feature is not supported', async () => { mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts new file mode 100644 index 00000000000..628c9729d14 --- /dev/null +++ b/test/utils/device/clientInformation-test.ts @@ -0,0 +1,86 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import BasePlatform from "../../../src/BasePlatform"; +import { IConfigOptions } from "../../../src/IConfigOptions"; +import { recordClientInformation } from "../../../src/utils/device/clientInformation"; +import { getMockClientWithEventEmitter } from "../../test-utils"; + +describe('recordClientInformation()', () => { + const deviceId = 'my-device-id'; + const version = '1.2.3'; + const isElectron = window.electron; + + const mockClient = getMockClientWithEventEmitter({ + getDeviceId: jest.fn().mockReturnValue(deviceId), + setAccountData: jest.fn(), + }); + + const sdkConfig: IConfigOptions = { + brand: 'Test Brand', + element_call: { url: '', use_exclusively: false }, + }; + + const platform = { + getAppVersion: jest.fn().mockResolvedValue(version), + } as unknown as BasePlatform; + + beforeEach(() => { + jest.clearAllMocks(); + window.electron = false; + }); + + afterAll(() => { + // restore global + window.electron = isElectron; + }); + + it('saves client information without url for electron clients', async () => { + window.electron = true; + + await recordClientInformation( + mockClient, + sdkConfig, + platform, + ); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { + name: sdkConfig.brand, + version, + url: undefined, + }, + ); + }); + + it('saves client information with url for non-electron clients', async () => { + await recordClientInformation( + mockClient, + sdkConfig, + platform, + ); + + expect(mockClient.setAccountData).toHaveBeenCalledWith( + `io.element.matrix_client_information.${deviceId}`, + { + name: sdkConfig.brand, + version, + url: 'localhost', + }, + ); + }); +});