[MM-54863] Add permissions manager UI in Edit Server modal, improve permission checks to be less missable (#3059)

* [MM-54863] Add permissions manager UI in Edit Server modal, improve permission checks to be less missable

* Removing this for E2E (which was having issues anyways)

* PR feedback

* Disable permissions dialog for current E2E tests

* Fixed the dark mode CSS

* Update icon
This commit is contained in:
Devin Binnie
2024-06-19 09:19:24 -04:00
committed by GitHub
parent 7b1b25b6e0
commit 0d4800fd61
20 changed files with 711 additions and 234 deletions

View File

@@ -5,7 +5,7 @@ import {app, shell, Notification, ipcMain} from 'electron';
import isDev from 'electron-is-dev';
import {getDoNotDisturb as getDarwinDoNotDisturb} from 'macos-notification-state';
import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH} from 'common/communication';
import {PLAY_SOUND, NOTIFICATION_CLICKED, BROWSER_HISTORY_PUSH, OPEN_NOTIFICATION_PREFERENCES} from 'common/communication';
import Config from 'common/config';
import {Logger} from 'common/log';
@@ -27,6 +27,10 @@ class NotificationManager {
private upgradeNotification?: NewVersionNotification;
private restartToUpgradeNotification?: UpgradeNotification;
constructor() {
ipcMain.on(OPEN_NOTIFICATION_PREFERENCES, this.openNotificationPreferences);
}
public async displayMention(title: string, body: string, channelId: string, teamId: string, url: string, silent: boolean, webcontents: Electron.WebContents, soundName: string) {
log.debug('displayMention', {title, channelId, teamId, url, silent, soundName});
@@ -209,6 +213,17 @@ class NotificationManager {
});
this.restartToUpgradeNotification.show();
}
private openNotificationPreferences() {
switch (process.platform) {
case 'darwin':
shell.openExternal('x-apple.systempreferences:com.apple.preference.notifications?Notifications');
break;
case 'win32':
shell.openExternal('ms-settings:notifications');
break;
}
}
}
export async function getDoNotDisturb() {

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {dialog} from 'electron';
import {dialog, systemPreferences} from 'electron';
import {parseURL, isTrustedURL} from 'common/utils/url';
import ViewManager from 'main/views/viewManager';
@@ -21,10 +21,15 @@ jest.mock('electron', () => ({
},
ipcMain: {
on: jest.fn(),
handle: jest.fn(),
},
dialog: {
showMessageBox: jest.fn(),
},
systemPreferences: {
getMediaAccessStatus: jest.fn(),
askForMediaAccess: jest.fn(),
},
}));
jest.mock('common/utils/url', () => ({
@@ -47,169 +52,185 @@ jest.mock('main/windows/mainWindow', () => ({
}));
describe('main/PermissionsManager', () => {
beforeEach(() => {
MainWindow.get.mockReturnValue({webContents: {id: 1}});
ViewManager.getViewByWebContentsId.mockImplementation((id) => {
if (id === 2) {
return {view: {server: {url: new URL('http://anyurl.com')}}};
}
return null;
describe('setForServer', () => {
it('should ask for media permission when is not granted but the user explicitly granted it', () => {
systemPreferences.getMediaAccessStatus.mockReturnValue('denied');
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.setForServer({url: new URL('http://anyurl.com')}, {media: {allowed: true}});
expect(systemPreferences.askForMediaAccess).toHaveBeenNthCalledWith(1, 'microphone');
expect(systemPreferences.askForMediaAccess).toHaveBeenNthCalledWith(2, 'camera');
});
CallsWidgetWindow.isCallsWidget.mockImplementation((id) => id === 3);
parseURL.mockImplementation((url) => {
try {
return new URL(url);
} catch {
});
describe('handlePermissionRequest', () => {
const env = process.env;
beforeEach(() => {
process.env = {...env, NODE_ENV: 'jest'};
MainWindow.get.mockReturnValue({webContents: {id: 1}});
ViewManager.getViewByWebContentsId.mockImplementation((id) => {
if (id === 2) {
return {view: {server: {url: new URL('http://anyurl.com')}}};
}
return null;
}
});
CallsWidgetWindow.isCallsWidget.mockImplementation((id) => id === 3);
parseURL.mockImplementation((url) => {
try {
return new URL(url);
} catch {
return null;
}
});
isTrustedURL.mockImplementation((url, baseURL) => url.toString().startsWith(baseURL.toString()));
});
isTrustedURL.mockImplementation((url, baseURL) => url.toString().startsWith(baseURL.toString()));
});
afterEach(() => {
jest.resetAllMocks();
});
it('should deny if the permission is not supported', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({}, 'some-other-permission', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should allow if the request came from the main window', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 1}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(true);
});
it('should deny if the URL is malformed', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'abadurl!?'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should deny if the server URL can not be found', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 4}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should deny if the URL is not trusted', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://wrongurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should allow if dialog is not required', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'fullscreen', cb, {requestingUrl: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(true);
});
it('should allow if already confirmed by user', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.json = {
'http://anyurl.com': {
media: {
allowed: true,
},
},
};
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(true);
});
it('should deny if set to permanently deny', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.json = {
'http://anyurl.com': {
media: {
alwaysDeny: true,
},
},
};
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should pop dialog and allow if the user allows, should save to file', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(true);
expect(permissionsManager.writeToFile).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(true);
});
it('should pop dialog and deny if the user denies', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 0}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false);
expect(permissionsManager.writeToFile).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(false);
});
it('should pop dialog and deny permanently if the user chooses', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 1}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false);
expect(permissionsManager.json['http://anyurl.com'].media.alwaysDeny).toBe(true);
expect(permissionsManager.writeToFile).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(false);
});
it('should only pop dialog once upon multiple permission checks', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await Promise.all([
permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}),
permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}),
permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}),
]);
expect(dialog.showMessageBox).toHaveBeenCalledTimes(1);
});
it('should still pop dialog for media requests from the servers origin', async () => {
ViewManager.getViewByWebContentsId.mockImplementation((id) => {
if (id === 2) {
return {view: {server: {url: new URL('http://anyurl.com/subpath')}}};
}
return null;
afterEach(() => {
jest.resetAllMocks();
process.env = env;
});
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(dialog.showMessageBox).toHaveBeenCalled();
});
it('should pop dialog for external applications', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await permissionsManager.handlePermissionRequest({id: 2}, 'openExternal', cb, {requestingUrl: 'http://anyurl.com', externalURL: 'ms-excel://differenturl.com'});
expect(dialog.showMessageBox).toHaveBeenCalled();
it('should deny if the permission is not supported', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({}, 'some-other-permission', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should allow if the request came from the main window', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 1}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(true);
});
it('should deny if the URL is malformed', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'abadurl!?'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should deny if the server URL can not be found', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 4}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should deny if the URL is not trusted', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://wrongurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should allow if dialog is not required', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'fullscreen', cb, {requestingUrl: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(true);
});
it('should allow if already confirmed by user', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.json = {
'http://anyurl.com': {
media: {
allowed: true,
},
},
};
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(true);
});
it('should deny if set to permanently deny', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.json = {
'http://anyurl.com': {
media: {
alwaysDeny: true,
},
},
};
const cb = jest.fn();
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(cb).toHaveBeenCalledWith(false);
});
it('should pop dialog and allow if the user allows, should save to file', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(true);
expect(permissionsManager.writeToFile).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(true);
});
it('should pop dialog and deny if the user denies', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 0}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false);
expect(permissionsManager.writeToFile).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(false);
});
it('should pop dialog and deny permanently if the user chooses', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 1}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(permissionsManager.json['http://anyurl.com'].media.allowed).toBe(false);
expect(permissionsManager.json['http://anyurl.com'].media.alwaysDeny).toBe(true);
expect(permissionsManager.writeToFile).toHaveBeenCalled();
expect(cb).toHaveBeenCalledWith(false);
});
it('should only pop dialog once upon multiple permission checks', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await Promise.all([
permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}),
permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}),
permissionsManager.handlePermissionRequest({id: 2}, 'notifications', cb, {requestingUrl: 'http://anyurl.com'}),
]);
expect(dialog.showMessageBox).toHaveBeenCalledTimes(1);
});
it('should still pop dialog for media requests from the servers origin', async () => {
ViewManager.getViewByWebContentsId.mockImplementation((id) => {
if (id === 2) {
return {view: {server: {url: new URL('http://anyurl.com/subpath')}}};
}
return null;
});
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await permissionsManager.handlePermissionRequest({id: 2}, 'media', cb, {securityOrigin: 'http://anyurl.com'});
expect(dialog.showMessageBox).toHaveBeenCalled();
});
it('should pop dialog for external applications', async () => {
const permissionsManager = new PermissionsManager('anyfile.json');
permissionsManager.writeToFile = jest.fn();
const cb = jest.fn();
dialog.showMessageBox.mockReturnValue(Promise.resolve({response: 2}));
await permissionsManager.handlePermissionRequest({id: 2}, 'openExternal', cb, {requestingUrl: 'http://anyurl.com', externalURL: 'ms-excel://differenturl.com'});
expect(dialog.showMessageBox).toHaveBeenCalled();
});
});
});

View File

@@ -2,17 +2,26 @@
// See LICENSE.txt for license information.
import type {
IpcMainInvokeEvent,
PermissionRequestHandlerHandlerDetails,
WebContents} from 'electron';
import {
app,
dialog,
ipcMain,
shell,
systemPreferences,
} from 'electron';
import {UPDATE_PATHS} from 'common/communication';
import {
GET_MEDIA_ACCESS_STATUS,
OPEN_WINDOWS_CAMERA_PREFERENCES,
OPEN_WINDOWS_MICROPHONE_PREFERENCES,
UPDATE_PATHS,
} from 'common/communication';
import JsonFileManager from 'common/JsonFileManager';
import {Logger} from 'common/log';
import type {MattermostServer} from 'common/servers/MattermostServer';
import {isTrustedURL, parseURL} from 'common/utils/url';
import {t} from 'common/utils/util';
import {permissionsJson} from 'main/constants';
@@ -21,6 +30,8 @@ import ViewManager from 'main/views/viewManager';
import CallsWidgetWindow from 'main/windows/callsWidgetWindow';
import MainWindow from 'main/windows/mainWindow';
import type {Permissions} from 'types/permissions';
const log = new Logger('PermissionsManager');
// supported permission types
@@ -41,22 +52,21 @@ const authorizablePermissionTypes = [
'openExternal',
];
type Permissions = {
[origin: string]: {
[permission: string]: {
allowed: boolean;
alwaysDeny?: boolean;
};
};
type PermissionsByOrigin = {
[origin: string]: Permissions;
};
export class PermissionsManager extends JsonFileManager<Permissions> {
private inflightPermissionChecks: Set<string>;
export class PermissionsManager extends JsonFileManager<PermissionsByOrigin> {
private inflightPermissionChecks: Map<string, Promise<boolean>>;
constructor(file: string) {
super(file);
this.inflightPermissionChecks = new Set();
this.inflightPermissionChecks = new Map();
ipcMain.on(OPEN_WINDOWS_CAMERA_PREFERENCES, this.openWindowsCameraPreferences);
ipcMain.on(OPEN_WINDOWS_MICROPHONE_PREFERENCES, this.openWindowsMicrophonePreferences);
ipcMain.handle(GET_MEDIA_ACCESS_STATUS, this.handleGetMediaAccessStatus);
}
handlePermissionRequest = async (
@@ -72,6 +82,30 @@ export class PermissionsManager extends JsonFileManager<Permissions> {
));
};
getForServer = (server: MattermostServer): Permissions | undefined => {
return this.getValue(server.url.origin);
};
setForServer = (server: MattermostServer, permissions: Permissions) => {
if (permissions.media?.allowed) {
this.checkMediaAccess('microphone');
this.checkMediaAccess('camera');
}
return this.setValue(server.url.origin, permissions);
};
private checkMediaAccess = (mediaType: 'microphone' | 'camera') => {
if (systemPreferences.getMediaAccessStatus(mediaType) !== 'granted') {
// For windows, the user needs to enable these manually
if (process.platform === 'win32') {
log.warn(`${mediaType} access disabled in Windows settings`);
}
systemPreferences.askForMediaAccess(mediaType);
}
};
doPermissionRequest = async (
webContentsId: number,
permission: string,
@@ -141,44 +175,59 @@ export class PermissionsManager extends JsonFileManager<Permissions> {
// Make sure we don't pop multiple dialogs for the same permission check
const permissionKey = `${parsedURL.origin}:${permission}`;
if (this.inflightPermissionChecks.has(permissionKey)) {
return false;
return this.inflightPermissionChecks.get(permissionKey)!;
}
this.inflightPermissionChecks.add(permissionKey);
// Show the dialog to ask the user
const {response} = await dialog.showMessageBox(mainWindow, {
title: localizeMessage('main.permissionsManager.checkPermission.dialog.title', 'Permission Requested'),
message: localizeMessage(`main.permissionsManager.checkPermission.dialog.message.${permission}`, '{appName} ({url}) is requesting the "{permission}" permission.', {appName: app.name, url: parsedURL.origin, permission, externalURL: details.externalURL}),
detail: localizeMessage(`main.permissionsManager.checkPermission.dialog.detail.${permission}`, 'Would you like to grant {appName} this permission?', {appName: app.name}),
type: 'question',
buttons: [
localizeMessage('label.deny', 'Deny'),
localizeMessage('label.denyPermanently', 'Deny Permanently'),
localizeMessage('label.allow', 'Allow'),
],
const promise = new Promise<boolean>((resolve) => {
if (process.env.NODE_ENV === 'test') {
resolve(false);
return;
}
// Show the dialog to ask the user
dialog.showMessageBox(mainWindow, {
title: localizeMessage('main.permissionsManager.checkPermission.dialog.title', 'Permission Requested'),
message: localizeMessage(`main.permissionsManager.checkPermission.dialog.message.${permission}`, '{appName} ({url}) is requesting the "{permission}" permission.', {appName: app.name, url: parsedURL.origin, permission, externalURL: details.externalURL}),
detail: localizeMessage(`main.permissionsManager.checkPermission.dialog.detail.${permission}`, 'Would you like to grant {appName} this permission?', {appName: app.name}),
type: 'question',
buttons: [
localizeMessage('label.deny', 'Deny'),
localizeMessage('label.denyPermanently', 'Deny Permanently'),
localizeMessage('label.allow', 'Allow'),
],
}).then(({response}) => {
// Save their response
const newPermission = {
allowed: response === 2,
alwaysDeny: (response === 1) ? true : undefined,
};
this.json[parsedURL.origin] = {
...this.json[parsedURL.origin],
[permission]: newPermission,
};
this.writeToFile();
this.inflightPermissionChecks.delete(permissionKey);
if (response < 2) {
resolve(false);
}
resolve(true);
});
});
// Save their response
const newPermission = {
allowed: response === 2,
alwaysDeny: (response === 1) ? true : undefined,
};
this.json[parsedURL.origin] = {
...this.json[parsedURL.origin],
[permission]: newPermission,
};
this.writeToFile();
this.inflightPermissionChecks.delete(permissionKey);
if (response < 2) {
return false;
}
this.inflightPermissionChecks.set(permissionKey, promise);
return promise;
}
// We've checked everything so we're okay to grant the remaining cases
return true;
};
private openWindowsCameraPreferences = () => shell.openExternal('ms-settings:privacy-webcam');
private openWindowsMicrophonePreferences = () => shell.openExternal('ms-settings:privacy-microphone');
private handleGetMediaAccessStatus = (event: IpcMainInvokeEvent, mediaType: 'microphone' | 'camera' | 'screen') => systemPreferences.getMediaAccessStatus(mediaType);
}
t('main.permissionsManager.checkPermission.dialog.message.media');

View File

@@ -90,6 +90,10 @@ import {
SERVERS_UPDATE,
VALIDATE_SERVER_URL,
GET_APP_INFO,
OPEN_NOTIFICATION_PREFERENCES,
OPEN_WINDOWS_CAMERA_PREFERENCES,
OPEN_WINDOWS_MICROPHONE_PREFERENCES,
GET_MEDIA_ACCESS_STATUS,
} from 'common/communication';
console.log('Preload initialized');
@@ -175,6 +179,10 @@ contextBridge.exposeInMainWorld('desktop', {
onAppMenuWillClose: (listener) => ipcRenderer.on(APP_MENU_WILL_CLOSE, () => listener()),
onFocusThreeDotMenu: (listener) => ipcRenderer.on(FOCUS_THREE_DOT_MENU, () => listener()),
updateURLViewWidth: (width) => ipcRenderer.send(UPDATE_URL_VIEW_WIDTH, width),
openNotificationPreferences: () => ipcRenderer.send(OPEN_NOTIFICATION_PREFERENCES),
openWindowsCameraPreferences: () => ipcRenderer.send(OPEN_WINDOWS_CAMERA_PREFERENCES),
openWindowsMicrophonePreferences: () => ipcRenderer.send(OPEN_WINDOWS_MICROPHONE_PREFERENCES),
getMediaAccessStatus: (mediaType) => ipcRenderer.invoke(GET_MEDIA_ACCESS_STATUS, mediaType),
downloadsDropdown: {
toggleDownloadsDropdownMenu: (payload) => ipcRenderer.send(TOGGLE_DOWNLOADS_DROPDOWN_MENU, payload),

View File

@@ -8,6 +8,7 @@ import {BROWSER_HISTORY_PUSH, LOAD_SUCCESS, SET_ACTIVE_VIEW} from 'common/commun
import ServerManager from 'common/servers/serverManager';
import urlUtils from 'common/utils/url';
import {TAB_MESSAGING} from 'common/views/View';
import PermissionsManager from 'main/permissionsManager';
import MainWindow from 'main/windows/mainWindow';
import LoadingScreen from './loadingScreen';
@@ -64,6 +65,11 @@ jest.mock('main/i18nManager', () => ({
localizeMessage: jest.fn(),
}));
jest.mock('main/permissionsManager', () => ({
getForServer: jest.fn(),
doPermissionRequest: jest.fn(),
}));
jest.mock('main/server/serverInfo', () => ({
ServerInfo: jest.fn(),
}));
@@ -129,6 +135,8 @@ describe('main/views/viewManager', () => {
once: onceFn,
destroy: destroyFn,
id: view.id,
view,
webContentsId: 1,
}));
});
@@ -143,19 +151,48 @@ describe('main/views/viewManager', () => {
expect(viewManager.closedViews.has('view1')).toBe(true);
});
it('should remove from remove from closedViews when the view is open', () => {
viewManager.closedViews.set('view1', {});
expect(viewManager.closedViews.has('view1')).toBe(true);
viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true});
expect(viewManager.closedViews.has('view1')).toBe(false);
});
it('should add view to views map and add listeners', () => {
viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true}, 'http://server-1.com/subpath');
expect(viewManager.views.has('view1')).toBe(true);
expect(onceFn).toHaveBeenCalledWith(LOAD_SUCCESS, viewManager.activateView);
expect(loadFn).toHaveBeenCalledWith('http://server-1.com/subpath');
});
it('should force a permission check for new views', () => {
viewManager.loadView({id: 'server1'}, {id: 'view1', isOpen: true, type: TAB_MESSAGING, server: {url: new URL('http://server-1.com')}}, 'http://server-1.com/subpath');
expect(PermissionsManager.doPermissionRequest).toBeCalledWith(
1,
'notifications',
{
requestingUrl: 'http://server-1.com/',
isMainFrame: false,
},
);
});
});
describe('openClosedView', () => {
const viewManager = new ViewManager();
beforeEach(() => {
viewManager.showById = jest.fn();
MainWindow.get.mockReturnValue({});
MattermostBrowserView.mockImplementation((view) => ({
on: jest.fn(),
load: jest.fn(),
once: jest.fn(),
destroy: jest.fn(),
id: view.id,
view,
}));
});
it('should remove from closedViews when the view is open', () => {
viewManager.closedViews.set('view1', {srv: {id: 'server1'}, view: {id: 'view1'}});
expect(viewManager.closedViews.has('view1')).toBe(true);
viewManager.openClosedView('view1');
expect(viewManager.closedViews.has('view1')).toBe(false);
});
});
describe('reload', () => {

View File

@@ -45,6 +45,7 @@ import type {MattermostView} from 'common/views/View';
import {TAB_MESSAGING} from 'common/views/View';
import {flushCookiesStore} from 'main/app/utils';
import {localizeMessage} from 'main/i18nManager';
import PermissionsManager from 'main/permissionsManager';
import MainWindow from 'main/windows/mainWindow';
import LoadingScreen from './loadingScreen';
@@ -269,8 +270,20 @@ export class ViewManager {
private addView = (view: MattermostBrowserView): void => {
this.views.set(view.id, view);
if (this.closedViews.has(view.id)) {
this.closedViews.delete(view.id);
// Force a permission check for notifications
if (view.view.type === TAB_MESSAGING) {
const notificationPermission = PermissionsManager.getForServer(view.view.server)?.notifications;
if (!notificationPermission || (!notificationPermission.allowed && notificationPermission.alwaysDeny !== true)) {
PermissionsManager.doPermissionRequest(
view.webContentsId,
'notifications',
{
requestingUrl: view.view.server.url.toString(),
isMainFrame: false,
},
);
}
}
};
@@ -435,7 +448,7 @@ export class ViewManager {
// commit views
this.views = new Map();
for (const x of views.values()) {
this.views.set(x.id, x);
this.addView(x);
}
// commit closed
@@ -617,6 +630,9 @@ export class ViewManager {
const {srv, view} = this.closedViews.get(id)!;
view.isOpen = true;
this.loadView(srv, view, url);
if (this.closedViews.has(view.id)) {
this.closedViews.delete(view.id);
}
this.showById(id);
const browserView = this.views.get(id)!;
browserView.isVisible = true;