[MM-60308] Add a set of "Developer Mode" settings that allow the user to turn off systems or force the app to behave a certain way (#3144)

* Add developer mode manager, implement browser-only mode

* Add indicator when developer mode is enabled

* Add switch to disable notification storage

* Add setting to disable the user activity monitor

* Add switchOff method for easily creating switches to disable/enable functionality, added setting to disable context menu

* Add setting to force legacy API

* Add force new API to remove any support for legacy mode, fix i18n

* Fix lint

* Use one call to `push`
This commit is contained in:
Devin Binnie 2024-09-18 10:02:20 -04:00 committed by GitHub
parent 61cf759b23
commit 42a0bc4759
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 734 additions and 380 deletions

View file

@ -81,6 +81,12 @@
"main.menus.app.view": "&View", "main.menus.app.view": "&View",
"main.menus.app.view.actualSize": "Actual Size", "main.menus.app.view.actualSize": "Actual Size",
"main.menus.app.view.clearCacheAndReload": "Clear Cache and Reload", "main.menus.app.view.clearCacheAndReload": "Clear Cache and Reload",
"main.menus.app.view.developerModeBrowserOnly": "Browser Only Mode",
"main.menus.app.view.developerModeDisableContextMenu": "Disable Context Menu",
"main.menus.app.view.developerModeDisableNotificationStorage": "Disable Notification Storage",
"main.menus.app.view.developerModeDisableUserActivityMonitor": "Disable User Activity Monitor",
"main.menus.app.view.developerModeForceLegacyAPI": "Force Legacy API",
"main.menus.app.view.developerModeForceNewAPI": "Force New API",
"main.menus.app.view.devToolsAppWrapper": "Developer Tools for Application Wrapper", "main.menus.app.view.devToolsAppWrapper": "Developer Tools for Application Wrapper",
"main.menus.app.view.devToolsCurrentCallWidget": "Developer Tools for Call Widget", "main.menus.app.view.devToolsCurrentCallWidget": "Developer Tools for Call Widget",
"main.menus.app.view.devToolsCurrentServer": "Developer Tools for Current Server", "main.menus.app.view.devToolsCurrentServer": "Developer Tools for Current Server",
@ -151,6 +157,7 @@
"renderer.components.configureServer.url.urlNotMatched": "The server URL provided does not match the configured Site URL on your Mattermost server. Server version: {serverVersion}", "renderer.components.configureServer.url.urlNotMatched": "The server URL provided does not match the configured Site URL on your Mattermost server. Server version: {serverVersion}",
"renderer.components.configureServer.url.urlUpdated": "The server URL provided has been updated to match the configured Site URL on your Mattermost server. Server version: {serverVersion}", "renderer.components.configureServer.url.urlUpdated": "The server URL provided has been updated to match the configured Site URL on your Mattermost server. Server version: {serverVersion}",
"renderer.components.configureServer.url.validating": "Validating...", "renderer.components.configureServer.url.validating": "Validating...",
"renderer.components.developerModeIndicator.tooltip": "Developer mode is enabled. You should only have this enabled if a Mattermost developer has instructed you to.",
"renderer.components.errorView.cannotConnectToAppName": "Cannot connect to {appName}", "renderer.components.errorView.cannotConnectToAppName": "Cannot connect to {appName}",
"renderer.components.errorView.havingTroubleConnecting": "We're having trouble connecting to {appName}. We'll continue to try and establish a connection.", "renderer.components.errorView.havingTroubleConnecting": "We're having trouble connecting to {appName}. We'll continue to try and establish a connection.",
"renderer.components.errorView.refreshThenVerify": "If refreshing this page (Ctrl+R or Command+R) does not work please verify that:", "renderer.components.errorView.refreshThenVerify": "If refreshing this page (Ctrl+R or Command+R) does not work please verify that:",

View file

@ -193,3 +193,7 @@ export const GET_MEDIA_ACCESS_STATUS = 'get-media-access-status';
export const LEGACY_OFF = 'legacy-off'; export const LEGACY_OFF = 'legacy-off';
export const GET_NONCE = 'get-nonce'; 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';

View file

@ -82,6 +82,7 @@ export class UserActivityMonitor extends EventEmitter {
*/ */
stopMonitoring() { stopMonitoring() {
clearInterval(this.systemIdleTimeIntervalID); clearInterval(this.systemIdleTimeIntervalID);
this.systemIdleTimeIntervalID = -1;
} }
/** /**

View file

@ -29,6 +29,7 @@ import {
TOGGLE_SECURE_INPUT, TOGGLE_SECURE_INPUT,
GET_APP_INFO, GET_APP_INFO,
SHOW_SETTINGS_WINDOW, SHOW_SETTINGS_WINDOW,
DEVELOPER_MODE_UPDATED,
} from 'common/communication'; } from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
@ -43,6 +44,7 @@ import {setupBadge} from 'main/badge';
import CertificateManager from 'main/certificateManager'; import CertificateManager from 'main/certificateManager';
import {configPath, updatePaths} from 'main/constants'; import {configPath, updatePaths} from 'main/constants';
import CriticalErrorHandler from 'main/CriticalErrorHandler'; import CriticalErrorHandler from 'main/CriticalErrorHandler';
import DeveloperMode from 'main/developerMode';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import i18nManager from 'main/i18nManager'; import i18nManager from 'main/i18nManager';
import NonceManager from 'main/nonceManager'; import NonceManager from 'main/nonceManager';
@ -405,14 +407,16 @@ async function initializeAfterAppReady() {
// Call this to initiate a permissions check for DND state // Call this to initiate a permissions check for DND state
getDoNotDisturb(); getDoNotDisturb();
// listen for status updates and pass on to renderer DeveloperMode.switchOff('disableUserActivityMonitor', () => {
UserActivityMonitor.on('status', (status) => { // listen for status updates and pass on to renderer
log.debug('UserActivityMonitor.on(status)', status); UserActivityMonitor.on('status', onUserActivityStatus);
ViewManager.sendToAllViews(USER_ACTIVITY_UPDATE, status.userIsActive, status.idleTime, status.isSystemEvent);
});
// start monitoring user activity (needs to be started after the app is ready) // start monitoring user activity (needs to be started after the app is ready)
UserActivityMonitor.startMonitoring(); UserActivityMonitor.startMonitoring();
}, () => {
UserActivityMonitor.off('status', onUserActivityStatus);
UserActivityMonitor.stopMonitoring();
});
if (shouldShowTrayIcon()) { if (shouldShowTrayIcon()) {
Tray.init(Config.trayIconTheme); Tray.init(Config.trayIconTheme);
@ -430,6 +434,7 @@ async function initializeAfterAppReady() {
} }
handleUpdateMenuEvent(); handleUpdateMenuEvent();
DeveloperMode.on(DEVELOPER_MODE_UPDATED, handleUpdateMenuEvent);
ipcMain.emit('update-dict'); ipcMain.emit('update-dict');
@ -445,6 +450,15 @@ async function initializeAfterAppReady() {
handleMainWindowIsShown(); handleMainWindowIsShown();
} }
function onUserActivityStatus(status: {
userIsActive: boolean;
idleTime: number;
isSystemEvent: boolean;
}) {
log.debug('UserActivityMonitor.on(status)', status);
ViewManager.sendToAllViews(USER_ACTIVITY_UPDATE, status.userIsActive, status.idleTime, status.isSystemEvent);
}
function handleStartDownload() { function handleStartDownload() {
if (updateManager) { if (updateManager) {
updateManager.handleDownload(); updateManager.handleDownload();

View file

@ -20,6 +20,7 @@ export let boundsInfoPath = '';
export let migrationInfoPath = ''; export let migrationInfoPath = '';
export let downloadsJson = ''; export let downloadsJson = '';
export let permissionsJson = ''; export let permissionsJson = '';
export let developerModeJson = '';
export function updatePaths(emit = false) { export function updatePaths(emit = false) {
userDataPath = app.getPath('userData'); userDataPath = app.getPath('userData');
@ -33,6 +34,7 @@ export function updatePaths(emit = false) {
migrationInfoPath = path.resolve(userDataPath, 'migration-info.json'); migrationInfoPath = path.resolve(userDataPath, 'migration-info.json');
downloadsJson = path.resolve(userDataPath, 'downloads.json'); downloadsJson = path.resolve(userDataPath, 'downloads.json');
permissionsJson = path.resolve(userDataPath, 'permissions.json'); permissionsJson = path.resolve(userDataPath, 'permissions.json');
developerModeJson = path.resolve(userDataPath, 'developerMode.json');
if (emit) { if (emit) {
ipcMain.emit(UPDATE_PATHS); ipcMain.emit(UPDATE_PATHS);

View file

@ -0,0 +1,34 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DeveloperMode} from './developerMode';
jest.mock('fs', () => ({
readFileSync: jest.fn(),
writeFile: jest.fn(),
}));
jest.mock('electron', () => ({
ipcMain: {
on: jest.fn(),
handle: jest.fn(),
},
}));
describe('main/developerMode', () => {
it('should toggle values correctly', () => {
const developerMode = new DeveloperMode('file.json');
// Should be false unless developer mode is enabled
developerMode.toggle('setting1');
expect(developerMode.get('setting1')).toBe(false);
developerMode.enabled = () => true;
developerMode.toggle('setting1');
expect(developerMode.get('setting1')).toBe(true);
developerMode.toggle('setting1');
expect(developerMode.get('setting1')).toBe(false);
});
});

68
src/main/developerMode.ts Normal file
View file

@ -0,0 +1,68 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ipcMain} from 'electron';
import {EventEmitter} from 'events';
import {DEVELOPER_MODE_UPDATED, IS_DEVELOPER_MODE_ENABLED, UPDATE_PATHS, GET_DEVELOPER_MODE_SETTING} from 'common/communication';
import JsonFileManager from 'common/JsonFileManager';
import {developerModeJson} from 'main/constants';
import type {DeveloperSettings} from 'types/settings';
export class DeveloperMode extends EventEmitter {
private json: JsonFileManager<DeveloperSettings>;
constructor(file: string) {
super();
this.json = new JsonFileManager(file);
ipcMain.handle(IS_DEVELOPER_MODE_ENABLED, this.enabled);
ipcMain.handle(GET_DEVELOPER_MODE_SETTING, (_, setting) => this.get(setting));
}
enabled = () => process.env.MM_DESKTOP_DEVELOPER_MODE === 'true';
toggle = (setting: keyof DeveloperSettings) => {
if (!this.enabled()) {
return;
}
this.json.setValue(setting, !this.json.getValue(setting));
this.emit(DEVELOPER_MODE_UPDATED, {[setting]: this.json.getValue(setting)});
};
get = (setting: keyof DeveloperSettings) => {
if (!this.enabled()) {
return false;
}
return this.json.getValue(setting);
};
switchOff = (
setting: keyof DeveloperSettings,
onStart: () => void,
onStop: () => void,
) => {
if (!this.get(setting)) {
onStart();
}
this.on(DEVELOPER_MODE_UPDATED, (settings: DeveloperSettings) => {
if (typeof settings[setting] !== 'undefined') {
if (settings[setting]) {
onStop();
} else {
onStart();
}
}
});
};
}
let developerMode = new DeveloperMode(developerModeJson);
ipcMain.on(UPDATE_PATHS, () => {
developerMode = new DeveloperMode(developerModeJson);
});
export default developerMode;

View file

@ -3,7 +3,7 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
'use strict'; 'use strict';
import type {MenuItemConstructorOptions, MenuItem, WebContents} from 'electron'; import type {MenuItemConstructorOptions, MenuItem, BrowserWindow} from 'electron';
import {app, ipcMain, Menu, session, shell, clipboard} from 'electron'; import {app, ipcMain, Menu, session, shell, clipboard} from 'electron';
import log from 'electron-log'; import log from 'electron-log';
@ -15,6 +15,7 @@ import {t} from 'common/utils/util';
import {getViewDisplayName} from 'common/views/View'; import {getViewDisplayName} from 'common/views/View';
import type {ViewType} from 'common/views/View'; import type {ViewType} from 'common/views/View';
import type {UpdateManager} from 'main/autoUpdater'; import type {UpdateManager} from 'main/autoUpdater';
import DeveloperMode from 'main/developerMode';
import Diagnostics from 'main/diagnostics'; import Diagnostics from 'main/diagnostics';
import downloadsManager from 'main/downloadsManager'; import downloadsManager from 'main/downloadsManager';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
@ -139,7 +140,7 @@ export function createTemplate(config: Config, updateManager: UpdateManager) {
}], }],
}); });
const devToolsSubMenu = [ const devToolsSubMenu: Electron.MenuItemConstructorOptions[] = [
{ {
label: localizeMessage('main.menus.app.view.devToolsAppWrapper', 'Developer Tools for Application Wrapper'), label: localizeMessage('main.menus.app.view.devToolsAppWrapper', 'Developer Tools for Application Wrapper'),
accelerator: (() => { accelerator: (() => {
@ -148,13 +149,13 @@ export function createTemplate(config: Config, updateManager: UpdateManager) {
} }
return 'Ctrl+Shift+I'; return 'Ctrl+Shift+I';
})(), })(),
click(item: Electron.MenuItem, focusedWindow?: WebContents) { click(item: Electron.MenuItem, focusedWindow?: BrowserWindow) {
if (focusedWindow) { if (focusedWindow) {
// toggledevtools opens it in the last known position, so sometimes it goes below the browserview // toggledevtools opens it in the last known position, so sometimes it goes below the browserview
if (focusedWindow.isDevToolsOpened()) { if (focusedWindow.webContents.isDevToolsOpened()) {
focusedWindow.closeDevTools(); focusedWindow.webContents.closeDevTools();
} else { } else {
focusedWindow.openDevTools({mode: 'detach'}); focusedWindow.webContents.openDevTools({mode: 'detach'});
} }
} }
}, },
@ -176,6 +177,60 @@ export function createTemplate(config: Config, updateManager: UpdateManager) {
}); });
} }
if (DeveloperMode.enabled()) {
devToolsSubMenu.push(...[
separatorItem,
{
label: localizeMessage('main.menus.app.view.developerModeBrowserOnly', 'Browser Only Mode'),
type: 'checkbox' as const,
checked: DeveloperMode.get('browserOnly'),
click() {
DeveloperMode.toggle('browserOnly');
},
},
{
label: localizeMessage('main.menus.app.view.developerModeDisableNotificationStorage', 'Disable Notification Storage'),
type: 'checkbox' as const,
checked: DeveloperMode.get('disableNotificationStorage'),
click() {
DeveloperMode.toggle('disableNotificationStorage');
},
},
{
label: localizeMessage('main.menus.app.view.developerModeDisableUserActivityMonitor', 'Disable User Activity Monitor'),
type: 'checkbox' as const,
checked: DeveloperMode.get('disableUserActivityMonitor'),
click() {
DeveloperMode.toggle('disableUserActivityMonitor');
},
},
{
label: localizeMessage('main.menus.app.view.developerModeDisableContextMenu', 'Disable Context Menu'),
type: 'checkbox' as const,
checked: DeveloperMode.get('disableContextMenu'),
click() {
DeveloperMode.toggle('disableContextMenu');
},
},
{
label: localizeMessage('main.menus.app.view.developerModeForceLegacyAPI', 'Force Legacy API'),
type: 'checkbox' as const,
checked: DeveloperMode.get('forceLegacyAPI'),
click() {
DeveloperMode.toggle('forceLegacyAPI');
},
},
{
label: localizeMessage('main.menus.app.view.developerModeForceNewAPI', 'Force New API'),
type: 'checkbox' as const,
checked: DeveloperMode.get('forceNewAPI'),
click() {
DeveloperMode.toggle('forceNewAPI');
},
},
]);
}
const viewSubMenu = [{ const viewSubMenu = [{
label: localizeMessage('main.menus.app.view.find', 'Find..'), label: localizeMessage('main.menus.app.view.find', 'Find..'),
accelerator: 'CmdOrCtrl+F', accelerator: 'CmdOrCtrl+F',

View file

@ -106,7 +106,9 @@ jest.mock('../windows/mainWindow', () => ({
show: jest.fn(), show: jest.fn(),
sendToRenderer: jest.fn(), sendToRenderer: jest.fn(),
})); }));
jest.mock('main/developerMode', () => ({
switchOff: (_: string, onStart: () => void) => onStart(),
}));
jest.mock('main/i18nManager', () => ({ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(), localizeMessage: jest.fn(),
})); }));

View file

@ -8,6 +8,7 @@ import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state
import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication'; import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
import DeveloperMode from 'main/developerMode';
import getLinuxDoNotDisturb from './dnd-linux'; import getLinuxDoNotDisturb from './dnd-linux';
import getWindowsDoNotDisturb from './dnd-windows'; import getWindowsDoNotDisturb from './dnd-windows';
@ -22,13 +23,23 @@ import MainWindow from '../windows/mainWindow';
const log = new Logger('Notifications'); const log = new Logger('Notifications');
class NotificationManager { class NotificationManager {
private mentionsPerChannel: Map<string, Mention> = new Map(); private mentionsPerChannel?: Map<string, Mention>;
private allActiveNotifications: Map<string, Notification> = new Map(); private allActiveNotifications?: Map<string, Notification>;
private upgradeNotification?: NewVersionNotification; private upgradeNotification?: NewVersionNotification;
private restartToUpgradeNotification?: UpgradeNotification; private restartToUpgradeNotification?: UpgradeNotification;
constructor() { constructor() {
ipcMain.on(OPEN_NOTIFICATION_PREFERENCES, this.openNotificationPreferences); ipcMain.on(OPEN_NOTIFICATION_PREFERENCES, this.openNotificationPreferences);
DeveloperMode.switchOff('disableNotificationStorage', () => {
this.mentionsPerChannel = new Map();
this.allActiveNotifications = new Map();
}, () => {
this.mentionsPerChannel?.clear();
delete this.mentionsPerChannel;
this.allActiveNotifications?.clear();
delete this.allActiveNotifications;
});
} }
public async displayMention(title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, soundName: string) { public async displayMention(title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, soundName: string) {
@ -68,12 +79,12 @@ class NotificationManager {
} }
const mention = new Mention(options, channelId, teamId); const mention = new Mention(options, channelId, teamId);
this.allActiveNotifications.set(mention.uId, mention); this.allActiveNotifications?.set(mention.uId, mention);
mention.on('click', () => { mention.on('click', () => {
log.debug('notification click', serverName, mention.uId); log.debug('notification click', serverName, mention.uId);
this.allActiveNotifications.delete(mention.uId); this.allActiveNotifications?.delete(mention.uId);
// Show the window after navigation has finished to avoid the focus handler // Show the window after navigation has finished to avoid the focus handler
// being called before the current channel has updated // being called before the current channel has updated
@ -87,7 +98,7 @@ class NotificationManager {
}); });
mention.on('close', () => { mention.on('close', () => {
this.allActiveNotifications.delete(mention.uId); this.allActiveNotifications?.delete(mention.uId);
}); });
return new Promise((resolve) => { return new Promise((resolve) => {
@ -107,12 +118,12 @@ class NotificationManager {
// On Windows, manually dismiss notifications from the same channel and only show the latest one // On Windows, manually dismiss notifications from the same channel and only show the latest one
if (process.platform === 'win32') { if (process.platform === 'win32') {
const mentionKey = `${mention.teamId}:${mention.channelId}`; const mentionKey = `${mention.teamId}:${mention.channelId}`;
if (this.mentionsPerChannel.has(mentionKey)) { if (this.mentionsPerChannel?.has(mentionKey)) {
log.debug(`close ${mentionKey}`); log.debug(`close ${mentionKey}`);
this.mentionsPerChannel.get(mentionKey)?.close(); this.mentionsPerChannel?.get(mentionKey)?.close();
this.mentionsPerChannel.delete(mentionKey); this.mentionsPerChannel?.delete(mentionKey);
} }
this.mentionsPerChannel.set(mentionKey, mention); this.mentionsPerChannel?.set(mentionKey, mention);
} }
const notificationSound = mention.getNotificationSound(); const notificationSound = mention.getNotificationSound();
if (notificationSound) { if (notificationSound) {
@ -127,7 +138,7 @@ class NotificationManager {
mention.on('failed', (_, error) => { mention.on('failed', (_, error) => {
failed = true; failed = true;
this.allActiveNotifications.delete(mention.uId); this.allActiveNotifications?.delete(mention.uId);
clearTimeout(timeout); clearTimeout(timeout);
// Special case for Windows - means that notifications are disabled at the OS level // Special case for Windows - means that notifications are disabled at the OS level
@ -156,7 +167,7 @@ class NotificationManager {
} }
const download = new DownloadNotification(fileName, serverName); const download = new DownloadNotification(fileName, serverName);
this.allActiveNotifications.set(download.uId, download); this.allActiveNotifications?.set(download.uId, download);
download.on('show', () => { download.on('show', () => {
flashFrame(true); flashFrame(true);
@ -164,15 +175,15 @@ class NotificationManager {
download.on('click', () => { download.on('click', () => {
shell.showItemInFolder(path.normalize()); shell.showItemInFolder(path.normalize());
this.allActiveNotifications.delete(download.uId); this.allActiveNotifications?.delete(download.uId);
}); });
download.on('close', () => { download.on('close', () => {
this.allActiveNotifications.delete(download.uId); this.allActiveNotifications?.delete(download.uId);
}); });
download.on('failed', () => { download.on('failed', () => {
this.allActiveNotifications.delete(download.uId); this.allActiveNotifications?.delete(download.uId);
}); });
download.show(); download.show();
} }

View file

@ -43,89 +43,99 @@ import {
UNREADS_AND_MENTIONS, UNREADS_AND_MENTIONS,
LEGACY_OFF, LEGACY_OFF,
TAB_LOGIN_CHANGED, TAB_LOGIN_CHANGED,
GET_DEVELOPER_MODE_SETTING,
} from 'common/communication'; } from 'common/communication';
import type {ExternalAPI} from 'types/externalAPI'; import type {ExternalAPI} from 'types/externalAPI';
const createListener: ExternalAPI['createListener'] = (channel: string, listener: (...args: never[]) => void) => { let legacyEnabled = false;
const listenerWithEvent = (_: IpcRendererEvent, ...args: unknown[]) => let legacyOff: () => void;
listener(...args as never[]);
ipcRenderer.on(channel, listenerWithEvent); ipcRenderer.invoke(GET_DEVELOPER_MODE_SETTING, 'forceLegacyAPI').then((force) => {
return () => { if (force) {
ipcRenderer.off(channel, listenerWithEvent); return;
}
const createListener: ExternalAPI['createListener'] = (channel: string, listener: (...args: never[]) => void) => {
const listenerWithEvent = (_: IpcRendererEvent, ...args: unknown[]) =>
listener(...args as never[]);
ipcRenderer.on(channel, listenerWithEvent);
return () => {
ipcRenderer.off(channel, listenerWithEvent);
};
}; };
};
const desktopAPI: DesktopAPI = { const desktopAPI: DesktopAPI = {
// Initialization // Initialization
isDev: () => ipcRenderer.invoke(GET_IS_DEV_MODE), isDev: () => ipcRenderer.invoke(GET_IS_DEV_MODE),
getAppInfo: () => { getAppInfo: () => {
// Using this signal as the sign to disable the legacy code, since it is run before the app is rendered // Using this signal as the sign to disable the legacy code, since it is run before the app is rendered
if (legacyEnabled) { if (legacyEnabled) {
legacyOff(); legacyOff?.();
} }
return ipcRenderer.invoke(GET_APP_INFO); return ipcRenderer.invoke(GET_APP_INFO);
}, },
reactAppInitialized: () => ipcRenderer.send(REACT_APP_INITIALIZED), reactAppInitialized: () => ipcRenderer.send(REACT_APP_INITIALIZED),
// Session // Session
setSessionExpired: (isExpired) => ipcRenderer.send(SESSION_EXPIRED, isExpired), setSessionExpired: (isExpired) => ipcRenderer.send(SESSION_EXPIRED, isExpired),
onUserActivityUpdate: (listener) => createListener(USER_ACTIVITY_UPDATE, listener), onUserActivityUpdate: (listener) => createListener(USER_ACTIVITY_UPDATE, listener),
onLogin: () => ipcRenderer.send(TAB_LOGIN_CHANGED, true), onLogin: () => ipcRenderer.send(TAB_LOGIN_CHANGED, true),
onLogout: () => ipcRenderer.send(TAB_LOGIN_CHANGED, false), onLogout: () => ipcRenderer.send(TAB_LOGIN_CHANGED, false),
// Unreads/mentions/notifications // Unreads/mentions/notifications
sendNotification: (title, body, channelId, teamId, url, silent, soundName) => sendNotification: (title, body, channelId, teamId, url, silent, soundName) =>
ipcRenderer.invoke(NOTIFY_MENTION, title, body, channelId, teamId, url, silent, soundName), ipcRenderer.invoke(NOTIFY_MENTION, title, body, channelId, teamId, url, silent, soundName),
onNotificationClicked: (listener) => createListener(NOTIFICATION_CLICKED, listener), onNotificationClicked: (listener) => createListener(NOTIFICATION_CLICKED, listener),
setUnreadsAndMentions: (isUnread, mentionCount) => ipcRenderer.send(UNREADS_AND_MENTIONS, isUnread, mentionCount), setUnreadsAndMentions: (isUnread, mentionCount) => ipcRenderer.send(UNREADS_AND_MENTIONS, isUnread, mentionCount),
// Navigation // Navigation
requestBrowserHistoryStatus: () => ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS), requestBrowserHistoryStatus: () => ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS),
onBrowserHistoryStatusUpdated: (listener) => createListener(BROWSER_HISTORY_STATUS_UPDATED, listener), onBrowserHistoryStatusUpdated: (listener) => createListener(BROWSER_HISTORY_STATUS_UPDATED, listener),
onBrowserHistoryPush: (listener) => createListener(BROWSER_HISTORY_PUSH, listener), onBrowserHistoryPush: (listener) => createListener(BROWSER_HISTORY_PUSH, listener),
sendBrowserHistoryPush: (path) => ipcRenderer.send(BROWSER_HISTORY_PUSH, path), sendBrowserHistoryPush: (path) => ipcRenderer.send(BROWSER_HISTORY_PUSH, path),
// Calls // Calls
joinCall: (opts) => ipcRenderer.invoke(CALLS_JOIN_CALL, opts), joinCall: (opts) => ipcRenderer.invoke(CALLS_JOIN_CALL, opts),
leaveCall: () => ipcRenderer.send(CALLS_LEAVE_CALL), leaveCall: () => ipcRenderer.send(CALLS_LEAVE_CALL),
callsWidgetConnected: (callID, sessionID) => ipcRenderer.send(CALLS_JOINED_CALL, callID, sessionID), callsWidgetConnected: (callID, sessionID) => ipcRenderer.send(CALLS_JOINED_CALL, callID, sessionID),
resizeCallsWidget: (width, height) => ipcRenderer.send(CALLS_WIDGET_RESIZE, width, height), resizeCallsWidget: (width, height) => ipcRenderer.send(CALLS_WIDGET_RESIZE, width, height),
sendCallsError: (err, callID, errMsg) => ipcRenderer.send(CALLS_ERROR, err, callID, errMsg), sendCallsError: (err, callID, errMsg) => ipcRenderer.send(CALLS_ERROR, err, callID, errMsg),
onCallsError: (listener) => createListener(CALLS_ERROR, listener), onCallsError: (listener) => createListener(CALLS_ERROR, listener),
getDesktopSources: (opts) => ipcRenderer.invoke(GET_DESKTOP_SOURCES, opts), getDesktopSources: (opts) => ipcRenderer.invoke(GET_DESKTOP_SOURCES, opts),
openScreenShareModal: () => ipcRenderer.send(DESKTOP_SOURCES_MODAL_REQUEST), openScreenShareModal: () => ipcRenderer.send(DESKTOP_SOURCES_MODAL_REQUEST),
onOpenScreenShareModal: (listener) => createListener(DESKTOP_SOURCES_MODAL_REQUEST, listener), onOpenScreenShareModal: (listener) => createListener(DESKTOP_SOURCES_MODAL_REQUEST, listener),
shareScreen: (sourceID, withAudio) => ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, sourceID, withAudio), shareScreen: (sourceID, withAudio) => ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, sourceID, withAudio),
onScreenShared: (listener) => createListener(CALLS_WIDGET_SHARE_SCREEN, listener), onScreenShared: (listener) => createListener(CALLS_WIDGET_SHARE_SCREEN, listener),
sendJoinCallRequest: (callId) => ipcRenderer.send(CALLS_JOIN_REQUEST, callId), sendJoinCallRequest: (callId) => ipcRenderer.send(CALLS_JOIN_REQUEST, callId),
onJoinCallRequest: (listener) => createListener(CALLS_JOIN_REQUEST, listener), onJoinCallRequest: (listener) => createListener(CALLS_JOIN_REQUEST, listener),
openLinkFromCalls: (url) => ipcRenderer.send(CALLS_LINK_CLICK, url), openLinkFromCalls: (url) => ipcRenderer.send(CALLS_LINK_CLICK, url),
focusPopout: () => ipcRenderer.send(CALLS_POPOUT_FOCUS), focusPopout: () => ipcRenderer.send(CALLS_POPOUT_FOCUS),
openThreadForCalls: (threadID) => ipcRenderer.send(CALLS_WIDGET_OPEN_THREAD, threadID), openThreadForCalls: (threadID) => ipcRenderer.send(CALLS_WIDGET_OPEN_THREAD, threadID),
onOpenThreadForCalls: (listener) => createListener(CALLS_WIDGET_OPEN_THREAD, listener), onOpenThreadForCalls: (listener) => createListener(CALLS_WIDGET_OPEN_THREAD, listener),
openStopRecordingModal: (channelID) => ipcRenderer.send(CALLS_WIDGET_OPEN_STOP_RECORDING_MODAL, channelID), openStopRecordingModal: (channelID) => ipcRenderer.send(CALLS_WIDGET_OPEN_STOP_RECORDING_MODAL, channelID),
onOpenStopRecordingModal: (listener) => createListener(CALLS_WIDGET_OPEN_STOP_RECORDING_MODAL, listener), onOpenStopRecordingModal: (listener) => createListener(CALLS_WIDGET_OPEN_STOP_RECORDING_MODAL, listener),
openCallsUserSettings: () => ipcRenderer.send(CALLS_WIDGET_OPEN_USER_SETTINGS), openCallsUserSettings: () => ipcRenderer.send(CALLS_WIDGET_OPEN_USER_SETTINGS),
onOpenCallsUserSettings: (listener) => createListener(CALLS_WIDGET_OPEN_USER_SETTINGS, listener), onOpenCallsUserSettings: (listener) => createListener(CALLS_WIDGET_OPEN_USER_SETTINGS, listener),
// Utility // Utility
unregister: (channel) => ipcRenderer.removeAllListeners(channel), unregister: (channel) => ipcRenderer.removeAllListeners(channel),
}; };
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI); contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
});
// Specific info for the testing environment // Specific info for the testing environment
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
@ -181,312 +191,318 @@ setInterval(() => {
webFrame.clearCache(); webFrame.clearCache();
}, CLEAR_CACHE_INTERVAL); }, CLEAR_CACHE_INTERVAL);
/**************************************************************************** ipcRenderer.invoke(GET_DEVELOPER_MODE_SETTING, 'forceNewAPI').then((force) => {
* LEGACY CODE BELOW if (force) {
* All of this code is deprecated and should be removed eventually
* Current it is there to support older versions of the web app
****************************************************************************
*/
/**
* Legacy helper functions
*/
const onLoad = () => {
if (document.getElementById('root') === null) {
console.warn('The guest is not assumed as mattermost-webapp');
return; return;
} }
watchReactAppUntilInitialized(() => {
console.warn('Legacy preload initialized');
ipcRenderer.send(REACT_APP_INITIALIZED);
ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS).then(sendHistoryButtonReturn);
});
};
const onStorageChanged = (e: StorageEvent) => { /****************************************************************************
if (e.key === '__login__' && e.storageArea === localStorage && e.newValue) { * LEGACY CODE BELOW
ipcRenderer.send(APP_LOGGED_IN); * All of this code is deprecated and should be removed eventually
} * Current it is there to support older versions of the web app
if (e.key === '__logout__' && e.storageArea === localStorage && e.newValue) { ****************************************************************************
ipcRenderer.send(APP_LOGGED_OUT); */
}
};
const isReactAppInitialized = () => { /**
const initializedRoot = * Legacy helper functions
document.querySelector('#root.channel-view') || // React 16 webapp */
document.querySelector('#root .signup-team__container') || // React 16 login
document.querySelector('div[data-reactroot]'); // Older React apps
if (initializedRoot === null) {
return false;
}
return initializedRoot.children.length !== 0;
};
const watchReactAppUntilInitialized = (callback: () => void) => { const onLoad = () => {
let count = 0; if (document.getElementById('root') === null) {
const interval = 500; console.warn('The guest is not assumed as mattermost-webapp');
const timeout = 30000; return;
const timer = setInterval(() => {
count += interval;
if (isReactAppInitialized() || count >= timeout) { // assumed as webapp has been initialized.
clearTimeout(timer);
callback();
} }
}, interval);
};
const checkUnread = () => {
if (isReactAppInitialized()) {
findUnread();
} else {
watchReactAppUntilInitialized(() => { watchReactAppUntilInitialized(() => {
console.warn('Legacy preload initialized');
ipcRenderer.send(REACT_APP_INITIALIZED);
ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS).then(sendHistoryButtonReturn);
});
};
const onStorageChanged = (e: StorageEvent) => {
if (e.key === '__login__' && e.storageArea === localStorage && e.newValue) {
ipcRenderer.send(APP_LOGGED_IN);
}
if (e.key === '__logout__' && e.storageArea === localStorage && e.newValue) {
ipcRenderer.send(APP_LOGGED_OUT);
}
};
const isReactAppInitialized = () => {
const initializedRoot =
document.querySelector('#root.channel-view') || // React 16 webapp
document.querySelector('#root .signup-team__container') || // React 16 login
document.querySelector('div[data-reactroot]'); // Older React apps
if (initializedRoot === null) {
return false;
}
return initializedRoot.children.length !== 0;
};
const watchReactAppUntilInitialized = (callback: () => void) => {
let count = 0;
const interval = 500;
const timeout = 30000;
const timer = setInterval(() => {
count += interval;
if (isReactAppInitialized() || count >= timeout) { // assumed as webapp has been initialized.
clearTimeout(timer);
callback();
}
}, interval);
};
const checkUnread = () => {
if (isReactAppInitialized()) {
findUnread(); findUnread();
}); } else {
} watchReactAppUntilInitialized(() => {
}; findUnread();
});
}
};
const findUnread = () => { const findUnread = () => {
const classes = ['team-container unread', 'SidebarChannel unread', 'sidebar-item unread-title']; const classes = ['team-container unread', 'SidebarChannel unread', 'sidebar-item unread-title'];
const isUnread = classes.some((classPair) => { const isUnread = classes.some((classPair) => {
const result = document.getElementsByClassName(classPair); const result = document.getElementsByClassName(classPair);
return result && result.length > 0; return result && result.length > 0;
});
ipcRenderer.send(UNREAD_RESULT, isUnread);
};
let sessionExpired: boolean;
const getUnreadCount = () => {
// LHS not found => Log out => Count should be 0, but session may be expired.
let isExpired;
if (document.getElementById('sidebar-left') === null) {
const extraParam = (new URLSearchParams(window.location.search)).get('extra');
isExpired = extraParam === 'expired';
} else {
isExpired = false;
}
if (isExpired !== sessionExpired) {
sessionExpired = isExpired;
ipcRenderer.send(SESSION_EXPIRED, sessionExpired);
}
};
/**
* Legacy message passing code - can be running alongside the new API stuff
*/
// Disabling no-explicit-any for this legacy code
// eslint-disable-next-line @typescript-eslint/no-explicit-any
window.addEventListener('message', ({origin, data = {}}: {origin?: string; data?: {type?: string; message?: any}} = {}) => {
const {type, message = {}} = data;
if (origin !== window.location.origin) {
return;
}
switch (type) {
case 'webapp-ready':
case 'get-app-version': {
// register with the webapp to enable custom integration functionality
ipcRenderer.invoke(GET_APP_INFO).then((info) => {
console.log(`registering ${info.name} v${info.version} with the server`);
window.postMessage(
{
type: 'register-desktop',
message: info,
},
window.location.origin || '*',
);
});
break;
}
case 'dispatch-notification': {
const {title, body, channel, teamId, url, silent, data: messageData} = message;
channels.set(channel.id, channel);
ipcRenderer.invoke(NOTIFY_MENTION, title, body, channel.id, teamId, url, silent, messageData.soundName);
break;
}
case BROWSER_HISTORY_PUSH: {
const {path} = message as {path: string};
ipcRenderer.send(BROWSER_HISTORY_PUSH, path);
break;
}
case 'history-button': {
ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS).then(sendHistoryButtonReturn);
break;
}
case CALLS_LINK_CLICK: {
ipcRenderer.send(CALLS_LINK_CLICK, message.link);
break;
}
case GET_DESKTOP_SOURCES: {
ipcRenderer.invoke(GET_DESKTOP_SOURCES, message).then(sendDesktopSourcesResult);
break;
}
case CALLS_WIDGET_SHARE_SCREEN: {
ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, message.sourceID, message.withAudio);
break;
}
case CALLS_JOIN_CALL: {
ipcRenderer.invoke(CALLS_JOIN_CALL, message).then(sendCallsJoinedCall);
break;
}
case CALLS_JOINED_CALL: {
ipcRenderer.send(CALLS_JOINED_CALL, message.callID, message.sessionID);
break;
}
case CALLS_JOIN_REQUEST: {
ipcRenderer.send(CALLS_JOIN_REQUEST, message.callID);
break;
}
case CALLS_WIDGET_RESIZE: {
ipcRenderer.send(CALLS_WIDGET_RESIZE, message.width, message.height);
break;
}
case CALLS_ERROR: {
ipcRenderer.send(CALLS_ERROR, message.err, message.callID, message.errMsg);
break;
}
case CALLS_WIDGET_CHANNEL_LINK_CLICK:
case CALLS_LEAVE_CALL:
case DESKTOP_SOURCES_MODAL_REQUEST:
case CALLS_POPOUT_FOCUS: {
ipcRenderer.send(type);
}
}
}); });
ipcRenderer.send(UNREAD_RESULT, isUnread);
};
let sessionExpired: boolean; // Legacy support to hold the full channel object so that it can be used for the click event
const getUnreadCount = () => { const channels: Map<string, {id: string}> = new Map();
// LHS not found => Log out => Count should be 0, but session may be expired. ipcRenderer.on(NOTIFICATION_CLICKED, (event, channelId, teamId, url) => {
let isExpired; const channel = channels.get(channelId) ?? {id: channelId};
if (document.getElementById('sidebar-left') === null) { channels.delete(channelId);
const extraParam = (new URLSearchParams(window.location.search)).get('extra'); window.postMessage(
isExpired = extraParam === 'expired'; {
} else { type: NOTIFICATION_CLICKED,
isExpired = false; message: {
} channel,
if (isExpired !== sessionExpired) { teamId,
sessionExpired = isExpired; url,
ipcRenderer.send(SESSION_EXPIRED, sessionExpired);
}
};
/**
* Legacy message passing code - can be running alongside the new API stuff
*/
// Disabling no-explicit-any for this legacy code
// eslint-disable-next-line @typescript-eslint/no-explicit-any
window.addEventListener('message', ({origin, data = {}}: {origin?: string; data?: {type?: string; message?: any}} = {}) => {
const {type, message = {}} = data;
if (origin !== window.location.origin) {
return;
}
switch (type) {
case 'webapp-ready':
case 'get-app-version': {
// register with the webapp to enable custom integration functionality
ipcRenderer.invoke(GET_APP_INFO).then((info) => {
console.log(`registering ${info.name} v${info.version} with the server`);
window.postMessage(
{
type: 'register-desktop',
message: info,
}, },
window.location.origin || '*',
);
});
break;
}
case 'dispatch-notification': {
const {title, body, channel, teamId, url, silent, data: messageData} = message;
channels.set(channel.id, channel);
ipcRenderer.invoke(NOTIFY_MENTION, title, body, channel.id, teamId, url, silent, messageData.soundName);
break;
}
case BROWSER_HISTORY_PUSH: {
const {path} = message as {path: string};
ipcRenderer.send(BROWSER_HISTORY_PUSH, path);
break;
}
case 'history-button': {
ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS).then(sendHistoryButtonReturn);
break;
}
case CALLS_LINK_CLICK: {
ipcRenderer.send(CALLS_LINK_CLICK, message.link);
break;
}
case GET_DESKTOP_SOURCES: {
ipcRenderer.invoke(GET_DESKTOP_SOURCES, message).then(sendDesktopSourcesResult);
break;
}
case CALLS_WIDGET_SHARE_SCREEN: {
ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, message.sourceID, message.withAudio);
break;
}
case CALLS_JOIN_CALL: {
ipcRenderer.invoke(CALLS_JOIN_CALL, message).then(sendCallsJoinedCall);
break;
}
case CALLS_JOINED_CALL: {
ipcRenderer.send(CALLS_JOINED_CALL, message.callID, message.sessionID);
break;
}
case CALLS_JOIN_REQUEST: {
ipcRenderer.send(CALLS_JOIN_REQUEST, message.callID);
break;
}
case CALLS_WIDGET_RESIZE: {
ipcRenderer.send(CALLS_WIDGET_RESIZE, message.width, message.height);
break;
}
case CALLS_ERROR: {
ipcRenderer.send(CALLS_ERROR, message.err, message.callID, message.errMsg);
break;
}
case CALLS_WIDGET_CHANNEL_LINK_CLICK:
case CALLS_LEAVE_CALL:
case DESKTOP_SOURCES_MODAL_REQUEST:
case CALLS_POPOUT_FOCUS: {
ipcRenderer.send(type);
}
}
});
// Legacy support to hold the full channel object so that it can be used for the click event
const channels: Map<string, {id: string}> = new Map();
ipcRenderer.on(NOTIFICATION_CLICKED, (event, channelId, teamId, url) => {
const channel = channels.get(channelId) ?? {id: channelId};
channels.delete(channelId);
window.postMessage(
{
type: NOTIFICATION_CLICKED,
message: {
channel,
teamId,
url,
}, },
}, window.location.origin,
window.location.origin, );
); });
});
ipcRenderer.on(BROWSER_HISTORY_PUSH, (event, pathName) => { ipcRenderer.on(BROWSER_HISTORY_PUSH, (event, pathName) => {
window.postMessage( window.postMessage(
{ {
type: 'browser-history-push-return', type: 'browser-history-push-return',
message: { message: {
pathName, pathName,
},
}, },
}, window.location.origin,
window.location.origin, );
); });
});
const sendHistoryButtonReturn = (status: {canGoBack: boolean; canGoForward: boolean}) => { const sendHistoryButtonReturn = (status: {canGoBack: boolean; canGoForward: boolean}) => {
window.postMessage( window.postMessage(
{ {
type: 'history-button-return', type: 'history-button-return',
message: { message: {
enableBack: status.canGoBack, enableBack: status.canGoBack,
enableForward: status.canGoForward, enableForward: status.canGoForward,
},
}, },
}, window.location.origin,
window.location.origin, );
); };
};
ipcRenderer.on(BROWSER_HISTORY_STATUS_UPDATED, (event, canGoBack, canGoForward) => sendHistoryButtonReturn({canGoBack, canGoForward})); ipcRenderer.on(BROWSER_HISTORY_STATUS_UPDATED, (event, canGoBack, canGoForward) => sendHistoryButtonReturn({canGoBack, canGoForward}));
const sendDesktopSourcesResult = (sources: Array<{ const sendDesktopSourcesResult = (sources: Array<{
id: string; id: string;
name: string; name: string;
thumbnailURL: string; thumbnailURL: string;
}>) => { }>) => {
window.postMessage( window.postMessage(
{ {
type: DESKTOP_SOURCES_RESULT, type: DESKTOP_SOURCES_RESULT,
message: sources, message: sources,
}, },
window.location.origin, window.location.origin,
); );
}; };
const sendCallsJoinedCall = (message: {callID: string; sessionID: string}) => { const sendCallsJoinedCall = (message: {callID: string; sessionID: string}) => {
window.postMessage( window.postMessage(
{ {
type: CALLS_JOINED_CALL, type: CALLS_JOINED_CALL,
message, message,
}, },
window.location.origin, window.location.origin,
); );
}; };
ipcRenderer.on(CALLS_JOIN_REQUEST, (_, callID) => { ipcRenderer.on(CALLS_JOIN_REQUEST, (_, callID) => {
window.postMessage( window.postMessage(
{ {
type: CALLS_JOIN_REQUEST, type: CALLS_JOIN_REQUEST,
message: {callID}, message: {callID},
}, },
window.location.origin, window.location.origin,
); );
});
ipcRenderer.on(DESKTOP_SOURCES_MODAL_REQUEST, () => {
window.postMessage(
{
type: DESKTOP_SOURCES_MODAL_REQUEST,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_WIDGET_SHARE_SCREEN, (_, sourceID, withAudio) => {
window.postMessage(
{
type: CALLS_WIDGET_SHARE_SCREEN,
message: {sourceID, withAudio},
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_ERROR, (_, err, callID, errMsg) => {
window.postMessage(
{
type: CALLS_ERROR,
message: {err, callID, errMsg},
},
window.location.origin,
);
});
// push user activity updates to the webapp
ipcRenderer.on(USER_ACTIVITY_UPDATE, (event, userIsActive, isSystemEvent) => {
if (window.location.origin !== 'null') {
window.postMessage({type: USER_ACTIVITY_UPDATE, message: {userIsActive, manual: isSystemEvent}}, window.location.origin);
}
});
/**
* Legacy functionality that needs to be disabled with the new API
*/
legacyEnabled = true;
ipcRenderer.on(IS_UNREAD, checkUnread);
const unreadInterval = setInterval(getUnreadCount, 1000);
window.addEventListener('storage', onStorageChanged);
window.addEventListener('load', onLoad);
legacyOff = () => {
ipcRenderer.send(LEGACY_OFF);
ipcRenderer.off(IS_UNREAD, checkUnread);
clearInterval(unreadInterval);
window.removeEventListener('storage', onStorageChanged);
window.removeEventListener('load', onLoad);
legacyEnabled = false;
console.log('New API preload initialized');
};
}); });
ipcRenderer.on(DESKTOP_SOURCES_MODAL_REQUEST, () => {
window.postMessage(
{
type: DESKTOP_SOURCES_MODAL_REQUEST,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_WIDGET_SHARE_SCREEN, (_, sourceID, withAudio) => {
window.postMessage(
{
type: CALLS_WIDGET_SHARE_SCREEN,
message: {sourceID, withAudio},
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_ERROR, (_, err, callID, errMsg) => {
window.postMessage(
{
type: CALLS_ERROR,
message: {err, callID, errMsg},
},
window.location.origin,
);
});
// push user activity updates to the webapp
ipcRenderer.on(USER_ACTIVITY_UPDATE, (event, userIsActive, isSystemEvent) => {
if (window.location.origin !== 'null') {
window.postMessage({type: USER_ACTIVITY_UPDATE, message: {userIsActive, manual: isSystemEvent}}, window.location.origin);
}
});
/**
* Legacy functionality that needs to be disabled with the new API
*/
let legacyEnabled = true;
ipcRenderer.on(IS_UNREAD, checkUnread);
const unreadInterval = setInterval(getUnreadCount, 1000);
window.addEventListener('storage', onStorageChanged);
window.addEventListener('load', onLoad);
function legacyOff() {
ipcRenderer.send(LEGACY_OFF);
ipcRenderer.off(IS_UNREAD, checkUnread);
clearInterval(unreadInterval);
window.removeEventListener('storage', onStorageChanged);
window.removeEventListener('load', onLoad);
legacyEnabled = false;
console.log('New API preload initialized');
}

View file

@ -92,6 +92,7 @@ import {
GET_MEDIA_ACCESS_STATUS, GET_MEDIA_ACCESS_STATUS,
VIEW_FINISHED_RESIZING, VIEW_FINISHED_RESIZING,
GET_NONCE, GET_NONCE,
IS_DEVELOPER_MODE_ENABLED,
} from 'common/communication'; } from 'common/communication';
console.log('Preload initialized'); console.log('Preload initialized');
@ -126,6 +127,7 @@ contextBridge.exposeInMainWorld('desktop', {
checkForUpdates: () => ipcRenderer.send(CHECK_FOR_UPDATES), checkForUpdates: () => ipcRenderer.send(CHECK_FOR_UPDATES),
updateConfiguration: (saveQueueItems) => ipcRenderer.send(UPDATE_CONFIGURATION, saveQueueItems), updateConfiguration: (saveQueueItems) => ipcRenderer.send(UPDATE_CONFIGURATION, saveQueueItems),
getNonce: () => ipcRenderer.invoke(GET_NONCE), getNonce: () => ipcRenderer.invoke(GET_NONCE),
isDeveloperModeEnabled: () => ipcRenderer.invoke(IS_DEVELOPER_MODE_ENABLED),
updateServerOrder: (serverOrder) => ipcRenderer.send(UPDATE_SERVER_ORDER, serverOrder), updateServerOrder: (serverOrder) => ipcRenderer.send(UPDATE_SERVER_ORDER, serverOrder),
updateTabOrder: (serverId, viewOrder) => ipcRenderer.send(UPDATE_TAB_ORDER, serverId, viewOrder), updateTabOrder: (serverId, viewOrder) => ipcRenderer.send(UPDATE_TAB_ORDER, serverId, viewOrder),

View file

@ -88,12 +88,16 @@ export function getLocalPreload(file: string) {
return path.join(app.getAppPath(), file); return path.join(app.getAppPath(), file);
} }
export function composeUserAgent() { export function composeUserAgent(browserMode?: boolean) {
const baseUserAgent = app.userAgentFallback.split(' '); const baseUserAgent = app.userAgentFallback.split(' ');
// filter out the Mattermost tag that gets added earlier on // filter out the Mattermost tag that gets added earlier on
const filteredUserAgent = baseUserAgent.filter((ua) => !ua.startsWith('Mattermost')); const filteredUserAgent = baseUserAgent.filter((ua) => !ua.startsWith('Mattermost'));
if (browserMode) {
return filteredUserAgent.join(' ');
}
return `${filteredUserAgent.join(' ')} Mattermost/${app.getVersion()}`; return `${filteredUserAgent.join(' ')} Mattermost/${app.getVersion()}`;
} }

View file

@ -58,6 +58,9 @@ jest.mock('../utils', () => ({
composeUserAgent: () => 'Mattermost/5.0.0', composeUserAgent: () => 'Mattermost/5.0.0',
shouldHaveBackBar: jest.fn(), shouldHaveBackBar: jest.fn(),
})); }));
jest.mock('main/developerMode', () => ({
get: jest.fn(),
}));
const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'}); const server = new MattermostServer({name: 'server_name', url: 'http://server-1.com'});
const view = new MessagingView(server, true); const view = new MessagingView(server, true);

View file

@ -24,6 +24,7 @@ import ServerManager from 'common/servers/serverManager';
import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants'; import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants';
import {isInternalURL, parseURL} from 'common/utils/url'; import {isInternalURL, parseURL} from 'common/utils/url';
import type {MattermostView} from 'common/views/View'; import type {MattermostView} from 'common/views/View';
import DeveloperMode from 'main/developerMode';
import {getServerAPI} from 'main/server/serverAPI'; import {getServerAPI} from 'main/server/serverAPI';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
@ -52,7 +53,7 @@ export class MattermostBrowserView extends EventEmitter {
private atRoot: boolean; private atRoot: boolean;
private options: BrowserViewConstructorOptions; private options: BrowserViewConstructorOptions;
private removeLoading?: NodeJS.Timeout; private removeLoading?: NodeJS.Timeout;
private contextMenu: ContextMenu; private contextMenu?: ContextMenu;
private status?: Status; private status?: Status;
private retryLoad?: NodeJS.Timeout; private retryLoad?: NodeJS.Timeout;
private maxRetries: number; private maxRetries: number;
@ -65,7 +66,7 @@ export class MattermostBrowserView extends EventEmitter {
const preload = getLocalPreload('externalAPI.js'); const preload = getLocalPreload('externalAPI.js');
this.options = Object.assign({}, options); this.options = Object.assign({}, options);
this.options.webPreferences = { this.options.webPreferences = {
preload, preload: DeveloperMode.get('browserOnly') ? undefined : preload,
additionalArguments: [ additionalArguments: [
`version=${app.getVersion()}`, `version=${app.getVersion()}`,
`appName=${app.name}`, `appName=${app.name}`,
@ -99,7 +100,10 @@ export class MattermostBrowserView extends EventEmitter {
WebContentsEventManager.addWebContentsEventListeners(this.browserView.webContents); WebContentsEventManager.addWebContentsEventListeners(this.browserView.webContents);
this.contextMenu = new ContextMenu({}, this.browserView); if (!DeveloperMode.get('disableContextMenu')) {
this.contextMenu = new ContextMenu({}, this.browserView);
}
this.maxRetries = MAX_SERVER_RETRIES; this.maxRetries = MAX_SERVER_RETRIES;
this.altPressStatus = false; this.altPressStatus = false;
@ -192,7 +196,7 @@ export class MattermostBrowserView extends EventEmitter {
loadURL = this.view.url.toString(); loadURL = this.view.url.toString();
} }
this.log.verbose(`Loading ${loadURL}`); this.log.verbose(`Loading ${loadURL}`);
const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent(DeveloperMode.get('browserOnly'))});
loading.then(this.loadSuccess(loadURL)).catch((err) => { loading.then(this.loadSuccess(loadURL)).catch((err) => {
if (err.code && err.code.startsWith('ERR_CERT')) { if (err.code && err.code.startsWith('ERR_CERT')) {
MainWindow.sendToRenderer(LOAD_FAILED, this.id, err.toString(), loadURL.toString()); MainWindow.sendToRenderer(LOAD_FAILED, this.id, err.toString(), loadURL.toString());
@ -427,7 +431,7 @@ export class MattermostBrowserView extends EventEmitter {
if (!this.browserView || !this.browserView.webContents) { if (!this.browserView || !this.browserView.webContents) {
return; return;
} }
const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent()}); const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent(DeveloperMode.get('browserOnly'))});
loading.then(this.loadSuccess(loadURL)).catch((err) => { loading.then(this.loadSuccess(loadURL)).catch((err) => {
if (this.maxRetries-- > 0) { if (this.maxRetries-- > 0) {
this.loadRetry(loadURL, err); this.loadRetry(loadURL, err);

View file

@ -34,6 +34,7 @@ import {
LEGACY_OFF, LEGACY_OFF,
UNREADS_AND_MENTIONS, UNREADS_AND_MENTIONS,
TAB_LOGIN_CHANGED, TAB_LOGIN_CHANGED,
DEVELOPER_MODE_UPDATED,
} from 'common/communication'; } from 'common/communication';
import Config from 'common/config'; import Config from 'common/config';
import {Logger} from 'common/log'; import {Logger} from 'common/log';
@ -45,10 +46,13 @@ import Utils from 'common/utils/util';
import type {MattermostView} from 'common/views/View'; import type {MattermostView} from 'common/views/View';
import {TAB_MESSAGING} from 'common/views/View'; import {TAB_MESSAGING} from 'common/views/View';
import {flushCookiesStore} from 'main/app/utils'; import {flushCookiesStore} from 'main/app/utils';
import DeveloperMode from 'main/developerMode';
import {localizeMessage} from 'main/i18nManager'; import {localizeMessage} from 'main/i18nManager';
import PermissionsManager from 'main/permissionsManager'; import PermissionsManager from 'main/permissionsManager';
import MainWindow from 'main/windows/mainWindow'; import MainWindow from 'main/windows/mainWindow';
import type {DeveloperSettings} from 'types/settings';
import LoadingScreen from './loadingScreen'; import LoadingScreen from './loadingScreen';
import {MattermostBrowserView} from './MattermostBrowserView'; import {MattermostBrowserView} from './MattermostBrowserView';
import modalManager from './modalManager'; import modalManager from './modalManager';
@ -91,6 +95,7 @@ export class ViewManager {
ipcMain.on(SWITCH_TAB, (event, viewId) => this.showById(viewId)); ipcMain.on(SWITCH_TAB, (event, viewId) => this.showById(viewId));
ServerManager.on(SERVERS_UPDATE, this.handleReloadConfiguration); ServerManager.on(SERVERS_UPDATE, this.handleReloadConfiguration);
DeveloperMode.on(DEVELOPER_MODE_UPDATED, this.handleDeveloperModeUpdated);
} }
private init = () => { private init = () => {
@ -99,6 +104,17 @@ export class ViewManager {
this.showInitial(); this.showInitial();
}; };
private handleDeveloperModeUpdated = (json: DeveloperSettings) => {
log.debug('handleDeveloperModeUpdated', json);
if (['browserOnly', 'disableContextMenu', 'forceLegacyAPI', 'forceNewAPI'].some((key) => Object.hasOwn(json, key))) {
this.views.forEach((view) => view.destroy());
this.views = new Map();
this.closedViews = new Map();
this.init();
}
};
getView = (viewId: string) => { getView = (viewId: string) => {
return this.views.get(viewId); return this.views.get(viewId);
}; };

View file

@ -0,0 +1,36 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
// eslint-disable-next-line no-restricted-imports
import {OverlayTrigger, Tooltip} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import 'renderer/css/components/DeveloperModeIndicator.scss';
export default function DeveloperModeIndicator({developerMode, darkMode}: {developerMode: boolean; darkMode: boolean}) {
if (!developerMode) {
return null;
}
return (
<OverlayTrigger
placement='left'
overlay={
<Tooltip id='DeveloperModeIndicator__tooltip'>
<FormattedMessage
id='renderer.components.developerModeIndicator.tooltip'
defaultMessage='Developer mode is enabled. You should only have this enabled if a Mattermost developer has instructed you to.'
/>
</Tooltip>
}
>
<div className={classNames('DeveloperModeIndicator', {darkMode})}>
<i className='icon-flask-outline'/>
<span className='DeveloperModeIndicator__badge'/>
</div>
</OverlayTrigger>
);
}

View file

@ -12,6 +12,7 @@ import {injectIntl} from 'react-intl';
import type {UniqueView, UniqueServer} from 'types/config'; import type {UniqueView, UniqueServer} from 'types/config';
import type {DownloadedItems} from 'types/downloads'; import type {DownloadedItems} from 'types/downloads';
import DeveloperModeIndicator from './DeveloperModeIndicator';
import DownloadsDropdownButton from './DownloadsDropdown/DownloadsDropdownButton'; import DownloadsDropdownButton from './DownloadsDropdown/DownloadsDropdownButton';
import ErrorView from './ErrorView'; import ErrorView from './ErrorView';
import ExtraBar from './ExtraBar'; import ExtraBar from './ExtraBar';
@ -55,6 +56,7 @@ type State = {
showDownloadsBadge: boolean; showDownloadsBadge: boolean;
hasDownloads: boolean; hasDownloads: boolean;
threeDotsIsFocused: boolean; threeDotsIsFocused: boolean;
developerMode: boolean;
}; };
type TabViewStatus = { type TabViewStatus = {
@ -88,6 +90,7 @@ class MainPage extends React.PureComponent<Props, State> {
showDownloadsBadge: false, showDownloadsBadge: false,
hasDownloads: false, hasDownloads: false,
threeDotsIsFocused: false, threeDotsIsFocused: false,
developerMode: false,
}; };
} }
@ -263,6 +266,10 @@ class MainPage extends React.PureComponent<Props, State> {
} }
window.addEventListener('click', this.handleCloseDropdowns); window.addEventListener('click', this.handleCloseDropdowns);
window.desktop.isDeveloperModeEnabled().then((developerMode) => {
this.setState({developerMode});
});
} }
componentWillUnmount() { componentWillUnmount() {
@ -471,6 +478,10 @@ class MainPage extends React.PureComponent<Props, State> {
/> />
)} )}
{tabsRow} {tabsRow}
<DeveloperModeIndicator
darkMode={this.props.darkMode}
developerMode={this.state.developerMode}
/>
{downloadsDropdownButton} {downloadsDropdownButton}
{window.process.platform !== 'darwin' && this.state.fullScreen && {window.process.platform !== 'darwin' && this.state.fullScreen &&
<span className='title-bar-btns'> <span className='title-bar-btns'>

View file

@ -0,0 +1,54 @@
.DeveloperModeIndicator {
align-items: center;
background: transparent;
border-radius: 4px;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
margin: 4px;
position: relative;
width: 32px;
i {
color: rgba(63, 67, 80, 0.56);
cursor: pointer;
font-size: 21px;
line-height: 21px;
}
&:hover, &:focus {
i {
color: rgba(63, 67, 80, 0.78);
}
}
.DeveloperModeIndicator__badge {
background: rgba(210, 75, 78, 1);
border-radius: 10px;
width: 10px;
height: 10px;
position: absolute;
top: 5px;
right: 5px;
}
&.darkMode {
i {
color: rgba(221, 223, 228, 0.56);
}
&:hover, &:focus {
i {
color: rgba(221, 223, 228, 0.78);
}
}
}
}
#DeveloperModeIndicator__tooltip {
> .tooltip-inner {
max-width: none;
}
}

View file

@ -8,3 +8,12 @@ export type SaveQueueItem = {
key: keyof CombinedConfig; key: keyof CombinedConfig;
data: CombinedConfig[keyof CombinedConfig]; data: CombinedConfig[keyof CombinedConfig];
}; };
export type DeveloperSettings = {
browserOnly?: boolean;
disableNotificationStorage?: boolean;
disableUserActivityMonitor?: boolean;
disableContextMenu?: boolean;
forceLegacyAPI?: boolean;
forceNewAPI?: boolean;
};

View file

@ -45,6 +45,7 @@ declare global {
checkForUpdates: () => void; checkForUpdates: () => void;
updateConfiguration: (saveQueueItems: SaveQueueItem[]) => void; updateConfiguration: (saveQueueItems: SaveQueueItem[]) => void;
getNonce: () => Promise<string | undefined>; getNonce: () => Promise<string | undefined>;
isDeveloperModeEnabled: () => Promise<boolean>;
updateServerOrder: (serverOrder: string[]) => Promise<void>; updateServerOrder: (serverOrder: string[]) => Promise<void>;
updateTabOrder: (serverId: string, viewOrder: string[]) => Promise<void>; updateTabOrder: (serverId: string, viewOrder: string[]) => Promise<void>;