Device manager - record device client information on app start (PSG-633) (#9314)

* record device client inforamtion events on app start

* matrix-client-information -> matrix_client_information

* fix types

* remove another unused export

* add docs link

* add opt in setting for recording device information
pull/28217/head
Kerry 2022-10-04 09:53:23 +02:00 committed by GitHub
parent bb2f4fb5e6
commit 0ded5e0505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 330 additions and 1 deletions

View File

@ -40,6 +40,10 @@ import { isSecureBackupRequired } from './utils/WellKnownUtils';
import { ActionPayload } from "./dispatcher/payloads"; import { ActionPayload } from "./dispatcher/payloads";
import { Action } from "./dispatcher/actions"; import { Action } from "./dispatcher/actions";
import { isLoggedIn } from "./utils/login"; 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; 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 // The set of device IDs we're currently displaying toasts for
private displayingToastsForDeviceIds = new Set<string>(); private displayingToastsForDeviceIds = new Set<string>();
private running = false; private running = false;
private shouldRecordClientInformation = false;
private deviceClientInformationSettingWatcherRef: string | undefined;
public static sharedInstance() { public static sharedInstance() {
if (!window.mxDeviceListener) window.mxDeviceListener = new DeviceListener(); 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.AccountData, this.onAccountData);
MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().on(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); 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.dispatcherRef = dis.register(this.onAction);
this.recheck(); this.recheck();
this.recordClientInformation();
} }
public stop() { public stop() {
@ -95,6 +108,9 @@ export default class DeviceListener {
MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync); MatrixClientPeg.get().removeListener(ClientEvent.Sync, this.onSync);
MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents); MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
} }
if (this.deviceClientInformationSettingWatcherRef) {
SettingsStore.unwatchSetting(this.deviceClientInformationSettingWatcherRef);
}
if (this.dispatcherRef) { if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this.dispatcherRef = null; this.dispatcherRef = null;
@ -200,6 +216,7 @@ export default class DeviceListener {
private onAction = ({ action }: ActionPayload) => { private onAction = ({ action }: ActionPayload) => {
if (action !== Action.OnLoggedIn) return; if (action !== Action.OnLoggedIn) return;
this.recheck(); this.recheck();
this.recordClientInformation();
}; };
// The server doesn't tell us when key backup is set up, so we poll // 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 }); 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);
}
};
} }

View File

@ -319,6 +319,12 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
level={SettingLevel.ACCOUNT} /> level={SettingLevel.ACCOUNT} />
) } ) }
</div> </div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Sessions") }</span>
<SettingsFlag
name="deviceClientInformationOptIn"
level={SettingLevel.ACCOUNT} />
</div>
</React.Fragment>; </React.Fragment>;
} }

View File

@ -955,6 +955,7 @@
"System font name": "System font name", "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)", "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", "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 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", "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", "Enable inline URL previews by default": "Enable inline URL previews by default",
@ -1569,9 +1570,9 @@
"Okay": "Okay", "Okay": "Okay",
"Privacy": "Privacy", "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.", "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", "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.", "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", "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.", "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", "Sidebar": "Sidebar",

View File

@ -740,6 +740,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td('Send analytics data'), displayName: _td('Send analytics data'),
default: null, 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": { "FTUE.useCaseSelection": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null, default: null,

View File

@ -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<void> => {
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,
});
};

View File

@ -18,6 +18,7 @@ limitations under the License.
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import DeviceListener from "../src/DeviceListener"; import DeviceListener from "../src/DeviceListener";
import { MatrixClientPeg } from "../src/MatrixClientPeg"; import { MatrixClientPeg } from "../src/MatrixClientPeg";
@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio
import { isSecretStorageBeingAccessed } from "../src/SecurityManager"; import { isSecretStorageBeingAccessed } from "../src/SecurityManager";
import dis from "../src/dispatcher/dispatcher"; import dis from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions"; 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 // don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger"); jest.mock("matrix-js-sdk/src/logger");
@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({
isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(), isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(),
})); }));
const deviceId = 'my-device-id';
class MockClient extends EventEmitter { class MockClient extends EventEmitter {
isGuest = jest.fn();
getUserId = jest.fn(); getUserId = jest.fn();
getKeyBackupVersion = jest.fn().mockResolvedValue(undefined); getKeyBackupVersion = jest.fn().mockResolvedValue(undefined);
getRooms = jest.fn().mockReturnValue([]); getRooms = jest.fn().mockReturnValue([]);
@ -57,6 +64,8 @@ class MockClient extends EventEmitter {
downloadKeys = jest.fn(); downloadKeys = jest.fn();
isRoomEncrypted = jest.fn(); isRoomEncrypted = jest.fn();
getClientWellKnown = jest.fn(); getClientWellKnown = jest.fn();
getDeviceId = jest.fn().mockReturnValue(deviceId);
setAccountData = jest.fn();
} }
const mockDispatcher = mocked(dis); const mockDispatcher = mocked(dis);
const flushPromises = async () => await new Promise(process.nextTick); const flushPromises = async () => await new Promise(process.nextTick);
@ -75,8 +84,12 @@ describe('DeviceListener', () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
mockPlatformPeg({
getAppVersion: jest.fn().mockResolvedValue('1.2.3'),
});
mockClient = new MockClient(); mockClient = new MockClient();
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient); jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient);
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
}); });
const createAndStart = async (): Promise<DeviceListener> => { const createAndStart = async (): Promise<DeviceListener> => {
@ -86,6 +99,115 @@ describe('DeviceListener', () => {
return instance; 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', () => { describe('recheck', () => {
it('does nothing when cross signing feature is not supported', async () => { it('does nothing when cross signing feature is not supported', async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false); mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);

View File

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