[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
41 changed files with 1071 additions and 1040 deletions

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;
}