[MM-55152] Add new Desktop API endpoints, improve preload script, some clean-up (#2900)

* Add constants for app info, add to API

* Migrate history button

* Converted calls API over to context bridge, removed some unnecessary logging

* Convert to TS, add types for web app to consume

* Fix tests, prune

* Fix lint

* More changes to support the legacy API

* Force legacy code off, add support for unreads/mentions/expired through the API

* Fix issues with cross-tab login, removed need for log in/log out signalling

* Fixed test, typos

* Change package name for types

* Add some other stuff to the types

* PR feedback

* More feedback

* Use npm package

* Change types and API to provide off listeners

* Version number

* Lock

* Fix typo

* Add sessionID for calls
This commit is contained in:
Devin Binnie 2023-12-13 09:39:46 -05:00 committed by GitHub
parent 675ec6d661
commit 0cab09b7f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1071 additions and 1040 deletions

View file

@ -103,7 +103,7 @@
"src/main/downloadURL.ts",
"src/main/SpellChecker.ts",
"src/main/menus/app.ts",
"src/main/preload/mattermost.js",
"src/main/preload/externalAPI.js",
"src/renderer/components/RemoveServerModal.tsx",
"src/renderer/components/MainPage.tsx",
"src/renderer/components/HoveringURL.tsx",

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ fastlane/README.md
fastlane/report.xml
*.provisionprofile
*.tsbuildinfo

67
api-types/index.ts Normal file
View file

@ -0,0 +1,67 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type DesktopSourcesOptions = {
types: Array<'screen' | 'window'>;
thumbnailSize?: {height: number; width: number};
fetchWindowIcons?: boolean;
};
export type DesktopCaptureSource = {
id: string;
name: string;
thumbnailURL: string;
};
export type DesktopAPI = {
// Initialization
isDev: () => Promise<boolean>;
getAppInfo: () => Promise<{name: string; version: string}>;
reactAppInitialized: () => void;
// Session
setSessionExpired: (isExpired: boolean) => void;
onUserActivityUpdate: (listener: (
userIsActive: boolean,
idleTime: number,
isSystemEvent: boolean,
) => void) => () => void;
// Unreads/mentions/notifications
sendNotification: (title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, soundName: string) => void;
onNotificationClicked: (listener: (channelId: string, teamId: string, url: string) => void) => () => void;
setUnreadsAndMentions: (isUnread: boolean, mentionCount: number) => void;
// Navigation
requestBrowserHistoryStatus: () => Promise<{canGoBack: boolean; canGoForward: boolean}>;
onBrowserHistoryStatusUpdated: (listener: (canGoBack: boolean, canGoForward: boolean) => void) => () => void;
onBrowserHistoryPush: (listener: (pathName: string) => void) => () => void;
sendBrowserHistoryPush: (path: string) => void;
// Calls widget
openLinkFromCallsWidget: (url: string) => void;
openScreenShareModal: () => void;
onScreenShared: (listener: (sourceID: string, withAudio: boolean) => void) => () => void;
callsWidgetConnected: (callID: string, sessionID: string) => void;
onJoinCallRequest: (listener: (callID: string) => void) => () => void;
resizeCallsWidget: (width: number, height: number) => void;
focusPopout: () => void;
leaveCall: () => void;
sendCallsError: (err: string, callID?: string, errMsg?: string) => void;
// Calls plugin
getDesktopSources: (opts: DesktopSourcesOptions) => Promise<DesktopCaptureSource[]>;
onOpenScreenShareModal: (listener: () => void) => () => void;
shareScreen: (sourceID: string, withAudi: boolean) => void;
joinCall: (opts: {
callID: string;
title: string;
rootID: string;
channelURL: string;
}) => Promise<{callID: string; sessionID: string}>;
sendJoinCallRequest: (callId: string) => void;
onCallsError: (listener: (err: string, callID?: string, errMsg?: string) => void) => () => void;
// Utility
unregister: (channel: string) => void;
}

57
api-types/lib/index.d.ts vendored Normal file
View file

@ -0,0 +1,57 @@
export declare type DesktopSourcesOptions = {
types: Array<'screen' | 'window'>;
thumbnailSize?: {
height: number;
width: number;
};
fetchWindowIcons?: boolean;
};
export declare type DesktopCaptureSource = {
id: string;
name: string;
thumbnailURL: string;
};
export declare type DesktopAPI = {
isDev: () => Promise<boolean>;
getAppInfo: () => Promise<{
name: string;
version: string;
}>;
reactAppInitialized: () => void;
setSessionExpired: (isExpired: boolean) => void;
onUserActivityUpdate: (listener: (userIsActive: boolean, idleTime: number, isSystemEvent: boolean) => void) => () => void;
sendNotification: (title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, soundName: string) => void;
onNotificationClicked: (listener: (channelId: string, teamId: string, url: string) => void) => () => void;
setUnreadsAndMentions: (isUnread: boolean, mentionCount: number) => void;
requestBrowserHistoryStatus: () => Promise<{
canGoBack: boolean;
canGoForward: boolean;
}>;
onBrowserHistoryStatusUpdated: (listener: (canGoBack: boolean, canGoForward: boolean) => void) => () => void;
onBrowserHistoryPush: (listener: (pathName: string) => void) => () => void;
sendBrowserHistoryPush: (path: string) => void;
openLinkFromCallsWidget: (url: string) => void;
openScreenShareModal: () => void;
onScreenShared: (listener: (sourceID: string, withAudio: boolean) => void) => () => void;
callsWidgetConnected: (callID: string, sessionID: string) => void;
onJoinCallRequest: (listener: (callID: string) => void) => () => void;
resizeCallsWidget: (width: number, height: number) => void;
focusPopout: () => void;
leaveCall: () => void;
sendCallsError: (err: string, callID?: string, errMsg?: string) => void;
getDesktopSources: (opts: DesktopSourcesOptions) => Promise<DesktopCaptureSource[]>;
onOpenScreenShareModal: (listener: () => void) => () => void;
shareScreen: (sourceID: string, withAudi: boolean) => void;
joinCall: (opts: {
callID: string;
title: string;
rootID: string;
channelURL: string;
}) => Promise<{
callID: string;
sessionID: string;
}>;
sendJoinCallRequest: (callId: string) => void;
onCallsError: (listener: (err: string, callID?: string, errMsg?: string) => void) => () => void;
unregister: (channel: string) => void;
};

4
api-types/lib/index.js Normal file
View file

@ -0,0 +1,4 @@
"use strict";
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
Object.defineProperty(exports, "__esModule", { value: true });

21
api-types/package-lock.json generated Normal file
View file

@ -0,0 +1,21 @@
{
"name": "@mattermost/desktop-api",
"version": "5.7.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@mattermost/desktop-api",
"version": "5.7.0-2",
"license": "MIT",
"peerDependencies": {
"typescript": "^4.3"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
}
}

33
api-types/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "@mattermost/desktop-api",
"version": "5.7.0-2",
"description": "Shared types for the Desktop App API provided to the Web App",
"keywords": [
"mattermost"
],
"homepage": "https://github.com/mattermost/desktop",
"license": "MIT",
"files": [
"lib"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"repository": {
"type": "git",
"url": "github:mattermost/desktop",
"directory": "api-types"
},
"peerDependencies": {
"typescript": "^4.3"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"scripts": {
"build": "tsc --build --verbose",
"run": "tsc --watch --preserveWatchOutput",
"clean": "rm -rf tsconfig.tsbuildinfo ./lib"
}
}

20
api-types/tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es6",
"declaration": true,
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"jsx": "react",
"outDir": "./lib",
"rootDir": ".",
"composite": true
},
"include": [
"./index.ts"
]
}

23
package-lock.json generated
View file

@ -26,6 +26,7 @@
"@electron/fuses": "1.6.0",
"@electron/universal": "1.3.1",
"@mattermost/compass-icons": "0.1.32",
"@mattermost/desktop-api": "*",
"@storybook/addon-actions": "6.4.20",
"@storybook/react": "6.4.20",
"@types/auto-launch": "5.0.2",
@ -117,6 +118,20 @@
"node": ">=16.16.0"
}
},
"api-types": {
"name": "@mattermost/desktop-api",
"version": "5.7.0-0",
"dev": true,
"license": "MIT",
"peerDependencies": {
"typescript": "^4.3"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@ampproject/remapping": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
@ -4791,6 +4806,10 @@
"integrity": "sha512-SruyY3dJUGoOCuc5M7KkpFZgotfmeV5Osi+nrMObRdTmaLfJ8h9Q6ZueLx4k4LkLt7hW0CAl33pWc6jO7p3egQ==",
"dev": true
},
"node_modules/@mattermost/desktop-api": {
"resolved": "api-types",
"link": true
},
"node_modules/@mdx-js/mdx": {
"version": "1.6.22",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz",
@ -40037,6 +40056,10 @@
"integrity": "sha512-SruyY3dJUGoOCuc5M7KkpFZgotfmeV5Osi+nrMObRdTmaLfJ8h9Q6ZueLx4k4LkLt7hW0CAl33pWc6jO7p3egQ==",
"dev": true
},
"@mattermost/desktop-api": {
"version": "file:api-types",
"requires": {}
},
"@mdx-js/mdx": {
"version": "1.6.22",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-1.6.22.tgz",

View file

@ -145,6 +145,7 @@
"@electron/fuses": "1.6.0",
"@electron/universal": "1.3.1",
"@mattermost/compass-icons": "0.1.32",
"@mattermost/desktop-api": "*",
"@storybook/addon-actions": "6.4.20",
"@storybook/react": "6.4.20",
"@types/auto-launch": "5.0.2",

View file

@ -68,7 +68,7 @@ export class ServerViewState {
}
getCurrentServer = () => {
log.debug('getCurrentServer');
log.silly('getCurrentServer');
if (!this.currentServerId) {
throw new Error('No server set as current');
@ -132,7 +132,7 @@ export class ServerViewState {
const modalPromise = ModalManager.addModal<null, Server>(
'newServer',
getLocalURLString('newServer.html'),
getLocalPreload('desktopAPI.js'),
getLocalPreload('internalAPI.js'),
null,
mainWindow,
!ServerManager.hasServers(),
@ -164,7 +164,7 @@ export class ServerViewState {
const modalPromise = ModalManager.addModal<UniqueServer, Server>(
'editServer',
getLocalURLString('editServer.html'),
getLocalPreload('desktopAPI.js'),
getLocalPreload('internalAPI.js'),
server.toUniqueServer(),
mainWindow);
@ -191,7 +191,7 @@ export class ServerViewState {
const modalPromise = ModalManager.addModal<string, boolean>(
'removeServer',
getLocalURLString('removeServer.html'),
getLocalPreload('desktopAPI.js'),
getLocalPreload('internalAPI.js'),
server.name,
mainWindow,
);

View file

@ -1,6 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const GET_APP_INFO = 'get-app-info';
export const SWITCH_SERVER = 'switch-server';
export const SWITCH_TAB = 'switch-tab';
export const CLOSE_VIEW = 'close-view';
@ -58,9 +60,9 @@ export const GET_DOWNLOAD_LOCATION = 'get_download_location';
export const UPDATE_MENTIONS = 'update_mentions';
export const IS_UNREAD = 'is_unread';
export const UNREAD_RESULT = 'unread_result';
export const UNREADS_AND_MENTIONS = 'unreads-and-mentions';
export const SESSION_EXPIRED = 'session_expired';
export const SET_VIEW_OPTIONS = 'set-view-name';
export const REACT_APP_INITIALIZED = 'react-app-initialized';
export const TOGGLE_BACK_BUTTON = 'toggle-back-button';
@ -93,7 +95,6 @@ export const START_UPGRADE = 'start-upgrade';
export const CHECK_FOR_UPDATES = 'check-for-updates';
export const NO_UPDATE_AVAILABLE = 'no-update-available';
export const BROWSER_HISTORY_BUTTON = 'browser-history-button';
export const BROWSER_HISTORY_PUSH = 'browser-history-push';
export const APP_LOGGED_IN = 'app-logged-in';
export const APP_LOGGED_OUT = 'app-logged-out';
@ -118,7 +119,7 @@ export const GET_AVAILABLE_LANGUAGES = 'get-available-languages';
export const VIEW_FINISHED_RESIZING = 'view-finished-resizing';
// Calls
export const DISPATCH_GET_DESKTOP_SOURCES = 'dispatch-get-desktop-sources';
export const GET_DESKTOP_SOURCES = 'get-desktop-sources';
export const DESKTOP_SOURCES_RESULT = 'desktop-sources-result';
export const DESKTOP_SOURCES_MODAL_REQUEST = 'desktop-sources-modal-request';
export const CALLS_JOIN_CALL = 'calls-join-call';
@ -177,3 +178,11 @@ export const VALIDATE_SERVER_URL = 'validate-server-url';
export const GET_IS_DEV_MODE = 'get-is-dev-mode';
export const TOGGLE_SECURE_INPUT = 'toggle-secure-input';
export const REQUEST_BROWSER_HISTORY_STATUS = 'request-browser-history-status';
export const BROWSER_HISTORY_STATUS_UPDATED = 'browser-history-status-updated';
export const NOTIFICATION_CLICKED = 'notification-clicked';
// Legacy code remove signal
export const LEGACY_OFF = 'legacy-off';

View file

@ -57,6 +57,7 @@ export const isUrlType = (urlType: string, serverURL: URL, inputURL: URL) => {
getFormattedPathName(inputURL.pathname).startsWith(`/${urlType}/`));
};
export const isLoginUrl = (serverURL: URL, inputURL: URL) => isUrlType('login', serverURL, inputURL);
export const isHelpUrl = (serverURL: URL, inputURL: URL) => isUrlType('help', serverURL, inputURL);
export const isImageProxyUrl = (serverURL: URL, inputURL: URL) => isUrlType('api/v4/image', serverURL, inputURL);
export const isPublicFilesUrl = (serverURL: URL, inputURL: URL) => isUrlType('files', serverURL, inputURL);

View file

@ -30,6 +30,7 @@ import {
WINDOW_RESTORE,
DOUBLE_CLICK_ON_WINDOW,
TOGGLE_SECURE_INPUT,
GET_APP_INFO,
} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
@ -249,7 +250,7 @@ function initializeBeforeAppReady() {
function initializeInterCommunicationEventListeners() {
ipcMain.on(NOTIFY_MENTION, handleMentionNotification);
ipcMain.handle('get-app-version', handleAppVersion);
ipcMain.handle(GET_APP_INFO, handleAppVersion);
ipcMain.on(UPDATE_SHORTCUT_MENU, handleUpdateMenuEvent);
ipcMain.on(FOCUS_BROWSERVIEW, ViewManager.focusCurrentView);
@ -366,7 +367,7 @@ async function initializeAfterAppReady() {
// listen for status updates and pass on to renderer
UserActivityMonitor.on('status', (status) => {
log.debug('UserActivityMonitor.on(status)', status);
ViewManager.sendToAllViews(USER_ACTIVITY_UPDATE, status);
ViewManager.sendToAllViews(USER_ACTIVITY_UPDATE, status.userIsActive, status.idleTime, status.isSystemEvent);
});
// start monitoring user activity (needs to be started after the app is ready)

View file

@ -4,7 +4,6 @@
import {app, IpcMainEvent, IpcMainInvokeEvent, Menu} from 'electron';
import {UniqueServer} from 'types/config';
import {MentionData} from 'types/notification';
import ServerViewState from 'app/serverViewState';
@ -92,7 +91,7 @@ export function handleWelcomeScreenModal() {
const html = getLocalURLString('welcomeScreen.html');
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
const mainWindow = MainWindow.get();
if (!mainWindow) {
@ -114,9 +113,9 @@ export function handleWelcomeScreenModal() {
}
}
export function handleMentionNotification(event: IpcMainEvent, title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, data: MentionData) {
log.debug('handleMentionNotification', {title, body, channel, teamId, url, silent, data});
NotificationManager.displayMention(title, body, channel, teamId, url, silent, event.sender, data);
export function handleMentionNotification(event: IpcMainEvent, title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, soundName: string) {
log.debug('handleMentionNotification', {title, body, channelId, teamId, url, silent, soundName});
NotificationManager.displayMention(title, body, channelId, teamId, url, silent, event.sender, soundName);
}
export function handleOpenAppMenu() {

View file

@ -16,7 +16,7 @@ import MainWindow from 'main/windows/mainWindow';
import ViewManager from 'main/views/viewManager';
const log = new Logger('AuthManager');
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
const loginModalHtml = getLocalURLString('loginModal.html');
const permissionModalHtml = getLocalURLString('permissionModal.html');

View file

@ -12,7 +12,7 @@ import {getLocalURLString, getLocalPreload} from './utils';
import MainWindow from './windows/mainWindow';
const log = new Logger('CertificateManager');
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
const html = getLocalURLString('certificateModal.html');
type CertificateModalResult = {

View file

@ -27,25 +27,25 @@ const DEFAULT_WIN7 = 'Ding';
export class Mention extends Notification {
customSound: string;
channel: {id: string}; // TODO: Channel from mattermost-redux
channelId: string;
teamId: string;
uId: string;
constructor(customOptions: MentionOptions, channel: {id: string}, teamId: string) {
constructor(customOptions: MentionOptions, channelId: string, teamId: string) {
const options = {...defaultOptions, ...customOptions};
if (process.platform === 'darwin' || (process.platform === 'win32' && Utils.isVersionGreaterThanOrEqualTo(os.release(), '10.0'))) {
// Notification Center shows app's icon, so there were two icons on the notification.
Reflect.deleteProperty(options, 'icon');
}
const isWin7 = (process.platform === 'win32' && !Utils.isVersionGreaterThanOrEqualTo(os.release(), '6.3') && DEFAULT_WIN7);
const customSound = String(!options.silent && ((options.data && options.data.soundName !== 'None' && options.data.soundName) || isWin7));
const customSound = String(!options.silent && ((options.soundName !== 'None' && options.soundName) || isWin7));
if (customSound) {
options.silent = true;
}
super(options);
this.customSound = customSound;
this.channel = channel;
this.channelId = channelId;
this.teamId = teamId;
this.uId = uuid();
}

View file

@ -92,6 +92,7 @@ jest.mock('../views/viewManager', () => ({
name: 'server_name',
url: new URL('http://someurl.com'),
},
shouldNotify: true,
},
}),
showById: jest.fn(),
@ -144,12 +145,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: ''},
'',
);
expect(MainWindow.show).not.toBeCalled();
});
@ -164,12 +165,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: ''},
'',
);
expect(MainWindow.show).not.toBeCalled();
@ -188,12 +189,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: ''},
'',
);
expect(MainWindow.show).not.toBeCalled();
@ -207,12 +208,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: ''},
'',
);
expect(MainWindow.show).not.toBeCalled();
});
@ -221,12 +222,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: 'test_sound'},
'test_sound',
);
expect(MainWindow.sendToRenderer).toHaveBeenCalledWith(PLAY_SOUND, 'test_sound');
});
@ -240,12 +241,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: ''},
'',
);
// convert to any to access private field
@ -257,12 +258,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'test',
'test body 2',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1} as WebContents,
{soundName: ''},
'',
);
expect(mentionsPerChannel.delete).toHaveBeenCalled();
@ -277,12 +278,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()} as unknown as WebContents,
{soundName: ''},
'',
);
const mention = mentions.find((m) => m.body === 'mention_click_body');
mention?.value.click();
@ -298,12 +299,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()} as unknown as WebContents,
{soundName: ''},
'',
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
@ -324,12 +325,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()} as unknown as WebContents,
{soundName: ''},
'',
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
@ -345,12 +346,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()} as unknown as WebContents,
{soundName: ''},
'',
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
@ -371,12 +372,12 @@ describe('main/notifications', () => {
await NotificationManager.displayMention(
'click_test',
'mention_click_body',
{id: 'channel_id'},
'channel_id',
'team_id',
'http://server-1.com/team_id/channel_id',
false,
{id: 1, send: jest.fn()} as unknown as WebContents,
{soundName: ''},
'',
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,

View file

@ -5,10 +5,8 @@ import {app, shell, Notification} from 'electron';
import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state';
import {MentionData} from 'types/notification';
import Config from 'common/config';
import {PLAY_SOUND} from 'common/communication';
import {PLAY_SOUND, NOTIFICATION_CLICKED} from 'common/communication';
import {Logger} from 'common/log';
import PermissionsManager from '../permissionsManager';
@ -29,8 +27,8 @@ class NotificationManager {
private upgradeNotification?: NewVersionNotification;
private restartToUpgradeNotification?: UpgradeNotification;
public async displayMention(title: string, body: string, channel: {id: string}, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, data: MentionData) {
log.debug('displayMention', {title, body, channel, teamId, url, silent, data});
public async displayMention(title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, soundName: string) {
log.debug('displayMention', {title, body, channelId, teamId, url, silent, soundName});
if (!Notification.isSupported()) {
log.error('notification not supported');
@ -45,21 +43,24 @@ class NotificationManager {
if (!view) {
return;
}
if (!view.view.shouldNotify) {
return;
}
const serverName = view.view.server.name;
const options = {
title: `${serverName}: ${title}`,
body,
silent,
data,
soundName,
};
if (!await PermissionsManager.doPermissionRequest(webcontents.id, 'notifications', view.view.server.url.toString())) {
return;
}
const mention = new Mention(options, channel, teamId);
const mentionKey = `${mention.teamId}:${mention.channel.id}`;
const mention = new Mention(options, channelId, teamId);
const mentionKey = `${mention.teamId}:${mention.channelId}`;
this.allActiveNotifications.set(mention.uId, mention);
mention.on('show', () => {
@ -88,7 +89,7 @@ class NotificationManager {
MainWindow.show();
if (serverName) {
ViewManager.showById(view.id);
webcontents.send('notification-clicked', {channel, teamId, url});
webcontents.send(NOTIFICATION_CLICKED, channelId, teamId, url);
}
});

View file

@ -1,94 +0,0 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
import {ipcRenderer} from 'electron';
import {
CALLS_LEAVE_CALL,
CALLS_JOINED_CALL,
CALLS_POPOUT_FOCUS,
CALLS_WIDGET_RESIZE,
CALLS_WIDGET_SHARE_SCREEN,
CALLS_WIDGET_CHANNEL_LINK_CLICK,
CALLS_ERROR,
DESKTOP_SOURCES_RESULT,
DESKTOP_SOURCES_MODAL_REQUEST,
CALLS_LINK_CLICK,
CALLS_JOIN_REQUEST,
} from 'common/communication';
//
// Handle messages FROM the widget. (i.e., widget's webapp -> widget's window)
//
window.addEventListener('message', ({origin, data = {}} = {}) => {
const {type, message = {}} = data;
if (origin !== window.location.origin) {
return;
}
switch (type) {
case 'get-app-version': {
ipcRenderer.invoke('get-app-version').then(({name, version}) => {
window.postMessage(
{
type: 'register-desktop',
message: {
name,
version,
},
},
window.location.origin,
);
});
break;
}
case DESKTOP_SOURCES_MODAL_REQUEST:
case CALLS_WIDGET_CHANNEL_LINK_CLICK:
case CALLS_LINK_CLICK:
case CALLS_WIDGET_RESIZE:
case CALLS_JOINED_CALL:
case CALLS_POPOUT_FOCUS:
case CALLS_ERROR:
case CALLS_LEAVE_CALL:
case CALLS_JOIN_REQUEST: {
ipcRenderer.send(type, 'widget', message);
break;
}
}
});
//
// Handle messages TO the widget.
//
ipcRenderer.on(DESKTOP_SOURCES_RESULT, (event, sources) => {
window.postMessage(
{
type: DESKTOP_SOURCES_RESULT,
message: sources,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_WIDGET_SHARE_SCREEN, (event, message) => {
window.postMessage(
{
type: CALLS_WIDGET_SHARE_SCREEN,
message,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_ERROR, (event, message) => {
window.postMessage(
{
type: CALLS_ERROR,
message,
},
window.location.origin,
);
});

View file

@ -0,0 +1,470 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IpcRendererEvent, contextBridge, ipcRenderer, webFrame} from 'electron';
import {ExternalAPI} from 'types/externalAPI';
import {DesktopAPI} from '@mattermost/desktop-api';
import {
NOTIFY_MENTION,
IS_UNREAD,
UNREAD_RESULT,
SESSION_EXPIRED,
REACT_APP_INITIALIZED,
USER_ACTIVITY_UPDATE,
BROWSER_HISTORY_PUSH,
APP_LOGGED_IN,
APP_LOGGED_OUT,
GET_VIEW_INFO_FOR_TEST,
DESKTOP_SOURCES_RESULT,
VIEW_FINISHED_RESIZING,
CALLS_JOIN_CALL,
CALLS_JOINED_CALL,
CALLS_LEAVE_CALL,
DESKTOP_SOURCES_MODAL_REQUEST,
CALLS_WIDGET_SHARE_SCREEN,
CALLS_ERROR,
CALLS_JOIN_REQUEST,
GET_IS_DEV_MODE,
TOGGLE_SECURE_INPUT,
GET_APP_INFO,
REQUEST_BROWSER_HISTORY_STATUS,
BROWSER_HISTORY_STATUS_UPDATED,
NOTIFICATION_CLICKED,
CALLS_WIDGET_RESIZE,
CALLS_WIDGET_CHANNEL_LINK_CLICK,
CALLS_LINK_CLICK,
CALLS_POPOUT_FOCUS,
GET_DESKTOP_SOURCES,
UNREADS_AND_MENTIONS,
LEGACY_OFF,
} from 'common/communication';
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 = {
// Initialization
isDev: () => ipcRenderer.invoke(GET_IS_DEV_MODE),
getAppInfo: () => {
// Using this signal as the sign to disable the legacy code, since it is run before the app is rendered
if (legacyEnabled) {
legacyOff();
}
return ipcRenderer.invoke(GET_APP_INFO);
},
reactAppInitialized: () => ipcRenderer.send(REACT_APP_INITIALIZED),
// Session
setSessionExpired: (isExpired) => ipcRenderer.send(SESSION_EXPIRED, isExpired),
onUserActivityUpdate: (listener) => createListener(USER_ACTIVITY_UPDATE, listener),
// Unreads/mentions/notifications
sendNotification: (title, body, channelId, teamId, url, silent, soundName) =>
ipcRenderer.send(NOTIFY_MENTION, title, body, channelId, teamId, url, silent, soundName),
onNotificationClicked: (listener) => createListener(NOTIFICATION_CLICKED, listener),
setUnreadsAndMentions: (isUnread, mentionCount) => ipcRenderer.send(UNREADS_AND_MENTIONS, isUnread, mentionCount),
// Navigation
requestBrowserHistoryStatus: () => ipcRenderer.invoke(REQUEST_BROWSER_HISTORY_STATUS),
onBrowserHistoryStatusUpdated: (listener) => createListener(BROWSER_HISTORY_STATUS_UPDATED, listener),
onBrowserHistoryPush: (listener) => createListener(BROWSER_HISTORY_PUSH, listener),
sendBrowserHistoryPush: (path) => ipcRenderer.send(BROWSER_HISTORY_PUSH, path),
// Calls widget
openLinkFromCallsWidget: (url) => ipcRenderer.send(CALLS_LINK_CLICK, url),
openScreenShareModal: () => ipcRenderer.send(DESKTOP_SOURCES_MODAL_REQUEST),
onScreenShared: (listener) => createListener(CALLS_WIDGET_SHARE_SCREEN, listener),
callsWidgetConnected: (callID, sessionID) => ipcRenderer.send(CALLS_JOINED_CALL, callID, sessionID),
onJoinCallRequest: (listener) => createListener(CALLS_JOIN_REQUEST, listener),
resizeCallsWidget: (width, height) => ipcRenderer.send(CALLS_WIDGET_RESIZE, width, height),
focusPopout: () => ipcRenderer.send(CALLS_POPOUT_FOCUS),
leaveCall: () => ipcRenderer.send(CALLS_LEAVE_CALL),
sendCallsError: (error) => ipcRenderer.send(CALLS_ERROR, error),
// Calls plugin
getDesktopSources: (opts) => ipcRenderer.invoke(GET_DESKTOP_SOURCES, opts),
onOpenScreenShareModal: (listener) => createListener(DESKTOP_SOURCES_MODAL_REQUEST, listener),
shareScreen: (sourceID, withAudio) => ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, sourceID, withAudio),
joinCall: (opts) => ipcRenderer.invoke(CALLS_JOIN_CALL, opts),
sendJoinCallRequest: (callId) => ipcRenderer.send(CALLS_JOIN_REQUEST, callId),
onCallsError: (listener) => createListener(CALLS_ERROR, listener),
// Utility
unregister: (channel) => ipcRenderer.removeAllListeners(channel),
};
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
// Specific info for the testing environment
if (process.env.NODE_ENV === 'test') {
contextBridge.exposeInMainWorld('testHelper', {
getViewInfoForTest: () => ipcRenderer.invoke(GET_VIEW_INFO_FOR_TEST),
});
}
/****************************************************************************
* window/document listeners
* These are here to perform specific tasks when global window or document events happen
* Avoid using these unless absolutely necessary
****************************************************************************
*/
// Let the main process know when the window has finished resizing
// This is to reduce the amount of white box that happens when expand the BrowserView
window.addEventListener('resize', () => {
ipcRenderer.send(VIEW_FINISHED_RESIZING);
});
// Enable secure input on macOS clients when the user is on a password input
let isPasswordBox = false;
const shouldSecureInput = (element: {tagName?: string; type?: string} | null, force = false) => {
const targetIsPasswordBox = (element && element.tagName === 'INPUT' && element.type === 'password');
if (targetIsPasswordBox && (!isPasswordBox || force)) {
ipcRenderer.send(TOGGLE_SECURE_INPUT, true);
} else if (!targetIsPasswordBox && (isPasswordBox || force)) {
ipcRenderer.send(TOGGLE_SECURE_INPUT, false);
}
isPasswordBox = Boolean(targetIsPasswordBox);
};
window.addEventListener('focusin', (event) => {
shouldSecureInput(event.target as Element);
});
window.addEventListener('focus', () => {
shouldSecureInput(document.activeElement, true);
});
// exit fullscreen embedded elements like youtube - https://mattermost.atlassian.net/browse/MM-19226
ipcRenderer.on('exit-fullscreen', () => {
if (document.fullscreenElement && document.fullscreenElement.nodeName.toLowerCase() === 'iframe') {
document.exitFullscreen();
}
});
// mattermost-webapp is SPA. So cache is not cleared due to no navigation.
// We needed to manually clear cache to free memory in long-term-use.
// http://seenaburns.com/debugging-electron-memory-usage/
const CLEAR_CACHE_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
setInterval(() => {
webFrame.clearCache();
}, CLEAR_CACHE_INTERVAL);
/****************************************************************************
* LEGACY CODE BELOW
* 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.log('The guest is not assumed as mattermost-webapp');
return;
}
watchReactAppUntilInitialized(() => {
console.log('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();
} else {
watchReactAppUntilInitialized(() => {
findUnread();
});
}
};
const findUnread = () => {
const classes = ['team-container unread', 'SidebarChannel unread', 'sidebar-item unread-title'];
const isUnread = classes.some((classPair) => {
const result = document.getElementsByClassName(classPair);
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.send(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,
);
});
ipcRenderer.on(BROWSER_HISTORY_PUSH, (event, pathName) => {
window.postMessage(
{
type: 'browser-history-push-return',
message: {
pathName,
},
},
window.location.origin,
);
});
const sendHistoryButtonReturn = (status: {canGoBack: boolean; canGoForward: boolean}) => {
window.postMessage(
{
type: 'history-button-return',
message: {
enableBack: status.canGoBack,
enableForward: status.canGoForward,
},
},
window.location.origin,
);
};
ipcRenderer.on(BROWSER_HISTORY_STATUS_UPDATED, (event, canGoBack, canGoForward) => sendHistoryButtonReturn({canGoBack, canGoForward}));
const sendDesktopSourcesResult = (sources: Array<{
id: string;
name: string;
thumbnailURL: string;
}>) => {
window.postMessage(
{
type: DESKTOP_SOURCES_RESULT,
message: sources,
},
window.location.origin,
);
};
const sendCallsJoinedCall = (message: {callID: string; sessionID: string}) => {
window.postMessage(
{
type: CALLS_JOINED_CALL,
message,
},
window.location.origin,
);
};
ipcRenderer.on(CALLS_JOIN_REQUEST, (_, callID) => {
window.postMessage(
{
type: CALLS_JOIN_REQUEST,
message: {callID},
},
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
*/
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

@ -89,6 +89,7 @@ import {
GET_ORDERED_TABS_FOR_SERVER,
SERVERS_UPDATE,
VALIDATE_SERVER_URL,
GET_APP_INFO,
} from 'common/communication';
console.log('Preload initialized');
@ -139,7 +140,7 @@ contextBridge.exposeInMainWorld('desktop', {
validateServerURL: (url, currentId) => ipcRenderer.invoke(VALIDATE_SERVER_URL, url, currentId),
getConfiguration: () => ipcRenderer.invoke(GET_CONFIGURATION),
getVersion: () => ipcRenderer.invoke('get-app-version'),
getVersion: () => ipcRenderer.invoke(GET_APP_INFO),
getDarkMode: () => ipcRenderer.invoke(GET_DARK_MODE),
requestHasDownloads: () => ipcRenderer.invoke(REQUEST_HAS_DOWNLOADS),
getFullScreenStatus: () => ipcRenderer.invoke(GET_FULL_SCREEN_STATUS),

View file

@ -1,396 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';
/* eslint-disable no-magic-numbers */
import {contextBridge, ipcRenderer, webFrame} from 'electron';
// I've filed an issue in electron-log https://github.com/megahertz/electron-log/issues/267
// we'll be able to use it again if there is a workaround for the 'os' import
//import log from 'electron-log';
import {
NOTIFY_MENTION,
IS_UNREAD,
UNREAD_RESULT,
SESSION_EXPIRED,
SET_VIEW_OPTIONS,
REACT_APP_INITIALIZED,
USER_ACTIVITY_UPDATE,
CLOSE_SERVERS_DROPDOWN,
BROWSER_HISTORY_BUTTON,
BROWSER_HISTORY_PUSH,
APP_LOGGED_IN,
APP_LOGGED_OUT,
GET_VIEW_INFO_FOR_TEST,
DISPATCH_GET_DESKTOP_SOURCES,
DESKTOP_SOURCES_RESULT,
VIEW_FINISHED_RESIZING,
CALLS_JOIN_CALL,
CALLS_JOINED_CALL,
CALLS_LEAVE_CALL,
DESKTOP_SOURCES_MODAL_REQUEST,
CALLS_WIDGET_SHARE_SCREEN,
CLOSE_DOWNLOADS_DROPDOWN,
CALLS_ERROR,
CALLS_JOIN_REQUEST,
GET_IS_DEV_MODE,
TOGGLE_SECURE_INPUT,
} from 'common/communication';
const UNREAD_COUNT_INTERVAL = 1000;
const CLEAR_CACHE_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
let appVersion;
let appName;
let sessionExpired;
let viewId;
let shouldSendNotifications;
console.log('Preload initialized');
if (process.env.NODE_ENV === 'test') {
contextBridge.exposeInMainWorld('testHelper', {
getViewInfoForTest: () => ipcRenderer.invoke(GET_VIEW_INFO_FOR_TEST),
});
}
contextBridge.exposeInMainWorld('desktopAPI', {
isDev: () => ipcRenderer.invoke(GET_IS_DEV_MODE),
});
ipcRenderer.invoke('get-app-version').then(({name, version}) => {
appVersion = version;
appName = name;
});
function 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;
}
function watchReactAppUntilInitialized(callback) {
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);
}
window.addEventListener('load', () => {
if (document.getElementById('root') === null) {
console.log('The guest is not assumed as mattermost-webapp');
return;
}
watchReactAppUntilInitialized(() => {
ipcRenderer.send(REACT_APP_INITIALIZED, viewId);
ipcRenderer.send(BROWSER_HISTORY_BUTTON, viewId);
});
});
const parentTag = (target) => {
if (target.parentNode && target.parentNode.tagName) {
return target.parentNode.tagName.toUpperCase();
}
return null;
};
document.addEventListener('mouseover', (event) => {
if (event.target && (event.target.tagName === 'A')) {
ipcRenderer.send('update-target-url', event.target.href);
} else if (event.target && (parentTag(event.target) === 'A')) {
ipcRenderer.send('update-target-url', event.target.parentNode.href);
}
});
document.addEventListener('mouseout', (event) => {
if (event.target && event.target.tagName === 'A') {
ipcRenderer.send('delete-target-url', event.target.href);
}
});
// listen for messages from the webapp
window.addEventListener('message', ({origin, data = {}} = {}) => {
const {type, message = {}} = data;
if (origin !== window.location.origin) {
return;
}
switch (type) {
case 'webapp-ready': {
// register with the webapp to enable custom integration functionality
console.log(`registering ${appName} v${appVersion} with the server`);
window.postMessage(
{
type: 'register-desktop',
message: {
version: appVersion,
name: appName,
},
},
window.location.origin || '*',
);
break;
}
case 'register-desktop':
// it will be captured by itself too
break;
case 'dispatch-notification': {
if (shouldSendNotifications) {
const {title, body, channel, teamId, url, silent, data: messageData} = message;
ipcRenderer.send(NOTIFY_MENTION, title, body, channel, teamId, url, silent, messageData);
}
break;
}
case 'browser-history-push': {
const {path} = message;
ipcRenderer.send(BROWSER_HISTORY_PUSH, viewId, path);
break;
}
case 'history-button': {
ipcRenderer.send(BROWSER_HISTORY_BUTTON, viewId);
break;
}
case 'get-desktop-sources': {
ipcRenderer.send(DISPATCH_GET_DESKTOP_SOURCES, viewId, message);
break;
}
case CALLS_JOIN_CALL: {
ipcRenderer.send(CALLS_JOIN_CALL, viewId, message);
break;
}
case CALLS_WIDGET_SHARE_SCREEN: {
ipcRenderer.send(CALLS_WIDGET_SHARE_SCREEN, viewId, message);
break;
}
case CALLS_LEAVE_CALL: {
ipcRenderer.send(CALLS_LEAVE_CALL, viewId, message);
break;
}
}
});
const handleNotificationClick = ({channel, teamId, url}) => {
window.postMessage(
{
type: 'notification-clicked',
message: {
channel,
teamId,
url,
},
},
window.location.origin,
);
};
ipcRenderer.on('notification-clicked', (event, data) => {
handleNotificationClick(data);
});
const findUnread = (favicon) => {
const classes = ['team-container unread', 'SidebarChannel unread', 'sidebar-item unread-title'];
const isUnread = classes.some((classPair) => {
const result = document.getElementsByClassName(classPair);
return result && result.length > 0;
});
ipcRenderer.send(UNREAD_RESULT, favicon, viewId, isUnread);
};
ipcRenderer.on(IS_UNREAD, (event, favicon, server) => {
if (typeof viewId === 'undefined') {
viewId = server;
}
if (isReactAppInitialized()) {
findUnread(favicon);
} else {
watchReactAppUntilInitialized(() => {
findUnread(favicon);
});
}
});
ipcRenderer.on(SET_VIEW_OPTIONS, (_, name, shouldNotify) => {
viewId = name;
shouldSendNotifications = shouldNotify;
});
function getUnreadCount() {
// LHS not found => Log out => Count should be 0, but session may be expired.
if (typeof viewId !== 'undefined') {
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, viewId);
}
}
}
setInterval(getUnreadCount, UNREAD_COUNT_INTERVAL);
// 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);
}
});
// exit fullscreen embedded elements like youtube - https://mattermost.atlassian.net/browse/MM-19226
ipcRenderer.on('exit-fullscreen', () => {
if (document.fullscreenElement && document.fullscreenElement.nodeName.toLowerCase() === 'iframe') {
document.exitFullscreen();
}
});
// mattermost-webapp is SPA. So cache is not cleared due to no navigation.
// We needed to manually clear cache to free memory in long-term-use.
// http://seenaburns.com/debugging-electron-memory-usage/
setInterval(() => {
webFrame.clearCache();
}, CLEAR_CACHE_INTERVAL);
function isDownloadLink(el) {
if (typeof el !== 'object') {
return false;
}
const parentEl = el.parentElement;
if (typeof parentEl !== 'object') {
return el.className?.includes?.('download') || el.tagName?.toLowerCase?.() === 'svg';
}
return el.closest('a[download]') !== null;
}
window.addEventListener('click', (e) => {
ipcRenderer.send(CLOSE_SERVERS_DROPDOWN);
const el = e.target;
if (!isDownloadLink(el)) {
ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN);
}
});
ipcRenderer.on(BROWSER_HISTORY_PUSH, (event, pathName) => {
window.postMessage(
{
type: 'browser-history-push-return',
message: {
pathName,
},
},
window.location.origin,
);
});
ipcRenderer.on(BROWSER_HISTORY_BUTTON, (event, enableBack, enableForward) => {
window.postMessage(
{
type: 'history-button-return',
message: {
enableBack,
enableForward,
},
},
window.location.origin,
);
});
window.addEventListener('storage', (e) => {
if (e.key === '__login__' && e.storageArea === localStorage && e.newValue) {
ipcRenderer.send(APP_LOGGED_IN, viewId);
}
if (e.key === '__logout__' && e.storageArea === localStorage && e.newValue) {
ipcRenderer.send(APP_LOGGED_OUT, viewId);
}
});
ipcRenderer.on(DESKTOP_SOURCES_RESULT, (event, sources) => {
window.postMessage(
{
type: 'desktop-sources-result',
message: sources,
},
window.location.origin,
);
});
ipcRenderer.on(DESKTOP_SOURCES_MODAL_REQUEST, () => {
window.postMessage(
{
type: DESKTOP_SOURCES_MODAL_REQUEST,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_JOINED_CALL, (event, message) => {
window.postMessage(
{
type: CALLS_JOINED_CALL,
message,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_ERROR, (event, message) => {
window.postMessage(
{
type: CALLS_ERROR,
message,
},
window.location.origin,
);
});
ipcRenderer.on(CALLS_JOIN_REQUEST, (event, message) => {
window.postMessage(
{
type: CALLS_JOIN_REQUEST,
message,
},
window.location.origin,
);
});
/* eslint-enable no-magic-numbers */
window.addEventListener('resize', () => {
ipcRenderer.send(VIEW_FINISHED_RESIZING);
});
let isPasswordBox = false;
const shouldSecureInput = (element, force = false) => {
const targetIsPasswordBox = (element && element.tagName === 'INPUT' && element.type === 'password');
if (targetIsPasswordBox && (!isPasswordBox || force)) {
ipcRenderer.send(TOGGLE_SECURE_INPUT, true);
} else if (!targetIsPasswordBox && (isPasswordBox || force)) {
ipcRenderer.send(TOGGLE_SECURE_INPUT, false);
}
isPasswordBox = targetIsPasswordBox;
};
window.addEventListener('focusin', (event) => {
shouldSecureInput(event.target);
});
window.addEventListener('focus', () => {
shouldSecureInput(document.activeElement, true);
});

View file

@ -45,6 +45,7 @@ jest.mock('../windows/mainWindow', () => ({
jest.mock('common/appState', () => ({
clear: jest.fn(),
updateMentions: jest.fn(),
updateExpired: jest.fn(),
}));
jest.mock('./webContentEvents', () => ({
addWebContentsEventListeners: jest.fn(),

View file

@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserView, app} from 'electron';
import {BrowserView, app, ipcMain} from 'electron';
import {BrowserViewConstructorOptions, Event, Input} from 'electron/main';
import {EventEmitter} from 'events';
@ -15,10 +15,11 @@ import {
UPDATE_TARGET_URL,
IS_UNREAD,
TOGGLE_BACK_BUTTON,
SET_VIEW_OPTIONS,
LOADSCREEN_END,
BROWSER_HISTORY_BUTTON,
SERVERS_URL_MODIFIED,
BROWSER_HISTORY_STATUS_UPDATED,
CLOSE_SERVERS_DROPDOWN,
CLOSE_DOWNLOADS_DROPDOWN,
} from 'common/communication';
import ServerManager from 'common/servers/serverManager';
import {Logger} from 'common/log';
@ -62,7 +63,7 @@ export class MattermostBrowserView extends EventEmitter {
super();
this.view = view;
const preload = getLocalPreload('preload.js');
const preload = getLocalPreload('externalAPI.js');
this.options = Object.assign({}, options);
this.options.webPreferences = {
preload,
@ -81,14 +82,21 @@ export class MattermostBrowserView extends EventEmitter {
this.log = ServerManager.getViewLog(this.id, 'MattermostBrowserView');
this.log.verbose('View created');
this.browserView.webContents.on('did-finish-load', this.handleDidFinishLoad);
this.browserView.webContents.on('page-title-updated', this.handleTitleUpdate);
this.browserView.webContents.on('page-favicon-updated', this.handleFaviconUpdate);
this.browserView.webContents.on('update-target-url', this.handleUpdateTarget);
this.browserView.webContents.on('did-navigate', this.handleDidNavigate);
if (process.platform !== 'darwin') {
this.browserView.webContents.on('before-input-event', this.handleInputEvents);
}
this.browserView.webContents.on('input-event', (_, inputEvent) => {
if (inputEvent.type === 'mouseDown') {
ipcMain.emit(CLOSE_SERVERS_DROPDOWN);
ipcMain.emit(CLOSE_DOWNLOADS_DROPDOWN);
}
});
// Legacy handlers using the title/favicon
this.browserView.webContents.on('page-title-updated', this.handleTitleUpdate);
this.browserView.webContents.on('page-favicon-updated', this.handleFaviconUpdate);
WebContentsEventManager.addWebContentsEventListeners(this.browserView.webContents);
@ -148,14 +156,23 @@ export class MattermostBrowserView extends EventEmitter {
}
}
updateHistoryButton = () => {
getBrowserHistoryStatus = () => {
if (this.currentURL?.toString() === this.view.url.toString()) {
this.browserView.webContents.clearHistory();
this.atRoot = true;
} else {
this.atRoot = false;
}
this.browserView.webContents.send(BROWSER_HISTORY_BUTTON, this.browserView.webContents.canGoBack(), this.browserView.webContents.canGoForward());
return {
canGoBack: this.browserView.webContents.canGoBack(),
canGoForward: this.browserView.webContents.canGoForward(),
};
}
updateHistoryButton = () => {
const {canGoBack, canGoForward} = this.getBrowserHistoryStatus();
this.browserView.webContents.send(BROWSER_HISTORY_STATUS_UPDATED, canGoBack, canGoForward);
}
load = (someURL?: URL | string) => {
@ -175,6 +192,7 @@ export class MattermostBrowserView extends EventEmitter {
} else {
loadURL = this.view.url.toString();
}
AppState.updateExpired(this.id, false);
this.log.verbose(`Loading ${loadURL}`);
const loading = this.browserView.webContents.loadURL(loadURL, {userAgent: composeUserAgent()});
loading.then(this.loadSuccess(loadURL)).catch((err) => {
@ -257,6 +275,15 @@ export class MattermostBrowserView extends EventEmitter {
}
}
/**
* Code to turn off the old method of getting unreads
* Newer web apps will send the mentions/unreads directly
*/
offLegacyUnreads = () => {
this.browserView.webContents.off('page-title-updated', this.handleTitleUpdate);
this.browserView.webContents.off('page-favicon-updated', this.handleFaviconUpdate);
}
/**
* Status hooks
*/
@ -463,26 +490,6 @@ export class MattermostBrowserView extends EventEmitter {
* WebContents event handlers
*/
private handleDidFinishLoad = () => {
this.log.debug('did-finish-load');
// wait for screen to truly finish loading before sending the message down
const timeout = setInterval(() => {
if (!this.browserView.webContents) {
return;
}
if (!this.browserView.webContents.isLoading()) {
try {
this.browserView.webContents.send(SET_VIEW_OPTIONS, this.id, this.view.shouldNotify);
clearTimeout(timeout);
} catch (e) {
this.log.error('failed to send view options to view');
}
}
}, 100);
}
private handleDidNavigate = (event: Event, url: string) => {
this.log.debug('handleDidNavigate', url);
@ -507,7 +514,7 @@ export class MattermostBrowserView extends EventEmitter {
}
private handleUpdateTarget = (e: Event, url: string) => {
this.log.silly('handleUpdateTarget', url);
this.log.silly('handleUpdateTarget', e, url);
const parsedURL = parseURL(url);
if (parsedURL && isInternalURL(parsedURL, this.view.server.url)) {
this.emit(UPDATE_TARGET_URL);

View file

@ -65,7 +65,7 @@ export class DownloadsDropdownMenuView {
}
this.bounds = this.getBounds(this.windowBounds.width, DOWNLOADS_DROPDOWN_MENU_FULL_WIDTH, DOWNLOADS_DROPDOWN_MENU_FULL_HEIGHT);
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
this.view = new BrowserView({webPreferences: {
preload,
@ -83,7 +83,7 @@ export class DownloadsDropdownMenuView {
* the downloads dropdown at the correct position
*/
private updateWindowBounds = (newBounds: Electron.Rectangle) => {
log.debug('updateWindowBounds');
log.silly('updateWindowBounds');
this.windowBounds = newBounds;
this.updateDownloadsDropdownMenu();
@ -98,7 +98,7 @@ export class DownloadsDropdownMenuView {
}
private updateDownloadsDropdownMenu = () => {
log.debug('updateDownloadsDropdownMenu');
log.silly('updateDownloadsDropdownMenu');
this.view?.webContents.send(
UPDATE_DOWNLOADS_DROPDOWN_MENU,
@ -131,7 +131,7 @@ export class DownloadsDropdownMenuView {
}
private handleClose = () => {
log.debug('handleClose');
log.silly('handleClose');
this.open = false;
this.item = undefined;

View file

@ -57,7 +57,7 @@ export class DownloadsDropdownView {
}
this.bounds = this.getBounds(this.windowBounds.width, DOWNLOADS_DROPDOWN_FULL_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT);
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
this.view = new BrowserView({webPreferences: {
preload,
@ -77,7 +77,7 @@ export class DownloadsDropdownView {
* the downloads dropdown at the correct position
*/
private updateWindowBounds = (newBounds: Electron.Rectangle) => {
log.debug('updateWindowBounds');
log.silly('updateWindowBounds');
this.windowBounds = newBounds;
this.updateDownloadsDropdown();
@ -85,13 +85,13 @@ export class DownloadsDropdownView {
}
private updateDownloadsDropdownMenuItem = (event: IpcMainEvent, item?: DownloadedItem) => {
log.debug('updateDownloadsDropdownMenuItem', {item});
log.silly('updateDownloadsDropdownMenuItem', {item});
this.item = item;
this.updateDownloadsDropdown();
}
private updateDownloadsDropdown = () => {
log.debug('updateDownloadsDropdown');
log.silly('updateDownloadsDropdown');
this.view?.webContents.send(
UPDATE_DOWNLOADS_DROPDOWN,
@ -117,7 +117,7 @@ export class DownloadsDropdownView {
}
private handleClose = () => {
log.debug('handleClose');
log.silly('handleClose');
this.view?.setBounds(this.getBounds(this.windowBounds?.width ?? 0, 0, 0));
downloadsManager.onClose();

View file

@ -77,7 +77,7 @@ export class LoadingScreen {
}
private create = () => {
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
this.view = new BrowserView({webPreferences: {
preload,

View file

@ -75,7 +75,7 @@ export class ServerDropdownView {
private init = () => {
log.info('init');
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
this.view = new BrowserView({webPreferences: {
preload,
@ -144,7 +144,7 @@ export class ServerDropdownView {
}
private handleClose = () => {
log.debug('handleClose');
log.silly('handleClose');
this.view?.setBounds(this.getBounds(0, 0));
MainWindow.sendToRenderer(CLOSE_SERVERS_DROPDOWN);

View file

@ -407,6 +407,7 @@ describe('main/views/viewManager', () => {
];
const view1 = {
id: 'server-1_view-messaging',
webContentsId: 1,
isLoggedIn: true,
view: {
type: TAB_MESSAGING,
@ -415,10 +416,12 @@ describe('main/views/viewManager', () => {
},
},
sendToRenderer: jest.fn(),
updateHistoryButton: jest.fn(),
};
const view2 = {
...view1,
id: 'server-1_other_type_1',
webContentsId: 2,
view: {
...view1.view,
type: 'other_type_1',
@ -427,6 +430,7 @@ describe('main/views/viewManager', () => {
const view3 = {
...view1,
id: 'server-1_other_type_2',
webContentsId: 3,
view: {
...view1.view,
type: 'other_type_2',
@ -442,6 +446,7 @@ describe('main/views/viewManager', () => {
viewManager.getView = (viewId) => views.get(viewId);
viewManager.isViewClosed = (viewId) => closedViews.has(viewId);
viewManager.openClosedView = jest.fn();
viewManager.getViewByWebContentsId = (webContentsId) => [...views.values()].find((view) => view.webContentsId === webContentsId);
beforeEach(() => {
ServerManager.getAllServers.mockReturnValue(servers);
@ -460,19 +465,19 @@ describe('main/views/viewManager', () => {
views.set(name, view);
});
ServerManager.lookupViewByURL.mockReturnValue({id: 'server-1_other_type_2'});
viewManager.handleBrowserHistoryPush(null, 'server-1_view-messaging', '/other_type_2/subpath');
viewManager.handleBrowserHistoryPush({sender: {id: 1}}, '/other_type_2/subpath');
expect(viewManager.openClosedView).toBeCalledWith('server-1_other_type_2', 'http://server-1.com/other_type_2/subpath');
});
it('should open redirect view if different from current view', () => {
ServerManager.lookupViewByURL.mockReturnValue({id: 'server-1_other_type_1'});
viewManager.handleBrowserHistoryPush(null, 'server-1_view-messaging', '/other_type_1/subpath');
viewManager.handleBrowserHistoryPush({sender: {id: 1}}, '/other_type_1/subpath');
expect(viewManager.showById).toBeCalledWith('server-1_other_type_1');
});
it('should ignore redirects to "/" to Messages from other views', () => {
ServerManager.lookupViewByURL.mockReturnValue({id: 'server-1_view-messaging'});
viewManager.handleBrowserHistoryPush(null, 'server-1_other_type_1', '/');
viewManager.handleBrowserHistoryPush({sender: {id: 2}}, '/');
expect(view1.sendToRenderer).not.toBeCalled();
});
});

View file

@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserView, dialog, ipcMain, IpcMainEvent, IpcMainInvokeEvent, Event} from 'electron';
import {BrowserView, dialog, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron';
import isDev from 'electron-is-dev';
import ServerViewState from 'app/serverViewState';
@ -19,7 +19,6 @@ import {
UPDATE_URL_VIEW_WIDTH,
SERVERS_UPDATE,
REACT_APP_INITIALIZED,
BROWSER_HISTORY_BUTTON,
APP_LOGGED_OUT,
APP_LOGGED_IN,
RELOAD_CURRENT_VIEW,
@ -32,6 +31,9 @@ import {
MAIN_WINDOW_FOCUSED,
SWITCH_TAB,
GET_IS_DEV_MODE,
REQUEST_BROWSER_HISTORY_STATUS,
LEGACY_OFF,
UNREADS_AND_MENTIONS,
} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
@ -70,15 +72,17 @@ export class ViewManager {
MainWindow.on(MAIN_WINDOW_FOCUSED, this.focusCurrentView);
ipcMain.handle(GET_VIEW_INFO_FOR_TEST, this.handleGetViewInfoForTest);
ipcMain.handle(GET_IS_DEV_MODE, () => isDev);
ipcMain.handle(REQUEST_BROWSER_HISTORY_STATUS, this.handleRequestBrowserHistoryStatus);
ipcMain.on(HISTORY, this.handleHistory);
ipcMain.on(REACT_APP_INITIALIZED, this.handleReactAppInitialized);
ipcMain.on(BROWSER_HISTORY_PUSH, this.handleBrowserHistoryPush);
ipcMain.on(BROWSER_HISTORY_BUTTON, this.handleBrowserHistoryButton);
ipcMain.on(APP_LOGGED_IN, this.handleAppLoggedIn);
ipcMain.on(APP_LOGGED_OUT, this.handleAppLoggedOut);
ipcMain.on(RELOAD_CURRENT_VIEW, this.handleReloadCurrentView);
ipcMain.on(UNREAD_RESULT, this.handleFaviconIsUnread);
ipcMain.on(UNREAD_RESULT, this.handleUnreadChanged);
ipcMain.on(UNREADS_AND_MENTIONS, this.handleUnreadsAndMentionsChanged);
ipcMain.on(SESSION_EXPIRED, this.handleSessionExpired);
ipcMain.on(LEGACY_OFF, this.handleLegacyOff);
ipcMain.on(SWITCH_TAB, (event, viewId) => this.showById(viewId));
@ -326,7 +330,7 @@ export class ViewManager {
}
if (url && url !== '') {
const urlString = typeof url === 'string' ? url : url.toString();
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
const urlView = new BrowserView({
webPreferences: {
preload,
@ -470,18 +474,18 @@ export class ViewManager {
this.getCurrentView()?.goToOffset(offset);
}
private handleAppLoggedIn = (event: IpcMainEvent, viewId: string) => {
this.getView(viewId)?.onLogin(true);
private handleAppLoggedIn = (event: IpcMainEvent) => {
this.getViewByWebContentsId(event.sender.id)?.onLogin(true);
}
private handleAppLoggedOut = (event: IpcMainEvent, viewId: string) => {
this.getView(viewId)?.onLogin(false);
private handleAppLoggedOut = (event: IpcMainEvent) => {
this.getViewByWebContentsId(event.sender.id)?.onLogin(false);
}
private handleBrowserHistoryPush = (e: IpcMainEvent, viewId: string, pathName: string) => {
log.debug('handleBrowserHistoryPush', {viewId, pathName});
private handleBrowserHistoryPush = (e: IpcMainEvent, pathName: string) => {
log.debug('handleBrowserHistoryPush', e.sender.id, pathName);
const currentView = this.getView(viewId);
const currentView = this.getViewByWebContentsId(e.sender.id);
if (!currentView) {
return;
}
@ -489,7 +493,7 @@ export class ViewManager {
if (currentView.view.server.url.pathname !== '/' && pathName.startsWith(currentView.view.server.url.pathname)) {
cleanedPathName = pathName.replace(currentView.view.server.url.pathname, '');
}
const redirectedviewId = ServerManager.lookupViewByURL(`${currentView.view.server.url.toString().replace(/\/$/, '')}${cleanedPathName}`)?.id || viewId;
const redirectedviewId = ServerManager.lookupViewByURL(`${currentView.view.server.url.toString().replace(/\/$/, '')}${cleanedPathName}`)?.id || currentView.id;
if (this.isViewClosed(redirectedviewId)) {
// If it's a closed view, just open it and stop
this.openClosedView(redirectedviewId, `${currentView.view.server.url}${cleanedPathName}`);
@ -497,8 +501,8 @@ export class ViewManager {
}
let redirectedView = this.getView(redirectedviewId) || currentView;
if (redirectedView !== currentView && redirectedView?.view.server.id === ServerViewState.getCurrentServer().id && redirectedView?.isLoggedIn) {
log.info('redirecting to a new view', redirectedView?.id || viewId);
this.showById(redirectedView?.id || viewId);
log.info('redirecting to a new view', redirectedView?.id || currentView.id);
this.showById(redirectedView?.id || currentView.id);
} else {
redirectedView = currentView;
}
@ -506,20 +510,20 @@ export class ViewManager {
// Special case check for Channels to not force a redirect to "/", causing a refresh
if (!(redirectedView !== currentView && redirectedView?.view.type === TAB_MESSAGING && cleanedPathName === '/')) {
redirectedView?.sendToRenderer(BROWSER_HISTORY_PUSH, cleanedPathName);
if (redirectedView) {
this.handleBrowserHistoryButton(e, redirectedView.id);
}
redirectedView?.updateHistoryButton();
}
}
private handleBrowserHistoryButton = (e: IpcMainEvent, viewId: string) => {
this.getView(viewId)?.updateHistoryButton();
private handleRequestBrowserHistoryStatus = (e: IpcMainInvokeEvent) => {
log.silly('handleRequestBrowserHistoryStatus', e.sender.id);
return this.getViewByWebContentsId(e.sender.id)?.getBrowserHistoryStatus();
}
private handleReactAppInitialized = (e: IpcMainEvent, viewId: string) => {
log.debug('handleReactAppInitialized', viewId);
private handleReactAppInitialized = (e: IpcMainEvent) => {
log.debug('handleReactAppInitialized', e.sender.id);
const view = this.views.get(viewId);
const view = this.getViewByWebContentsId(e.sender.id);
if (view) {
view.setInitialized();
if (this.getCurrentView() === view) {
@ -539,18 +543,47 @@ export class ViewManager {
this.showById(view?.id);
}
// if favicon is null, it means it is the initial load,
// so don't memoize as we don't have the favicons and there is no rush to find out.
private handleFaviconIsUnread = (e: Event, favicon: string, viewId: string, result: boolean) => {
log.silly('handleFaviconIsUnread', {favicon, viewId, result});
private handleLegacyOff = (e: IpcMainEvent) => {
log.silly('handleLegacyOff', {webContentsId: e.sender.id});
AppState.updateUnreads(viewId, result);
const view = this.getViewByWebContentsId(e.sender.id);
if (!view) {
return;
}
view.offLegacyUnreads();
}
private handleSessionExpired = (event: IpcMainEvent, isExpired: boolean, viewId: string) => {
ServerManager.getViewLog(viewId, 'ViewManager').debug('handleSessionExpired', isExpired);
// if favicon is null, it means it is the initial load,
// so don't memoize as we don't have the favicons and there is no rush to find out.
private handleUnreadChanged = (e: IpcMainEvent, result: boolean) => {
log.silly('handleUnreadChanged', {webContentsId: e.sender.id, result});
AppState.updateExpired(viewId, isExpired);
const view = this.getViewByWebContentsId(e.sender.id);
if (!view) {
return;
}
AppState.updateUnreads(view.id, result);
}
private handleUnreadsAndMentionsChanged = (e: IpcMainEvent, isUnread: boolean, mentionCount: number) => {
log.silly('handleUnreadsAndMentionsChanged', {webContentsId: e.sender.id, isUnread, mentionCount});
const view = this.getViewByWebContentsId(e.sender.id);
if (!view) {
return;
}
AppState.updateUnreads(view.id, isUnread);
AppState.updateMentions(view.id, mentionCount);
}
private handleSessionExpired = (event: IpcMainEvent, isExpired: boolean) => {
const view = this.getViewByWebContentsId(event.sender.id);
if (!view) {
return;
}
ServerManager.getViewLog(view.id, 'ViewManager').debug('handleSessionExpired', isExpired);
AppState.updateExpired(view.id, isExpired);
}
private handleSetCurrentViewBounds = (newBounds: Electron.Rectangle) => {

View file

@ -14,6 +14,7 @@ import {
isHelpUrl,
isImageProxyUrl,
isInternalURL,
isLoginUrl,
isManagedResource,
isPluginUrl,
isPublicFilesUrl,
@ -91,7 +92,9 @@ export class WebContentsEventManager {
const parsedURL = parseURL(url)!;
const serverURL = this.getServerURLFromWebContentsId(webContentsId);
if (serverURL && (isTeamUrl(serverURL, parsedURL) || isAdminUrl(serverURL, parsedURL) || this.isTrustedPopupWindow(webContentsId))) {
this.log(webContentsId).info(serverURL?.toString());
if (serverURL && (isTeamUrl(serverURL, parsedURL) || isAdminUrl(serverURL, parsedURL) || isLoginUrl(serverURL, parsedURL) || this.isTrustedPopupWindow(webContentsId))) {
return;
}

View file

@ -3,11 +3,11 @@
/* eslint-disable max-lines */
import {BrowserWindow, desktopCapturer, systemPreferences} from 'electron';
import {BrowserWindow, desktopCapturer, systemPreferences, ipcMain} from 'electron';
import ServerViewState from 'app/serverViewState';
import {CALLS_WIDGET_SHARE_SCREEN, CALLS_JOINED_CALL, CALLS_JOIN_REQUEST} from 'common/communication';
import {CALLS_WIDGET_SHARE_SCREEN} from 'common/communication';
import {
MINIMUM_CALLS_WIDGET_WIDTH,
MINIMUM_CALLS_WIDGET_HEIGHT,
@ -62,6 +62,7 @@ jest.mock('app/serverViewState', () => ({
}));
jest.mock('main/views/viewManager', () => ({
getView: jest.fn(),
getViewByWebContentsId: jest.fn(),
}));
jest.mock('../utils', () => ({
openScreensharePermissionsSettingsMacOS: jest.fn(),
@ -187,11 +188,7 @@ describe('main/windows/callsWidgetWindow', () => {
it('should resize correctly', () => {
callsWidgetWindow.handleResize({
sender: {id: 'windowID'},
}, 'widget', {
element: 'calls-widget',
width: 300,
height: 100,
});
}, 300, 100);
expect(callsWidgetWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 720 - (100 - MINIMUM_CALLS_WIDGET_HEIGHT),
@ -204,11 +201,7 @@ describe('main/windows/callsWidgetWindow', () => {
callsWidgetWindow.win.webContents.getZoomFactor.mockReturnValue(2.0);
callsWidgetWindow.handleResize({
sender: {id: 'windowID'},
}, 'widget', {
element: 'calls-widget',
width: 300,
height: 100,
});
}, 300, 100);
expect(callsWidgetWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 720 - (200 - MINIMUM_CALLS_WIDGET_HEIGHT),
@ -221,11 +214,7 @@ describe('main/windows/callsWidgetWindow', () => {
callsWidgetWindow.win.webContents.getZoomFactor.mockReturnValue(0.5);
callsWidgetWindow.handleResize({
sender: {id: 'windowID'},
}, 'widget', {
element: 'calls-widget',
width: 300,
height: 100,
});
}, 300, 100);
expect(callsWidgetWindow.setBounds).toHaveBeenCalledWith({
x: 12,
y: 720 - (50 - MINIMUM_CALLS_WIDGET_HEIGHT),
@ -288,51 +277,26 @@ describe('main/windows/callsWidgetWindow', () => {
it('handleShareScreen', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.isAllowedEvent = jest.fn();
callsWidgetWindow.mainView = {
webContentsId: 'goodID',
};
callsWidgetWindow.win = {
webContents: {
id: 'goodID',
send: jest.fn(),
},
};
const message = {
callID: 'test-call-id',
};
callsWidgetWindow.isAllowedEvent.mockReturnValue(false);
callsWidgetWindow.handleShareScreen({
sender: {id: 'badID'},
}, message);
}, 'sourceId', true);
expect(callsWidgetWindow.win.webContents.send).not.toHaveBeenCalled();
callsWidgetWindow.isAllowedEvent.mockReturnValue(true);
callsWidgetWindow.handleShareScreen({
sender: {id: 'goodID'},
}, 'widget', message);
expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith(CALLS_WIDGET_SHARE_SCREEN, message);
});
it('handleJoinedCall', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.isAllowedEvent = jest.fn();
callsWidgetWindow.mainView = {
webContentsId: 'goodID',
sendToRenderer: jest.fn(),
};
const message = {
callID: 'test-call-id',
};
callsWidgetWindow.isAllowedEvent.mockReturnValue(false);
callsWidgetWindow.handleJoinedCall({
sender: {id: 'badID'},
}, 'widget', message);
expect(callsWidgetWindow.mainView.sendToRenderer).not.toHaveBeenCalled();
callsWidgetWindow.isAllowedEvent.mockReturnValue(true);
callsWidgetWindow.handleJoinedCall({
sender: {id: 'goodID'},
}, 'widget', message);
expect(callsWidgetWindow.mainView.sendToRenderer).toHaveBeenCalledWith(CALLS_JOINED_CALL, message);
}, 'sourceId', true);
expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith(CALLS_WIDGET_SHARE_SCREEN, 'sourceId', true);
});
describe('onPopOutOpen', () => {
@ -451,42 +415,6 @@ describe('main/windows/callsWidgetWindow', () => {
expect(callsWidgetWindow.getViewURL().toString()).toBe('http://localhost:8065/');
});
describe('isAllowedEvent', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.mainView = {
webContentsId: 'mainViewID',
};
callsWidgetWindow.win = {
webContents: {
id: 'windowID',
},
};
it('should not allow on unknown sender id', () => {
expect(callsWidgetWindow.isAllowedEvent({
sender: {
id: 'senderID',
},
})).toEqual(false);
});
it('should allow on attached browser view', () => {
expect(callsWidgetWindow.isAllowedEvent({
sender: {
id: 'mainViewID',
},
})).toEqual(true);
});
it('should allow on widget window', () => {
expect(callsWidgetWindow.isAllowedEvent({
sender: {
id: 'windowID',
},
})).toEqual(true);
});
});
it('onNavigate', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.getWidgetURL = () => 'http://localhost:8065';
@ -510,11 +438,12 @@ describe('main/windows/callsWidgetWindow', () => {
url: new URL('http://server-1.com'),
},
},
webContentsId: 2,
};
const browserWindow = {
on: jest.fn(),
once: jest.fn(),
loadURL: jest.fn().mockReturnValue(Promise.resolve()),
loadURL: jest.fn(),
webContents: {
setWindowOpenHandler: jest.fn(),
on: jest.fn(),
@ -524,9 +453,18 @@ describe('main/windows/callsWidgetWindow', () => {
};
beforeEach(() => {
let func;
ipcMain.on.mockImplementation((_, callback) => {
func = callback;
});
browserWindow.loadURL.mockImplementation(() => {
func({sender: {id: 1}}, 'test');
return Promise.resolve();
});
BrowserWindow.mockReturnValue(browserWindow);
callsWidgetWindow.close.mockReturnValue(Promise.resolve());
ViewManager.getView.mockReturnValue(view);
callsWidgetWindow.getWidgetURL.mockReturnValue('http://server-1.com/widget');
ViewManager.getViewByWebContentsId.mockReturnValue(view);
});
afterEach(() => {
@ -539,12 +477,7 @@ describe('main/windows/callsWidgetWindow', () => {
it('should create calls widget window', async () => {
expect(callsWidgetWindow.win).toBeUndefined();
await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_view-messaging', {callID: 'test'});
expect(callsWidgetWindow.win).toBeDefined();
});
it('should create with correct initial configuration', async () => {
await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_view-messaging', {callID: 'test'});
await callsWidgetWindow.handleCreateCallsWidgetWindow({sender: {id: 2}}, {callID: 'test'});
expect(BrowserWindow).toHaveBeenCalledWith(expect.objectContaining({
width: MINIMUM_CALLS_WIDGET_WIDTH,
height: MINIMUM_CALLS_WIDGET_HEIGHT,
@ -556,6 +489,7 @@ describe('main/windows/callsWidgetWindow', () => {
alwaysOnTop: true,
backgroundColor: '#00ffffff',
}));
expect(callsWidgetWindow.win).toBeDefined();
});
it('should catch error when failing to load the URL', async () => {
@ -570,18 +504,28 @@ describe('main/windows/callsWidgetWindow', () => {
});
it('should not create a new window if call is the same', async () => {
const window = {webContents: {id: 2}};
const window = {webContents: {id: 3}};
callsWidgetWindow.win = window;
callsWidgetWindow.options = {callID: 'test'};
await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_view-messaging', {callID: 'test'});
await callsWidgetWindow.handleCreateCallsWidgetWindow({sender: {id: 2}}, {callID: 'test'});
expect(callsWidgetWindow.win).toEqual(window);
});
it('should create a new window if switching calls', async () => {
const window = {webContents: {id: 2}};
let func;
ipcMain.on.mockImplementation((_, callback) => {
func = callback;
});
browserWindow.loadURL.mockImplementation(() => {
func({sender: {id: 1}}, 'test2');
return Promise.resolve();
});
BrowserWindow.mockReturnValue(browserWindow);
const window = {webContents: {id: 3}};
callsWidgetWindow.win = window;
callsWidgetWindow.getCallID = jest.fn(() => 'test');
await callsWidgetWindow.handleCreateCallsWidgetWindow('server-1_view-messaging', {callID: 'test2'});
callsWidgetWindow.options = {callID: 'test'};
await callsWidgetWindow.handleCreateCallsWidgetWindow({sender: {id: 2}}, {callID: 'test2'});
expect(callsWidgetWindow.win).not.toEqual(window);
});
});
@ -627,10 +571,13 @@ describe('main/windows/callsWidgetWindow', () => {
lastActiveView: 2,
},
];
let index = 0;
const map = servers.reduce((arr, item) => {
item.views.forEach((view) => {
index++;
arr.push([`${item.name}_${view.name}`, {
sendToRenderer: jest.fn(),
webContentsId: index,
}]);
});
return arr;
@ -638,7 +585,8 @@ describe('main/windows/callsWidgetWindow', () => {
const views = new Map(map);
beforeEach(() => {
ViewManager.getView.mockImplementation((viewId) => views.get(viewId));
ViewManager.getViewByWebContentsId.mockImplementation((id) => [...views.values()].find((view) => view.webContentsId === id));
callsWidgetWindow.mainView = views.get('server-1_view-1');
});
afterEach(() => {
@ -662,9 +610,8 @@ describe('main/windows/callsWidgetWindow', () => {
},
]);
await callsWidgetWindow.handleGetDesktopSources('server-1_view-1', null);
expect(views.get('server-1_view-1').sendToRenderer).toHaveBeenCalledWith('desktop-sources-result', [
const sources = await callsWidgetWindow.handleGetDesktopSources({sender: {id: 1}}, null);
expect(sources).toEqual([
{
id: 'screen0',
},
@ -676,11 +623,11 @@ describe('main/windows/callsWidgetWindow', () => {
it('should send error with no sources', async () => {
jest.spyOn(desktopCapturer, 'getSources').mockResolvedValue([]);
await callsWidgetWindow.handleGetDesktopSources('server-2_view-1', null);
await callsWidgetWindow.handleGetDesktopSources({sender: {id: 1}}, null);
expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', {
err: 'screen-permissions',
});
expect(views.get('server-2_view-1').sendToRenderer).toHaveBeenCalledWith('calls-error', {
expect(views.get('server-1_view-1').sendToRenderer).toHaveBeenCalledWith('calls-error', {
err: 'screen-permissions',
});
expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledTimes(1);
@ -697,7 +644,7 @@ describe('main/windows/callsWidgetWindow', () => {
]);
jest.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied');
await callsWidgetWindow.handleGetDesktopSources('server-1_view-1', null);
await callsWidgetWindow.handleGetDesktopSources({sender: {id: 1}}, null);
expect(systemPreferences.getMediaAccessStatus).toHaveBeenCalledWith('screen');
expect(callsWidgetWindow.win.webContents.send).toHaveBeenCalledWith('calls-error', {
@ -726,7 +673,7 @@ describe('main/windows/callsWidgetWindow', () => {
]);
jest.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied');
await callsWidgetWindow.handleGetDesktopSources('server-1_view-1', null);
await callsWidgetWindow.handleGetDesktopSources({sender: {id: 1}}, null);
expect(callsWidgetWindow.missingScreensharePermissions).toBe(true);
expect(resetScreensharePermissionsMacOS).toHaveBeenCalledTimes(1);
@ -738,7 +685,7 @@ describe('main/windows/callsWidgetWindow', () => {
err: 'screen-permissions',
});
await callsWidgetWindow.handleGetDesktopSources('server-1_view-1', null);
await callsWidgetWindow.handleGetDesktopSources({sender: {id: 1}}, null);
expect(resetScreensharePermissionsMacOS).toHaveBeenCalledTimes(2);
expect(openScreensharePermissionsSettingsMacOS).toHaveBeenCalledTimes(1);
@ -749,74 +696,9 @@ describe('main/windows/callsWidgetWindow', () => {
});
});
describe('handleDesktopSourcesModalRequest', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.mainView = {
view: {
server: {
id: 'server-1',
},
},
sendToRenderer: jest.fn(),
};
const servers = [
{
name: 'server-1',
order: 1,
views: [
{
name: 'view-1',
order: 0,
isOpen: false,
},
{
name: 'view-2',
order: 2,
isOpen: true,
},
],
}, {
name: 'server-2',
order: 0,
views: [
{
name: 'view-1',
order: 0,
isOpen: false,
},
{
name: 'view-2',
order: 2,
isOpen: true,
},
],
lastActiveView: 2,
},
];
const map = servers.reduce((arr, item) => {
item.views.forEach((view) => {
arr.push([`${item.name}_${view.name}`, {}]);
});
return arr;
}, []);
const views = new Map(map);
beforeEach(() => {
ViewManager.getView.mockImplementation((viewId) => views.get(viewId));
});
afterEach(() => {
jest.resetAllMocks();
});
it('should switch server', () => {
callsWidgetWindow.handleDesktopSourcesModalRequest();
expect(ServerViewState.switchServer).toHaveBeenCalledWith('server-1');
});
});
describe('handleCallsWidgetChannelLinkClick', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.win = {webContents: {id: 1}};
callsWidgetWindow.mainView = {
view: {
server: {
@ -877,89 +759,12 @@ describe('main/windows/callsWidgetWindow', () => {
});
it('should switch server', () => {
callsWidgetWindow.handleCallsWidgetChannelLinkClick();
callsWidgetWindow.handleCallsWidgetChannelLinkClick({sender: {id: 1}});
expect(ServerViewState.switchServer).toHaveBeenCalledWith('server-2');
});
});
describe('handleCallsError', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.mainView = {
view: {
server: {
id: 'server-2',
},
},
sendToRenderer: jest.fn(),
};
const focus = jest.fn();
beforeEach(() => {
MainWindow.get.mockReturnValue({focus});
});
afterEach(() => {
jest.resetAllMocks();
});
it('should focus view and propagate error to main view', () => {
callsWidgetWindow.handleCallsError('', {err: 'client-error'});
expect(ServerViewState.switchServer).toHaveBeenCalledWith('server-2');
expect(focus).toHaveBeenCalled();
expect(callsWidgetWindow.mainView.sendToRenderer).toHaveBeenCalledWith('calls-error', {err: 'client-error'});
});
});
describe('handleCallsLinkClick', () => {
const view = {
view: {
server: {
id: 'server-1',
},
},
sendToRenderer: jest.fn(),
};
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.mainView = view;
beforeEach(() => {
ViewManager.getView.mockReturnValue(view);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should pass through the click link to browser history push', () => {
callsWidgetWindow.handleCallsLinkClick('', {link: '/other/subpath'});
expect(ServerViewState.switchServer).toHaveBeenCalledWith('server-1');
expect(view.sendToRenderer).toBeCalledWith('browser-history-push', '/other/subpath');
});
});
describe('genCallsEventHandler', () => {
const handler = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('should not call handler if source is not allowed', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.isAllowedEvent = () => false;
callsWidgetWindow.genCallsEventHandler(handler)();
expect(handler).not.toHaveBeenCalled();
});
it('should call handler if source is allowed', () => {
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.isAllowedEvent = () => true;
callsWidgetWindow.genCallsEventHandler(handler)();
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe('handleCallsJoinRequest', () => {
describe('forwardToMainApp', () => {
const view = {
view: {
server: {
@ -970,6 +775,7 @@ describe('main/windows/callsWidgetWindow', () => {
};
const callsWidgetWindow = new CallsWidgetWindow();
callsWidgetWindow.mainView = view;
callsWidgetWindow.win = {webContents: {id: 1}};
const focus = jest.fn();
@ -982,11 +788,12 @@ describe('main/windows/callsWidgetWindow', () => {
jest.resetAllMocks();
});
it('should pass through the join call callID to the webapp', () => {
callsWidgetWindow.handleCallsJoinRequest('', {callID: 'thecallchannelid'});
it('should pass through the arguments to the webapp', () => {
const func = callsWidgetWindow.forwardToMainApp('some-channel');
func({sender: {id: 1}}, 'thecallchannelid');
expect(ServerViewState.switchServer).toHaveBeenCalledWith('server-1');
expect(focus).toHaveBeenCalled();
expect(view.sendToRenderer).toBeCalledWith(CALLS_JOIN_REQUEST, {callID: 'thecallchannelid'});
expect(view.sendToRenderer).toBeCalledWith('some-channel', 'thecallchannelid');
});
});
});

View file

@ -1,17 +1,10 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BrowserWindow, desktopCapturer, ipcMain, IpcMainEvent, Rectangle, systemPreferences, Event} from 'electron';
import {BrowserWindow, desktopCapturer, ipcMain, IpcMainEvent, Rectangle, systemPreferences, Event, IpcMainInvokeEvent} from 'electron';
import {
CallsErrorMessage,
CallsEventHandler,
CallsJoinCallMessage,
CallsJoinedCallMessage,
CallsJoinRequestMessage,
CallsLinkClickMessage,
CallsWidgetResizeMessage,
CallsWidgetShareScreenMessage,
CallsWidgetWindowConfig,
} from 'types/calls';
@ -34,8 +27,7 @@ import {
CALLS_WIDGET_RESIZE,
CALLS_WIDGET_SHARE_SCREEN,
DESKTOP_SOURCES_MODAL_REQUEST,
DESKTOP_SOURCES_RESULT,
DISPATCH_GET_DESKTOP_SOURCES,
GET_DESKTOP_SOURCES,
} from 'common/communication';
import {MattermostBrowserView} from 'main/views/MattermostBrowserView';
@ -68,16 +60,19 @@ export class CallsWidgetWindow {
constructor() {
ipcMain.on(CALLS_WIDGET_RESIZE, this.handleResize);
ipcMain.on(CALLS_WIDGET_SHARE_SCREEN, this.handleShareScreen);
ipcMain.on(CALLS_JOINED_CALL, this.handleJoinedCall);
ipcMain.on(CALLS_POPOUT_FOCUS, this.handlePopOutFocus);
ipcMain.on(DISPATCH_GET_DESKTOP_SOURCES, this.genCallsEventHandler(this.handleGetDesktopSources));
ipcMain.on(DESKTOP_SOURCES_MODAL_REQUEST, this.genCallsEventHandler(this.handleDesktopSourcesModalRequest));
ipcMain.on(CALLS_JOIN_CALL, this.genCallsEventHandler(this.handleCreateCallsWidgetWindow));
ipcMain.on(CALLS_LEAVE_CALL, this.genCallsEventHandler(this.handleCallsLeave));
ipcMain.on(CALLS_WIDGET_CHANNEL_LINK_CLICK, this.genCallsEventHandler(this.handleCallsWidgetChannelLinkClick));
ipcMain.on(CALLS_ERROR, this.genCallsEventHandler(this.handleCallsError));
ipcMain.on(CALLS_LINK_CLICK, this.genCallsEventHandler(this.handleCallsLinkClick));
ipcMain.on(CALLS_JOIN_REQUEST, this.genCallsEventHandler(this.handleCallsJoinRequest));
ipcMain.handle(GET_DESKTOP_SOURCES, this.handleGetDesktopSources);
ipcMain.handle(CALLS_JOIN_CALL, this.handleCreateCallsWidgetWindow);
ipcMain.on(CALLS_LEAVE_CALL, this.handleCallsLeave);
// forwards to the main app
ipcMain.on(DESKTOP_SOURCES_MODAL_REQUEST, this.forwardToMainApp(DESKTOP_SOURCES_MODAL_REQUEST));
ipcMain.on(CALLS_ERROR, this.forwardToMainApp(CALLS_ERROR));
ipcMain.on(CALLS_LINK_CLICK, this.forwardToMainApp(CALLS_LINK_CLICK));
ipcMain.on(CALLS_JOIN_REQUEST, this.forwardToMainApp(CALLS_JOIN_REQUEST));
// deprecated in favour of CALLS_LINK_CLICK
ipcMain.on(CALLS_WIDGET_CHANNEL_LINK_CLICK, this.handleCallsWidgetChannelLinkClick);
}
/**
@ -140,7 +135,7 @@ export class CallsWidgetWindow {
hasShadow: false,
backgroundColor: '#00ffffff',
webPreferences: {
preload: getLocalPreload('callsWidget.js'),
preload: getLocalPreload('externalAPI.js'),
},
});
this.mainView = view;
@ -203,28 +198,6 @@ export class CallsWidgetWindow {
this.boundsErr = Utils.boundsDiff(bounds, this.win.getBounds());
}
private isAllowedEvent = (event: IpcMainEvent) => {
// Allow events when a call isn't in progress
if (!(this.win && this.mainView)) {
return true;
}
// Only allow events coming from either the widget window or the
// original Mattermost view that initiated it.
return event.sender.id === this.win?.webContents.id ||
event.sender.id === this.mainView?.webContentsId;
}
private genCallsEventHandler = (handler: CallsEventHandler) => {
return (event: IpcMainEvent, viewId: string, msg?: any) => {
if (!this.isAllowedEvent(event)) {
log.warn('genCallsEventHandler', 'Disallowed calls event');
return;
}
handler(viewId, msg);
};
}
/**
* BrowserWindow/WebContents handlers
*/
@ -333,15 +306,15 @@ export class CallsWidgetWindow {
* IPC HANDLERS
************************/
private handleResize = (ev: IpcMainEvent, _: string, msg: CallsWidgetResizeMessage) => {
log.debug('onResize', msg);
private handleResize = (ev: IpcMainEvent, width: number, height: number) => {
log.debug('handleResize', width, height);
if (!this.win) {
return;
}
if (!this.isAllowedEvent(ev)) {
log.warn('onResize', 'Disallowed calls event');
if (!this.isCallsWidget(ev.sender.id)) {
log.debug('handleResize', 'Disallowed calls event');
return;
}
@ -349,34 +322,23 @@ export class CallsWidgetWindow {
const currBounds = this.win.getBounds();
const newBounds = {
x: currBounds.x,
y: currBounds.y - (Math.ceil(msg.height * zoomFactor) - currBounds.height),
width: Math.ceil(msg.width * zoomFactor),
height: Math.ceil(msg.height * zoomFactor),
y: currBounds.y - (Math.ceil(height * zoomFactor) - currBounds.height),
width: Math.ceil(width * zoomFactor),
height: Math.ceil(height * zoomFactor),
};
this.setBounds(newBounds);
}
private handleShareScreen = (ev: IpcMainEvent, _: string, message: CallsWidgetShareScreenMessage) => {
log.debug('handleShareScreen');
private handleShareScreen = (ev: IpcMainEvent, sourceID: string, withAudio: boolean) => {
log.debug('handleShareScreen', {sourceID, withAudio});
if (!this.isAllowedEvent(ev)) {
log.warn('Disallowed calls event');
if (this.mainView?.webContentsId !== ev.sender.id) {
log.debug('handleShareScreen', 'blocked on wrong webContentsId');
return;
}
this.win?.webContents.send(CALLS_WIDGET_SHARE_SCREEN, message);
}
private handleJoinedCall = (ev: IpcMainEvent, _: string, message: CallsJoinedCallMessage) => {
log.debug('handleJoinedCall');
if (!this.isAllowedEvent(ev)) {
log.warn('handleJoinedCall', 'Disallowed calls event');
return;
}
this.mainView?.sendToRenderer(CALLS_JOINED_CALL, message);
this.win?.webContents.send(CALLS_WIDGET_SHARE_SCREEN, sourceID, withAudio);
}
private handlePopOutFocus = () => {
@ -389,13 +351,18 @@ export class CallsWidgetWindow {
this.popOut.focus();
}
private handleGetDesktopSources = async (viewId: string, opts: Electron.SourcesOptions) => {
private handleGetDesktopSources = async (event: IpcMainInvokeEvent, opts: Electron.SourcesOptions) => {
log.debug('handleGetDesktopSources', opts);
const view = ViewManager.getView(viewId);
if (event.sender.id !== this.mainView?.webContentsId) {
log.warn('handleGetDesktopSources', 'Blocked on wrong webContentsId');
return [];
}
const view = ViewManager.getViewByWebContentsId(event.sender.id);
if (!view) {
log.error('handleGetDesktopSources: view not found');
return Promise.resolve();
return [];
}
if (process.platform === 'darwin' && systemPreferences.getMediaAccessStatus('screen') === 'denied') {
@ -432,7 +399,7 @@ export class CallsWidgetWindow {
log.info('missing screen permissions');
view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg);
this.win?.webContents.send(CALLS_ERROR, screenPermissionsErrMsg);
return;
return [];
}
const message = sources.map((source) => {
@ -443,53 +410,63 @@ export class CallsWidgetWindow {
};
});
if (message.length > 0) {
view.sendToRenderer(DESKTOP_SOURCES_RESULT, message);
}
return message;
}).catch((err) => {
log.error('desktopCapturer.getSources failed', err);
view.sendToRenderer(CALLS_ERROR, screenPermissionsErrMsg);
this.win?.webContents.send(CALLS_ERROR, screenPermissionsErrMsg);
return [];
});
}
private handleCreateCallsWidgetWindow = async (viewId: string, msg: CallsJoinCallMessage) => {
private handleCreateCallsWidgetWindow = async (event: IpcMainInvokeEvent, msg: CallsJoinCallMessage) => {
log.debug('createCallsWidgetWindow');
// trying to join again the call we are already in should not be allowed.
if (this.options?.callID === msg.callID) {
return;
return Promise.resolve();
}
// to switch from one call to another we need to wait for the existing
// window to be fully closed.
await this.close();
const currentView = ViewManager.getView(viewId);
const currentView = ViewManager.getViewByWebContentsId(event.sender.id);
if (!currentView) {
log.error('unable to create calls widget window: currentView is missing');
return Promise.resolve();
}
const promise = new Promise((resolve) => {
const connected = (ev: IpcMainEvent, incomingCallId: string, incomingSessionId: string) => {
log.debug('onJoinedCall', incomingCallId);
if (!this.isCallsWidget(ev.sender.id)) {
log.debug('onJoinedCall', 'blocked on wrong webContentsId');
return;
}
if (msg.callID !== incomingCallId) {
log.debug('onJoinedCall', 'blocked on wrong callId');
return;
}
ipcMain.off(CALLS_JOINED_CALL, connected);
resolve({callID: msg.callID, sessionID: incomingSessionId});
};
ipcMain.on(CALLS_JOINED_CALL, connected);
});
this.init(currentView, {
callID: msg.callID,
title: msg.title,
rootID: msg.rootID,
channelURL: msg.channelURL,
});
}
private handleDesktopSourcesModalRequest = () => {
log.debug('handleDesktopSourcesModalRequest');
if (!this.serverID) {
return;
}
ServerViewState.switchServer(this.serverID);
MainWindow.get()?.focus();
this.mainView?.sendToRenderer(DESKTOP_SOURCES_MODAL_REQUEST);
return promise;
}
private handleCallsLeave = () => {
@ -498,9 +475,34 @@ export class CallsWidgetWindow {
this.close();
}
private handleCallsWidgetChannelLinkClick = () => {
private forwardToMainApp = (channel: string) => {
return (event: IpcMainEvent, ...args: any) => {
log.debug('forwardToMainApp', channel, ...args);
if (!this.isCallsWidget(event.sender.id)) {
return;
}
if (!this.serverID) {
return;
}
ServerViewState.switchServer(this.serverID);
MainWindow.get()?.focus();
this.mainView?.sendToRenderer(channel, ...args);
};
}
/**
* @deprecated
*/
private handleCallsWidgetChannelLinkClick = (event: IpcMainEvent) => {
log.debug('handleCallsWidgetChannelLinkClick');
if (!this.isCallsWidget(event.sender.id)) {
return;
}
if (!this.serverID) {
return;
}
@ -509,41 +511,6 @@ export class CallsWidgetWindow {
MainWindow.get()?.focus();
this.mainView?.sendToRenderer(BROWSER_HISTORY_PUSH, this.options?.channelURL);
}
private handleCallsError = (_: string, msg: CallsErrorMessage) => {
log.debug('handleCallsError', msg);
if (!this.serverID) {
return;
}
ServerViewState.switchServer(this.serverID);
MainWindow.get()?.focus();
this.mainView?.sendToRenderer(CALLS_ERROR, msg);
}
private handleCallsLinkClick = (_: string, msg: CallsLinkClickMessage) => {
log.debug('handleCallsLinkClick with linkURL', msg.link);
if (!this.serverID) {
return;
}
ServerViewState.switchServer(this.serverID);
MainWindow.get()?.focus();
this.mainView?.sendToRenderer(BROWSER_HISTORY_PUSH, msg.link);
}
private handleCallsJoinRequest = (_: string, msg: CallsJoinRequestMessage) => {
log.debug('handleCallsJoinRequest with callID', msg.callID);
if (!this.serverID) {
return;
}
ServerViewState.switchServer(this.serverID);
MainWindow.get()?.focus();
this.mainView?.sendToRenderer(CALLS_JOIN_REQUEST, msg);
}
}
const callsWidgetWindow = new CallsWidgetWindow();

View file

@ -86,7 +86,7 @@ export class MainWindow extends EventEmitter {
backgroundColor: '#fff', // prevents blurry text: https://electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
webPreferences: {
disableBlinkFeatures: 'Auxclick',
preload: getLocalPreload('desktopAPI.js'),
preload: getLocalPreload('internalAPI.js'),
spellcheck: typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker,
},
});

View file

@ -43,7 +43,7 @@ export class SettingsWindow {
return;
}
const preload = getLocalPreload('desktopAPI.js');
const preload = getLocalPreload('internalAPI.js');
const spellcheck = (typeof Config.useSpellChecker === 'undefined' ? true : Config.useSpellChecker);
this.win = new BrowserWindow({
parent: mainWindow,

View file

@ -8,34 +8,3 @@ export type CallsWidgetWindowConfig = {
}
export type CallsJoinCallMessage = CallsWidgetWindowConfig;
export type CallsWidgetResizeMessage = {
element: string;
width: number;
height: number;
}
export type CallsWidgetShareScreenMessage = {
sourceID: string;
withAudio: boolean;
}
export type CallsJoinedCallMessage = {
callID: string;
}
export type CallsErrorMessage = {
err: string;
callID?: string;
errMsg?: string;
}
export type CallsLinkClickMessage = {
link: string | URL;
}
export type CallsJoinRequestMessage = {
callID: string;
}
export type CallsEventHandler = ((viewName: string, msg: any) => void) | ((viewName: string, opts: Electron.SourcesOptions) => Promise<void>);

24
src/types/externalAPI.ts Normal file
View file

@ -0,0 +1,24 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface ExternalAPI {
createListener(event: 'user-activity-update', listener: (
userIsActive: boolean,
idleTime: number,
isSystemEvent: boolean,
) => void): () => void;
createListener(event: 'notification-clicked', listener: (
channelId: string,
teamId: string,
url: string,
) => void): () => void;
createListener(event: 'browser-history-status-updated', listener: (
canGoBack: boolean,
canGoForward: boolean,
) => void): () => void;
createListener(event: 'browser-history-push', listener: (path: string) => void): () => void;
createListener(event: 'calls-widget-share-screen', listener: (sourceID: string, withAudio: boolean) => void): () => void;
createListener(event: 'calls-join-request', listener: (callID: string) => void): () => void;
createListener(event: 'calls-error', listener: (err: string, callID?: string, errMsg?: string) => void): () => void;
createListener(event: 'desktop-sources-modal-request', listener: () => void): () => void;
}

View file

@ -3,10 +3,6 @@
import {NotificationConstructorOptions} from 'electron/common';
export type MentionData = {
export type MentionOptions = NotificationConstructorOptions & {
soundName: string;
}
export type MentionOptions = NotificationConstructorOptions & {
data: MentionData;
}

View file

@ -15,9 +15,8 @@ const base = require('./webpack.config.base');
module.exports = merge(base, {
entry: {
index: './src/main/app/index.ts',
desktopAPI: './src/main/preload/desktopAPI.js',
preload: './src/main/preload/mattermost.js',
callsWidget: './src/main/preload/callsWidget.js',
internalAPI: './src/main/preload/internalAPI.js',
externalAPI: './src/main/preload/externalAPI.ts',
},
externals: {
'macos-notification-state': 'require("macos-notification-state")',