diff --git a/api-types/index.ts b/api-types/index.ts index 7738696e606..02ac4076085 100644 --- a/api-types/index.ts +++ b/api-types/index.ts @@ -79,6 +79,8 @@ export type DesktopAPI = { openCallsUserSettings: () => void; onOpenCallsUserSettings: (listener: () => void) => () => void; + onSendMetrics: (listener: (metricsMap: Map) => void) => () => void; + // Utility unregister: (channel: string) => void; } diff --git a/api-types/lib/index.d.ts b/api-types/lib/index.d.ts index 14d117e6977..fd53d88b5d1 100644 --- a/api-types/lib/index.d.ts +++ b/api-types/lib/index.d.ts @@ -65,5 +65,9 @@ export type DesktopAPI = { onOpenStopRecordingModal: (listener: (channelID: string) => void) => () => void; openCallsUserSettings: () => void; onOpenCallsUserSettings: (listener: () => void) => () => void; + onSendMetrics: (listener: (metricsMap: Map) => void) => () => void; unregister: (channel: string) => void; }; diff --git a/api-types/package-lock.json b/api-types/package-lock.json index 345d3ff35e0..0e5ed568db5 100644 --- a/api-types/package-lock.json +++ b/api-types/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mattermost/desktop-api", - "version": "5.10.0-1", + "version": "5.10.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mattermost/desktop-api", - "version": "5.10.0-1", + "version": "5.10.0-2", "license": "MIT", "peerDependencies": { "typescript": "^4.3.0 || ^5.0.0" diff --git a/api-types/package.json b/api-types/package.json index e2e38553d50..b05c9a8f9bc 100644 --- a/api-types/package.json +++ b/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@mattermost/desktop-api", - "version": "5.10.0-1", + "version": "5.10.0-2", "description": "Shared types for the Desktop App API provided to the Web App", "keywords": [ "mattermost" diff --git a/i18n/en.json b/i18n/en.json index 1c0cd443d00..26f79452ca8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -218,6 +218,8 @@ "renderer.components.settingsPage.downloadLocation.description": "Specify the folder where files will download.", "renderer.components.settingsPage.enableHardwareAcceleration": "Use GPU hardware acceleration", "renderer.components.settingsPage.enableHardwareAcceleration.description": "If enabled, {appName} UI is rendered more efficiently but can lead to decreased stability for some systems.", + "renderer.components.settingsPage.enableMetrics": "Send anonymous usage data to your configured servers", + "renderer.components.settingsPage.enableMetrics.description": "Sends usage data about the application and its performance to your configured servers that accept it.", "renderer.components.settingsPage.flashWindow": "Flash taskbar icon when a new message is received", "renderer.components.settingsPage.flashWindow.description": "If enabled, the taskbar icon will flash for a few seconds when a new message is received.", "renderer.components.settingsPage.flashWindow.description.linuxFunctionality": "This functionality may not work with all Linux window managers.", diff --git a/src/common/Validator.ts b/src/common/Validator.ts index 6a6acf4fb84..6b46b5ccc46 100644 --- a/src/common/Validator.ts +++ b/src/common/Validator.ts @@ -150,6 +150,7 @@ const configDataSchemaV3 = Joi.object({ alwaysClose: Joi.boolean(), logLevel: Joi.string().default('info'), appLanguage: Joi.string().allow(''), + enableMetrics: Joi.boolean(), }); // eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'}; diff --git a/src/common/communication.ts b/src/common/communication.ts index 7ad4fbfcbe5..903403d308c 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -197,3 +197,7 @@ export const GET_NONCE = 'get-nonce'; export const DEVELOPER_MODE_UPDATED = 'developer-mode-updated'; export const IS_DEVELOPER_MODE_ENABLED = 'is-developer-mode-enabled'; export const GET_DEVELOPER_MODE_SETTING = 'get-developer-mode-setting'; + +export const METRICS_SEND = 'metrics-send'; +export const METRICS_RECEIVE = 'metrics-receive'; +export const METRICS_REQUEST = 'metrics-request'; diff --git a/src/common/config/defaultPreferences.ts b/src/common/config/defaultPreferences.ts index 5587371eaf9..4e1db4ec181 100644 --- a/src/common/config/defaultPreferences.ts +++ b/src/common/config/defaultPreferences.ts @@ -51,6 +51,7 @@ const defaultPreferences: ConfigV3 = { downloadLocation: getDefaultDownloadLocation(), startInFullscreen: false, logLevel: 'info', + enableMetrics: true, }; export default defaultPreferences; diff --git a/src/common/config/index.ts b/src/common/config/index.ts index 07ee14eb836..4f999b27478 100644 --- a/src/common/config/index.ts +++ b/src/common/config/index.ts @@ -241,6 +241,10 @@ export class Config extends EventEmitter { return this.combinedData?.appLanguage; } + get enableMetrics() { + return this.combinedData?.enableMetrics; + } + /** * Gets the servers from registry into the config object and reload * diff --git a/src/common/config/migrationPreferences.ts b/src/common/config/migrationPreferences.ts index c1bea55ca9c..0cfc6a767fc 100644 --- a/src/common/config/migrationPreferences.ts +++ b/src/common/config/migrationPreferences.ts @@ -27,5 +27,11 @@ export default function migrateConfigItems(config: Config) { didMigrate = true; } + if (!migrationPrefs.getValue('enableMetrics')) { + config.enableMetrics = true; + migrationPrefs.setValue('enableMetrics', true); + didMigrate = true; + } + return didMigrate; } diff --git a/src/main/app/initialize.test.js b/src/main/app/initialize.test.js index eb0ba7ef668..466c0bd472e 100644 --- a/src/main/app/initialize.test.js +++ b/src/main/app/initialize.test.js @@ -77,7 +77,9 @@ jest.mock('electron', () => ({ handle: jest.fn(), }, })); - +jest.mock('main/performanceMonitor', () => ({ + init: jest.fn(), +})); jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), setLocale: jest.fn(), diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index b8809082e8a..1d3b193d277 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -50,6 +50,7 @@ import i18nManager from 'main/i18nManager'; import NonceManager from 'main/nonceManager'; import {getDoNotDisturb} from 'main/notifications'; import parseArgs from 'main/ParseArgs'; +import PerformanceMonitor from 'main/performanceMonitor'; import PermissionsManager from 'main/permissionsManager'; import Tray from 'main/tray/tray'; import TrustedOriginsStore from 'main/trustedOrigins'; @@ -448,6 +449,10 @@ async function initializeAfterAppReady() { AppVersionManager.lastAppVersion = app.getVersion(); handleMainWindowIsShown(); + + // The metrics won't start collecting for another minute + // so we can assume if we start now everything should be loaded by the time we're done + PerformanceMonitor.init(); } function onUserActivityStatus(status: { diff --git a/src/main/performanceMonitor.test.js b/src/main/performanceMonitor.test.js new file mode 100644 index 00000000000..3b0b5678459 --- /dev/null +++ b/src/main/performanceMonitor.test.js @@ -0,0 +1,255 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, ipcMain, powerMonitor} from 'electron'; + +import {EMIT_CONFIGURATION, METRICS_RECEIVE, METRICS_REQUEST, METRICS_SEND} from 'common/communication'; +import Config from 'common/config'; + +import {PerformanceMonitor} from './performanceMonitor'; + +jest.mock('electron', () => ({ + app: { + getAppMetrics: jest.fn(), + }, + ipcMain: { + on: jest.fn(), + off: jest.fn(), + }, + powerMonitor: { + on: jest.fn(), + }, +})); + +jest.mock('common/config', () => ({ + enableMetrics: true, +})); + +describe('main/performanceMonitor', () => { + let makeWebContents; + beforeAll(() => { + jest.useFakeTimers(); + jest.spyOn(global, 'setInterval'); + jest.spyOn(global, 'clearInterval'); + }); + + beforeEach(() => { + app.getAppMetrics.mockReturnValue([]); + + let cb; + ipcMain.on.mockImplementation((channel, listener) => { + if (channel === METRICS_RECEIVE) { + cb = listener; + } + }); + + makeWebContents = (id, resolve) => ({ + send: jest.fn().mockImplementation((channel, arg1, arg2) => { + if (channel === METRICS_REQUEST) { + cb({sender: {id}}, arg1, {serverId: arg2, cpu: id, memory: id * 100}); + } + if (channel === METRICS_SEND) { + resolve(arg1); + } + }), + on: (_, listener) => listener(), + id, + }); + }); + + afterEach(() => { + Config.enableMetrics = true; + }); + + it('should start and stop with config changes', () => { + let emitConfigCb; + ipcMain.on.mockImplementation((channel, listener) => { + if (channel === EMIT_CONFIGURATION) { + emitConfigCb = listener; + } + }); + + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + expect(setInterval).toHaveBeenCalled(); + + Config.enableMetrics = false; + emitConfigCb(); + expect(clearInterval).toHaveBeenCalled(); + + Config.enableMetrics = true; + emitConfigCb(); + expect(setInterval).toHaveBeenCalledTimes(2); + }); + + it('should start and stop with power monitor changes', () => { + const listeners = new Map(); + powerMonitor.on.mockImplementation((channel, listener) => { + listeners.set(channel, listener); + }); + + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + expect(setInterval).toHaveBeenCalled(); + + listeners.get('suspend')(); + expect(clearInterval).toHaveBeenCalled(); + + setInterval.mockClear(); + clearInterval.mockClear(); + + listeners.get('resume')(); + expect(setInterval).toHaveBeenCalled(); + + listeners.get('lock-screen')(); + expect(clearInterval).toHaveBeenCalled(); + + setInterval.mockClear(); + clearInterval.mockClear(); + + listeners.get('unlock-screen')(); + expect(setInterval).toHaveBeenCalled(); + + listeners.get('speed-limit-change')(50); + expect(clearInterval).toHaveBeenCalled(); + + setInterval.mockClear(); + clearInterval.mockClear(); + + listeners.get('speed-limit-change')(100); + expect(setInterval).toHaveBeenCalled(); + }); + + describe('init', () => { + it('should not start until init', () => { + const performanceMonitor = new PerformanceMonitor(); + expect(setInterval).not.toHaveBeenCalled(); + + performanceMonitor.init(); + expect(setInterval).toHaveBeenCalled(); + }); + + it('should run app metrics for node on init', () => { + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + expect(app.getAppMetrics).toHaveBeenCalled(); + }); + + it('should not start if disabled by config', () => { + Config.enableMetrics = false; + + const performanceMonitor = new PerformanceMonitor(); + expect(setInterval).not.toHaveBeenCalled(); + + performanceMonitor.init(); + expect(setInterval).not.toHaveBeenCalled(); + }); + }); + + describe('registerView', () => { + it('should send metrics to registered server views', async () => { + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + + const sendValue = new Promise((resolve) => { + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + }); + + jest.runOnlyPendingTimers(); + + expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]])); + }); + + it('should send metrics for other tabs to registered server views', async () => { + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + + const sendValue = new Promise((resolve) => { + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + performanceMonitor.registerView('view-2', makeWebContents(2, resolve), 'server-1'); + }); + + jest.runOnlyPendingTimers(); + + expect(await sendValue).toEqual(new Map([['view-2', {cpu: 2, memory: 200, serverId: 'server-1'}], ['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]])); + }); + + it('should not send metrics for tabs of other servers to registered server views', async () => { + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + + const sendValue = new Promise((resolve) => { + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + performanceMonitor.registerView('view-2', makeWebContents(2, resolve), 'server-2'); + }); + + jest.runOnlyPendingTimers(); + + expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]])); + }); + + it('should always include node metrics', async () => { + app.getAppMetrics.mockReturnValue([{ + name: 'main', + type: 'Browser', + cpu: {percentCPUUsage: 50}, + memory: {privateBytes: 1000}, + }]); + + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + + const sendValue = new Promise((resolve) => { + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + }); + + jest.runOnlyPendingTimers(); + + expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}], ['main', {cpu: 50, memory: 1000}]])); + }); + + it('should never include tabs from getAppMetrics', async () => { + app.getAppMetrics.mockReturnValue([{ + name: 'other-server', + type: 'Tab', + cpu: {percentCPUUsage: 50}, + memory: {privateBytes: 1000}, + }]); + + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + + const sendValue = new Promise((resolve) => { + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + }); + + jest.runOnlyPendingTimers(); + + expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]])); + }); + }); + + describe('unregisterView', () => { + it('should not send after the view is removed', async () => { + const performanceMonitor = new PerformanceMonitor(); + performanceMonitor.init(); + + const sendValue = new Promise((resolve) => { + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + performanceMonitor.registerServerView('view-2', makeWebContents(2, resolve), 'server-1'); + }); + + jest.runOnlyPendingTimers(); + expect(await sendValue).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}], ['view-2', {cpu: 2, memory: 200, serverId: 'server-1'}]])); + + // Have to re-register to make sure the promise resolves + const sendValue2 = new Promise((resolve) => { + performanceMonitor.unregisterView(2); + performanceMonitor.registerServerView('view-1', makeWebContents(1, resolve), 'server-1'); + }); + + jest.runOnlyPendingTimers(); + expect(await sendValue2).toEqual(new Map([['view-1', {cpu: 1, memory: 100, serverId: 'server-1'}]])); + }); + }); +}); diff --git a/src/main/performanceMonitor.ts b/src/main/performanceMonitor.ts new file mode 100644 index 00000000000..a95cd4d13e5 --- /dev/null +++ b/src/main/performanceMonitor.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {app, ipcMain, type IpcMainEvent, powerMonitor, type WebContents} from 'electron'; + +import {EMIT_CONFIGURATION, METRICS_RECEIVE, METRICS_REQUEST, METRICS_SEND} from 'common/communication'; +import Config from 'common/config'; +import {Logger} from 'common/log'; + +const METRIC_SEND_INTERVAL = 60000; +const log = new Logger('PerformanceMonitor'); + +type MetricsView = { + name: string; + webContents: WebContents; + serverId?: string; +} + +type Metrics = { + serverId?: string; + cpu?: number; + memory?: number; +} + +export class PerformanceMonitor { + private updateInterval?: NodeJS.Timeout; + private views: Map; + private serverViews: Map; + private isInitted: boolean; + + constructor() { + this.views = new Map(); + this.serverViews = new Map(); + this.isInitted = false; + + powerMonitor.on('suspend', this.stop); + powerMonitor.on('resume', this.start); + powerMonitor.on('lock-screen', this.stop); + powerMonitor.on('unlock-screen', this.start); + powerMonitor.on('speed-limit-change', this.handleSpeedLimitChange); + + ipcMain.on(EMIT_CONFIGURATION, this.handleConfigUpdate); + } + + init = () => { + // Set that it's initted so that the powerMonitor functions correctly + this.isInitted = true; + + // Run once because the first CPU value is always 0 + this.runMetrics(); + + if (Config.enableMetrics) { + this.start(); + } + }; + + registerView = (name: string, webContents: WebContents, serverId?: string) => { + log.debug('registerView', webContents.id, name); + + webContents.on('did-finish-load', () => { + this.views.set(webContents.id, {name, webContents, serverId}); + }); + }; + + registerServerView = (name: string, webContents: WebContents, serverId: string) => { + log.debug('registerServerView', webContents.id, serverId); + + webContents.on('did-finish-load', () => { + this.serverViews.set(webContents.id, {name, webContents, serverId}); + }); + }; + + unregisterView = (webContentsId: number) => { + log.debug('unregisterView', webContentsId); + + this.views.delete(webContentsId); + this.serverViews.delete(webContentsId); + }; + + private start = () => { + if (!this.isInitted) { + return; + } + + if (!Config.enableMetrics) { + return; + } + + log.verbose('start'); + + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + this.updateInterval = setInterval(this.sendMetrics, METRIC_SEND_INTERVAL); + }; + + private stop = () => { + log.verbose('stop'); + + clearInterval(this.updateInterval); + delete this.updateInterval; + }; + + private runMetrics = async () => { + const metricsMap: Map = new Map(); + + // Collect metrics for all of the Node processes + app.getAppMetrics(). + filter((metric) => metric.type !== 'Tab'). + forEach((metric) => { + metricsMap.set(metric.name ?? metric.type, { + cpu: metric.cpu.percentCPUUsage, + memory: metric.memory.privateBytes ?? metric.memory.workingSetSize, + }); + }); + + const viewResolves: Map void> = new Map(); + const listener = (event: IpcMainEvent, name: string, metrics: Metrics) => { + metricsMap.set(name, metrics); + viewResolves.get(event.sender.id)?.(); + }; + ipcMain.on(METRICS_RECEIVE, listener); + const viewPromises = [...this.views.values(), ...this.serverViews.values()].map((view) => { + return new Promise((resolve) => { + viewResolves.set(view.webContents.id, resolve); + view.webContents.send(METRICS_REQUEST, view.name, view.serverId); + }); + }); + + // After 5 seconds, if all the promises are not resolved, resolve them so we don't block the send + // This can happen if a view doesn't send back metrics information + setTimeout(() => { + [...viewResolves.values()].forEach((value) => value()); + }, 5000); + await Promise.allSettled(viewPromises); + ipcMain.off(METRICS_RECEIVE, listener); + return metricsMap; + }; + + private sendMetrics = async () => { + const metricsMap = await this.runMetrics(); + for (const view of this.serverViews.values()) { + const serverId = view.serverId; + if (!serverId) { + log.error(`Cannot send metrics for ${view.name} - missing server id`); + continue; + } + + if (!view.webContents) { + log.error(`Cannot send metrics for ${view.name} - missing web contents`); + continue; + } + + const serverMetricsMap = new Map([...metricsMap].filter((value) => !value[1].serverId || value[1].serverId === view.serverId)); + view.webContents.send(METRICS_SEND, serverMetricsMap); + } + }; + + private handleConfigUpdate = () => { + if (!Config.enableMetrics && this.updateInterval) { + this.stop(); + } else if (!this.updateInterval) { + this.start(); + } + }; + + private handleSpeedLimitChange = (limit: number) => { + if (limit < 100) { + this.stop(); + } else { + this.start(); + } + }; +} + +const performanceMonitor = new PerformanceMonitor(); +export default performanceMonitor; diff --git a/src/main/preload/externalAPI.ts b/src/main/preload/externalAPI.ts index 3bfc76e0fcd..46d81c2525d 100644 --- a/src/main/preload/externalAPI.ts +++ b/src/main/preload/externalAPI.ts @@ -44,6 +44,9 @@ import { LEGACY_OFF, TAB_LOGIN_CHANGED, GET_DEVELOPER_MODE_SETTING, + METRICS_SEND, + METRICS_REQUEST, + METRICS_RECEIVE, } from 'common/communication'; import type {ExternalAPI} from 'types/externalAPI'; @@ -131,12 +134,22 @@ ipcRenderer.invoke(GET_DEVELOPER_MODE_SETTING, 'forceLegacyAPI').then((force) => openCallsUserSettings: () => ipcRenderer.send(CALLS_WIDGET_OPEN_USER_SETTINGS), onOpenCallsUserSettings: (listener) => createListener(CALLS_WIDGET_OPEN_USER_SETTINGS, listener), + onSendMetrics: (listener) => createListener(METRICS_SEND, listener), + // Utility unregister: (channel) => ipcRenderer.removeAllListeners(channel), }; contextBridge.exposeInMainWorld('desktopAPI', desktopAPI); }); +ipcRenderer.on(METRICS_REQUEST, async (_, name, serverId) => { + const memory = await process.getProcessMemoryInfo(); + ipcRenderer.send(METRICS_RECEIVE, name, {serverId, cpu: process.getCPUUsage().percentCPUUsage, memory: memory.residentSet ?? memory.private}); +}); + +// Call this once to unset it to 0 +process.getCPUUsage(); + // Specific info for the testing environment if (process.env.NODE_ENV === 'test') { contextBridge.exposeInMainWorld('testHelper', { diff --git a/src/main/preload/internalAPI.js b/src/main/preload/internalAPI.js index d2d7cf40b12..4b0a4b5780a 100644 --- a/src/main/preload/internalAPI.js +++ b/src/main/preload/internalAPI.js @@ -93,6 +93,8 @@ import { VIEW_FINISHED_RESIZING, GET_NONCE, IS_DEVELOPER_MODE_ENABLED, + METRICS_REQUEST, + METRICS_RECEIVE, } from 'common/communication'; console.log('Preload initialized'); @@ -258,3 +260,10 @@ const createKeyDownListener = () => { }; createKeyDownListener(); +ipcRenderer.on(METRICS_REQUEST, async (_, name) => { + const memory = await process.getProcessMemoryInfo(); + ipcRenderer.send(METRICS_RECEIVE, name, {cpu: process.getCPUUsage().percentCPUUsage, memory: memory.residentSet ?? memory.private}); +}); + +// Call this once to unset it to 0 +process.getCPUUsage(); diff --git a/src/main/tray/tray.test.js b/src/main/tray/tray.test.js index eb39a5f9ac4..032a1ac97a1 100644 --- a/src/main/tray/tray.test.js +++ b/src/main/tray/tray.test.js @@ -59,6 +59,9 @@ jest.mock('main/AutoLauncher', () => ({ jest.mock('main/badge', () => ({ setUnreadBadgeSetting: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); jest.mock('main/windows/mainWindow', () => ({ sendToRenderer: jest.fn(), on: jest.fn(), diff --git a/src/main/views/MattermostBrowserView.test.js b/src/main/views/MattermostBrowserView.test.js index 033f1b4e920..345d1162763 100644 --- a/src/main/views/MattermostBrowserView.test.js +++ b/src/main/views/MattermostBrowserView.test.js @@ -62,6 +62,11 @@ jest.mock('../utils', () => ({ jest.mock('main/developerMode', () => ({ get: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), + registerServerView: jest.fn(), + unregisterView: jest.fn(), +})); const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'}); const view = new MessagingView(server, true); diff --git a/src/main/views/MattermostBrowserView.ts b/src/main/views/MattermostBrowserView.ts index a53da872aa3..8b35436132a 100644 --- a/src/main/views/MattermostBrowserView.ts +++ b/src/main/views/MattermostBrowserView.ts @@ -23,8 +23,9 @@ import type {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants'; import {isInternalURL, parseURL} from 'common/utils/url'; -import type {MattermostView} from 'common/views/View'; +import {TAB_MESSAGING, type MattermostView} from 'common/views/View'; import DeveloperMode from 'main/developerMode'; +import performanceMonitor from 'main/performanceMonitor'; import {getServerAPI} from 'main/server/serverAPI'; import MainWindow from 'main/windows/mainWindow'; @@ -196,6 +197,11 @@ export class MattermostBrowserView extends EventEmitter { loadURL = this.view.url.toString(); } this.log.verbose(`Loading ${loadURL}`); + if (this.view.type === TAB_MESSAGING) { + performanceMonitor.registerServerView(`Server ${this.browserView.webContents.id}`, this.browserView.webContents, this.view.server.id); + } else { + performanceMonitor.registerView(`Server ${this.browserView.webContents.id}`, this.browserView.webContents, this.view.server.id); + } const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent(DeveloperMode.get('browserOnly'))}); loading.then(this.loadSuccess(loadURL)).catch((err) => { if (err.code && err.code.startsWith('ERR_CERT')) { @@ -262,6 +268,7 @@ export class MattermostBrowserView extends EventEmitter { WebContentsEventManager.removeWebContentsListeners(this.webContentsId); AppState.clear(this.id); MainWindow.get()?.removeBrowserView(this.browserView); + performanceMonitor.unregisterView(this.browserView.webContents.id); this.browserView.webContents.close(); this.isVisible = false; diff --git a/src/main/views/downloadsDropdownMenuView.test.js b/src/main/views/downloadsDropdownMenuView.test.js index c805d9668c5..ddbe8202b9d 100644 --- a/src/main/views/downloadsDropdownMenuView.test.js +++ b/src/main/views/downloadsDropdownMenuView.test.js @@ -52,6 +52,9 @@ jest.mock('macos-notification-state', () => ({ getDoNotDisturb: jest.fn(), })); jest.mock('main/downloadsManager', () => ({})); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); jest.mock('main/windows/mainWindow', () => ({ on: jest.fn(), get: jest.fn(), diff --git a/src/main/views/downloadsDropdownMenuView.ts b/src/main/views/downloadsDropdownMenuView.ts index 1b4aed783e8..6036e8d66af 100644 --- a/src/main/views/downloadsDropdownMenuView.ts +++ b/src/main/views/downloadsDropdownMenuView.ts @@ -27,6 +27,7 @@ import { TAB_BAR_HEIGHT, } from 'common/utils/constants'; import downloadsManager from 'main/downloadsManager'; +import performanceMonitor from 'main/performanceMonitor'; import {getLocalPreload} from 'main/utils'; import MainWindow from 'main/windows/mainWindow'; @@ -75,6 +76,7 @@ export class DownloadsDropdownMenuView { // @ts-ignore transparent: true, }}); + performanceMonitor.registerView('DownloadsDropdownMenuView', this.view.webContents); this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdownMenu.html'); MainWindow.get()?.addBrowserView(this.view); }; diff --git a/src/main/views/downloadsDropdownView.test.js b/src/main/views/downloadsDropdownView.test.js index e5d76bff1f2..74b65ac98e5 100644 --- a/src/main/views/downloadsDropdownView.test.js +++ b/src/main/views/downloadsDropdownView.test.js @@ -65,6 +65,9 @@ jest.mock('main/downloadsManager', () => ({ onOpen: jest.fn(), onClose: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); jest.mock('main/windows/mainWindow', () => ({ on: jest.fn(), get: jest.fn(), diff --git a/src/main/views/downloadsDropdownView.ts b/src/main/views/downloadsDropdownView.ts index 09a01ad2541..dc3194f0df1 100644 --- a/src/main/views/downloadsDropdownView.ts +++ b/src/main/views/downloadsDropdownView.ts @@ -21,6 +21,7 @@ import Config from 'common/config'; import {Logger} from 'common/log'; import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants'; import downloadsManager from 'main/downloadsManager'; +import performanceMonitor from 'main/performanceMonitor'; import {getLocalPreload} from 'main/utils'; import MainWindow from 'main/windows/mainWindow'; @@ -65,6 +66,7 @@ export class DownloadsDropdownView { transparent: true, }}); + performanceMonitor.registerView('DownloadsDropdownView', this.view.webContents); this.view.webContents.loadURL('mattermost-desktop://renderer/downloadsDropdown.html'); MainWindow.get()?.addBrowserView(this.view); }; diff --git a/src/main/views/loadingScreen.test.js b/src/main/views/loadingScreen.test.js index e0c4b7c2b05..de34b9593cb 100644 --- a/src/main/views/loadingScreen.test.js +++ b/src/main/views/loadingScreen.test.js @@ -10,7 +10,9 @@ jest.mock('electron', () => ({ on: jest.fn(), }, })); - +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); jest.mock('main/windows/mainWindow', () => ({ get: jest.fn(), on: jest.fn(), diff --git a/src/main/views/loadingScreen.ts b/src/main/views/loadingScreen.ts index 74d6ad23d18..ef08b5a9abc 100644 --- a/src/main/views/loadingScreen.ts +++ b/src/main/views/loadingScreen.ts @@ -5,6 +5,7 @@ import {BrowserView, app, ipcMain} from 'electron'; import {DARK_MODE_CHANGE, LOADING_SCREEN_ANIMATION_FINISHED, MAIN_WINDOW_RESIZED, TOGGLE_LOADING_SCREEN_VISIBILITY} from 'common/communication'; import {Logger} from 'common/log'; +import performanceMonitor from 'main/performanceMonitor'; import {getLocalPreload, getWindowBoundaries} from 'main/utils'; import MainWindow from 'main/windows/mainWindow'; @@ -86,6 +87,8 @@ export class LoadingScreen { transparent: true, }}); const localURL = 'mattermost-desktop://renderer/loadingScreen.html'; + + performanceMonitor.registerView('LoadingScreen', this.view.webContents); this.view.webContents.loadURL(localURL); }; diff --git a/src/main/views/modalView.test.js b/src/main/views/modalView.test.js index dd54e6a74e4..8b0aa909c7d 100644 --- a/src/main/views/modalView.test.js +++ b/src/main/views/modalView.test.js @@ -27,6 +27,10 @@ jest.mock('../contextMenu', () => jest.fn()); jest.mock('../utils', () => ({ getWindowBoundaries: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), + unregisterView: jest.fn(), +})); describe('main/views/modalView', () => { describe('show', () => { diff --git a/src/main/views/modalView.ts b/src/main/views/modalView.ts index cc983ac06b4..0fa591f0f45 100644 --- a/src/main/views/modalView.ts +++ b/src/main/views/modalView.ts @@ -5,6 +5,7 @@ import type {BrowserWindow} from 'electron'; import {BrowserView} from 'electron'; import {Logger} from 'common/log'; +import performanceMonitor from 'main/performanceMonitor'; import ContextMenu from '../contextMenu'; import {getWindowBoundaries} from '../utils'; @@ -50,6 +51,7 @@ export class ModalView { this.status = Status.ACTIVE; try { + performanceMonitor.registerView(`Modal-${key}`, this.view.webContents); this.view.webContents.loadURL(this.html); } catch (e) { this.log.error('there was an error loading the modal:'); @@ -99,6 +101,7 @@ export class ModalView { this.view.webContents.closeDevTools(); } this.windowAttached.removeBrowserView(this.view); + performanceMonitor.unregisterView(this.view.webContents.id); this.view.webContents.close(); delete this.windowAttached; diff --git a/src/main/views/serverDropdownView.test.js b/src/main/views/serverDropdownView.test.js index a98e42ecc2c..9b4621d4088 100644 --- a/src/main/views/serverDropdownView.test.js +++ b/src/main/views/serverDropdownView.test.js @@ -29,6 +29,9 @@ jest.mock('electron', () => ({ getPath: jest.fn(() => '/valid/downloads/path'), }, })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); jest.mock('main/windows/mainWindow', () => ({ on: jest.fn(), get: jest.fn(), diff --git a/src/main/views/serverDropdownView.ts b/src/main/views/serverDropdownView.ts index 345ab1b0ba5..059cf7e1d50 100644 --- a/src/main/views/serverDropdownView.ts +++ b/src/main/views/serverDropdownView.ts @@ -22,6 +22,7 @@ import Config from 'common/config'; import {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import {TAB_BAR_HEIGHT, THREE_DOT_MENU_WIDTH, THREE_DOT_MENU_WIDTH_MAC, MENU_SHADOW_WIDTH} from 'common/utils/constants'; +import performanceMonitor from 'main/performanceMonitor'; import {getLocalPreload} from 'main/utils'; import type {UniqueServer} from 'types/config'; @@ -83,6 +84,7 @@ export class ServerDropdownView { // @ts-ignore transparent: true, }}); + performanceMonitor.registerView('ServerDropdownView', this.view.webContents); this.view.webContents.loadURL('mattermost-desktop://renderer/dropdown.html'); this.setOrderedServers(); diff --git a/src/main/views/viewManager.test.js b/src/main/views/viewManager.test.js index fe6f5170c27..025669aaf73 100644 --- a/src/main/views/viewManager.test.js +++ b/src/main/views/viewManager.test.js @@ -82,6 +82,9 @@ jest.mock('main/windows/mainWindow', () => ({ get: jest.fn(), on: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); jest.mock('common/servers/serverManager', () => ({ getOrderedTabsForServer: jest.fn(), getAllServers: jest.fn(), diff --git a/src/main/views/viewManager.ts b/src/main/views/viewManager.ts index 0cb7abd91f9..c2147fac173 100644 --- a/src/main/views/viewManager.ts +++ b/src/main/views/viewManager.ts @@ -48,6 +48,7 @@ import {TAB_MESSAGING} from 'common/views/View'; import {flushCookiesStore} from 'main/app/utils'; import DeveloperMode from 'main/developerMode'; import {localizeMessage} from 'main/i18nManager'; +import performanceMonitor from 'main/performanceMonitor'; import PermissionsManager from 'main/permissionsManager'; import MainWindow from 'main/windows/mainWindow'; @@ -373,6 +374,7 @@ export class ViewManager { transparent: true, }}); const localURL = `mattermost-desktop://renderer/urlView.html?url=${encodeURIComponent(urlString)}`; + performanceMonitor.registerView('URLView', urlView.webContents); urlView.webContents.loadURL(localURL); MainWindow.get()?.addBrowserView(urlView); const boundaries = this.views.get(this.currentView || '')?.getBounds() ?? MainWindow.getBounds(); @@ -385,6 +387,7 @@ export class ViewManager { log.error('Failed to remove URL view', e); } + performanceMonitor.unregisterView(urlView.webContents.id); urlView.webContents.close(); }; diff --git a/src/main/windows/callsWidgetWindow.test.js b/src/main/windows/callsWidgetWindow.test.js index 678463c4b71..8630af2f239 100644 --- a/src/main/windows/callsWidgetWindow.test.js +++ b/src/main/windows/callsWidgetWindow.test.js @@ -67,6 +67,10 @@ jest.mock('main/windows/mainWindow', () => ({ jest.mock('app/serverViewState', () => ({ switchServer: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), + unregisterView: jest.fn(), +})); jest.mock('main/views/viewManager', () => ({ getView: jest.fn(), getViewByWebContentsId: jest.fn(), @@ -156,6 +160,9 @@ describe('main/windows/callsWidgetWindow', () => { on: jest.fn(), close: jest.fn(), isDestroyed: jest.fn(), + webContents: { + id: 1, + }, }; beforeEach(() => { diff --git a/src/main/windows/callsWidgetWindow.ts b/src/main/windows/callsWidgetWindow.ts index d2f5e6b4ff8..3ae9f8eb59f 100644 --- a/src/main/windows/callsWidgetWindow.ts +++ b/src/main/windows/callsWidgetWindow.ts @@ -28,6 +28,7 @@ import {Logger} from 'common/log'; import {CALLS_PLUGIN_ID, MINIMUM_CALLS_WIDGET_HEIGHT, MINIMUM_CALLS_WIDGET_WIDTH} from 'common/utils/constants'; import {getFormattedPathName, isCallsPopOutURL, parseURL} from 'common/utils/url'; import Utils from 'common/utils/util'; +import performanceMonitor from 'main/performanceMonitor'; import PermissionsManager from 'main/permissionsManager'; import { composeUserAgent, @@ -173,6 +174,7 @@ export class CallsWidgetWindow { if (!widgetURL) { return; } + performanceMonitor.registerView('CallsWidgetWindow', this.win.webContents); this.win?.loadURL(widgetURL, { userAgent: composeUserAgent(), }).catch((reason) => { @@ -195,6 +197,7 @@ export class CallsWidgetWindow { return; } this.win?.on('closed', resolve); + performanceMonitor.unregisterView(this.win.webContents.id); this.win?.close(); }); }; diff --git a/src/main/windows/mainWindow.test.js b/src/main/windows/mainWindow.test.js index 059b15a6260..a83d4e98df0 100644 --- a/src/main/windows/mainWindow.test.js +++ b/src/main/windows/mainWindow.test.js @@ -73,6 +73,9 @@ jest.mock('../utils', () => ({ jest.mock('main/i18nManager', () => ({ localizeMessage: jest.fn(), })); +jest.mock('main/performanceMonitor', () => ({ + registerView: jest.fn(), +})); describe('main/windows/mainWindow', () => { describe('init', () => { diff --git a/src/main/windows/mainWindow.ts b/src/main/windows/mainWindow.ts index c32e22c7450..633886535ed 100644 --- a/src/main/windows/mainWindow.ts +++ b/src/main/windows/mainWindow.ts @@ -34,6 +34,7 @@ import Utils from 'common/utils/util'; import * as Validator from 'common/Validator'; import {boundsInfoPath} from 'main/constants'; import {localizeMessage} from 'main/i18nManager'; +import performanceMonitor from 'main/performanceMonitor'; import type {SavedWindowState} from 'types/mainWindow'; @@ -152,6 +153,7 @@ export class MainWindow extends EventEmitter { contextMenu.reload(); const localURL = 'mattermost-desktop://renderer/index.html'; + performanceMonitor.registerView('MainWindow', this.win.webContents); this.win.loadURL(localURL).catch( (reason) => { log.error('failed to load', reason); diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx index 2406fe2bb48..ddce829ddd0 100644 --- a/src/renderer/components/SettingsPage.tsx +++ b/src/renderer/components/SettingsPage.tsx @@ -67,6 +67,7 @@ class SettingsPage extends React.PureComponent { autoCheckForUpdatesRef: React.RefObject; logLevelRef: React.RefObject; appLanguageRef: React.RefObject; + enableMetricsRef: React.RefObject; saveQueue: SaveQueueItem[]; @@ -106,6 +107,7 @@ class SettingsPage extends React.PureComponent { this.autoCheckForUpdatesRef = React.createRef(); this.logLevelRef = React.createRef(); this.appLanguageRef = React.createRef(); + this.enableMetricsRef = React.createRef(); this.saveQueue = []; this.selectedSpellCheckerLocales = []; @@ -218,6 +220,13 @@ class SettingsPage extends React.PureComponent { }, 2000); }; + handleEnableMetrics = () => { + window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'enableMetrics', data: this.enableMetricsRef.current?.checked}); + this.setState({ + enableMetrics: this.enableMetricsRef.current?.checked, + }); + }; + handleChangeShowTrayIcon = () => { const shouldShowTrayIcon = this.showTrayIconRef.current?.checked; window.timers.setImmediate(this.saveSetting, CONFIG_TYPE_APP_OPTIONS, {key: 'showTrayIcon', data: shouldShowTrayIcon}); @@ -709,6 +718,30 @@ class SettingsPage extends React.PureComponent { ); } + options.push( + + + + + + + ); + if (window.process.platform === 'win32' || window.process.platform === 'linux') { options.push( diff --git a/src/types/config.ts b/src/types/config.ts index cbd12e94317..670012f9571 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -59,6 +59,7 @@ export type ConfigV3 = { alwaysClose?: boolean; logLevel?: string; appLanguage?: string; + enableMetrics?: boolean; } export type ConfigV2 = @@ -131,4 +132,5 @@ export type MigrationInfo = { updateTrayIconWin32: boolean; masConfigs: boolean; closeExtraTabs: boolean; + enableMetrics: boolean; } diff --git a/src/types/externalAPI.ts b/src/types/externalAPI.ts index 563da09b895..f8aeaa2beaf 100644 --- a/src/types/externalAPI.ts +++ b/src/types/externalAPI.ts @@ -25,4 +25,5 @@ export interface ExternalAPI { createListener(event: 'calls-widget-open-thread', listener: (threadID: string) => void): () => void; createListener(event: 'calls-widget-open-stop-recording-modal', listener: (channelID: string) => void): () => void; createListener(event: 'calls-widget-open-user-settings', listener: () => void): () => void; + createListener(event: 'metrics-send', listener: (metricsMap: Map) => void): () => void; }