[MM-18152] Desktop notifications (#1040)
* temp * add in html5 notification tests * strip out custom permissions handling * disable middle click * validate as URI instead of URL allow’s custom protocol’s to pass through * add context isolation to new window requests * add new permissions handling * prevent setting user to away from quit/shutdown * dispatch desktop notifications from renderer * remove test code * log desktop notification errors * should deny as a last resort * only trigger callback once
This commit is contained in:
parent
77d823a076
commit
761ef8d0e6
|
@ -3,7 +3,7 @@
|
|||
"provider": "generic",
|
||||
"url": "https://releases.mattermost.com/desktop/"
|
||||
}],
|
||||
"appId": "com.mattermost.desktop",
|
||||
"appId": "Mattermost.Desktop",
|
||||
"artifactName": "${name}-${version}-${os}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
|
|
|
@ -18,7 +18,6 @@ import LoginModal from './LoginModal.jsx';
|
|||
import MattermostView from './MattermostView.jsx';
|
||||
import TabBar from './TabBar.jsx';
|
||||
import HoveringURL from './HoveringURL.jsx';
|
||||
import PermissionRequestDialog from './PermissionRequestDialog.jsx';
|
||||
import Finder from './Finder.jsx';
|
||||
import NewTeamModal from './NewTeamModal.jsx';
|
||||
|
||||
|
@ -333,8 +332,6 @@ export default class MainPage extends React.Component {
|
|||
onSelect={this.handleSelect}
|
||||
onAddServer={this.addServer}
|
||||
showAddServerButton={this.props.showAddServerButton}
|
||||
requestingPermission={this.props.requestingPermission}
|
||||
onClickPermissionDialog={this.props.onClickPermissionDialog}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
|
@ -421,16 +418,6 @@ export default class MainPage extends React.Component {
|
|||
onLogin={this.handleLogin}
|
||||
onCancel={this.handleLoginCancel}
|
||||
/>
|
||||
{this.props.teams.length === 1 && this.props.requestingPermission[0] ? // eslint-disable-line multiline-ternary
|
||||
<PermissionRequestDialog
|
||||
id='MainPage-permissionDialog'
|
||||
placement='bottom'
|
||||
{...this.props.requestingPermission[0]}
|
||||
onClickAllow={this.props.onClickPermissionDialog.bind(null, 0, 'allow')}
|
||||
onClickBlock={this.props.onClickPermissionDialog.bind(null, 0, 'block')}
|
||||
onClickClose={this.props.onClickPermissionDialog.bind(null, 0, 'close')}
|
||||
/> : null
|
||||
}
|
||||
<Grid fluid={true}>
|
||||
{ tabsRow }
|
||||
{ viewsRow }
|
||||
|
@ -474,8 +461,6 @@ MainPage.propTypes = {
|
|||
onSelectSpellCheckerLocale: PropTypes.func.isRequired,
|
||||
deeplinkingUrl: PropTypes.string,
|
||||
showAddServerButton: PropTypes.bool.isRequired,
|
||||
requestingPermission: TabBar.propTypes.requestingPermission,
|
||||
onClickPermissionDialog: PropTypes.func,
|
||||
};
|
||||
|
||||
/* eslint-enable react/no-set-state */
|
||||
|
|
|
@ -10,6 +10,7 @@ import url from 'url';
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {ipcRenderer, remote, shell} from 'electron';
|
||||
import log from 'electron-log';
|
||||
|
||||
import contextMenu from '../js/contextMenu';
|
||||
import Utils from '../../utils/util';
|
||||
|
@ -23,6 +24,8 @@ const preloadJS = `file://${remote.app.getAppPath()}/browser/webview/mattermost_
|
|||
const ERR_NOT_IMPLEMENTED = -11;
|
||||
const U2F_EXTENSION_URL = 'chrome-extension://kmendfapggjehodndflmmgagdbamhnfd/u2f-comms.html';
|
||||
|
||||
const appIconURL = `file:///${remote.app.getAppPath()}/assets/appicon.png`;
|
||||
|
||||
export default class MattermostView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -36,6 +39,7 @@ export default class MattermostView extends React.Component {
|
|||
};
|
||||
|
||||
this.handleUnreadCountChange = this.handleUnreadCountChange.bind(this);
|
||||
this.dispatchNotification = this.dispatchNotification.bind(this);
|
||||
this.reload = this.reload.bind(this);
|
||||
this.clearCacheAndReload = this.clearCacheAndReload.bind(this);
|
||||
this.focusOnWebView = this.focusOnWebView.bind(this);
|
||||
|
@ -56,6 +60,27 @@ export default class MattermostView extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async dispatchNotification(title, body, channel, teamId, silent) {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
log.error('Notifications not granted');
|
||||
return;
|
||||
}
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
tag: body,
|
||||
icon: appIconURL,
|
||||
requireInteraction: false,
|
||||
silent,
|
||||
});
|
||||
notification.onclick = () => {
|
||||
this.webviewRef.current.send('notification-clicked', {channel, teamId});
|
||||
};
|
||||
notification.onerror = () => {
|
||||
log.error('Notification failed to show');
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const self = this;
|
||||
const webview = this.webviewRef.current;
|
||||
|
@ -90,7 +115,7 @@ export default class MattermostView extends React.Component {
|
|||
|
||||
// Open link in browserWindow. for example, attached files.
|
||||
webview.addEventListener('new-window', (e) => {
|
||||
if (!Utils.isValidURL(e.url)) {
|
||||
if (!Utils.isValidURI(e.url)) {
|
||||
return;
|
||||
}
|
||||
const currentURL = url.parse(webview.getURL());
|
||||
|
@ -108,7 +133,7 @@ export default class MattermostView extends React.Component {
|
|||
shell.openExternal(e.url);
|
||||
} else {
|
||||
// New window should disable nodeIntegration.
|
||||
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, show=yes');
|
||||
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, contextIsolation=yes, show=yes');
|
||||
}
|
||||
} else {
|
||||
// if the link is external, use default browser.
|
||||
|
@ -153,13 +178,11 @@ export default class MattermostView extends React.Component {
|
|||
});
|
||||
break;
|
||||
case 'onBadgeChange': {
|
||||
const sessionExpired = event.args[0];
|
||||
const unreadCount = event.args[1];
|
||||
const mentionCount = event.args[2];
|
||||
const isUnread = event.args[3];
|
||||
const isMentioned = event.args[4];
|
||||
self.handleUnreadCountChange(sessionExpired, unreadCount, mentionCount, isUnread, isMentioned);
|
||||
|
||||
self.handleUnreadCountChange(...event.args);
|
||||
break;
|
||||
}
|
||||
case 'dispatchNotification': {
|
||||
self.dispatchNotification(...event.args);
|
||||
break;
|
||||
}
|
||||
case 'onNotificationClick':
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
// Copyright (c) 2015-2016 Yuya Ochiai
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Button, Glyphicon, Popover} from 'react-bootstrap';
|
||||
|
||||
const PERMISSIONS = {
|
||||
media: {
|
||||
description: 'Use your camera and microphone',
|
||||
glyph: 'facetime-video',
|
||||
},
|
||||
geolocation: {
|
||||
description: 'Know your location',
|
||||
glyph: 'map-marker',
|
||||
},
|
||||
notifications: {
|
||||
description: 'Show notifications',
|
||||
glyph: 'bell',
|
||||
},
|
||||
midiSysex: {
|
||||
description: 'Use your MIDI devices',
|
||||
glyph: 'music',
|
||||
},
|
||||
pointerLock: {
|
||||
description: 'Lock your mouse cursor',
|
||||
glyph: 'hand-up',
|
||||
},
|
||||
fullscreen: {
|
||||
description: 'Enter full screen',
|
||||
glyph: 'resize-full',
|
||||
},
|
||||
openExternal: {
|
||||
description: 'Open external',
|
||||
glyph: 'new-window',
|
||||
},
|
||||
};
|
||||
|
||||
function glyph(permission) {
|
||||
const data = PERMISSIONS[permission];
|
||||
if (data) {
|
||||
return data.glyph;
|
||||
}
|
||||
return 'alert';
|
||||
}
|
||||
|
||||
function description(permission) {
|
||||
const data = PERMISSIONS[permission];
|
||||
if (data) {
|
||||
return data.description;
|
||||
}
|
||||
return `Be granted "${permission}" permission`;
|
||||
}
|
||||
|
||||
export default function PermissionRequestDialog(props) {
|
||||
const {origin, permission, onClickAllow, onClickBlock, onClickClose, ...reft} = props;
|
||||
return (
|
||||
<Popover
|
||||
className='PermissionRequestDialog'
|
||||
{...reft}
|
||||
>
|
||||
<div
|
||||
className='PermissionRequestDialog-content'
|
||||
>
|
||||
<p>{`${origin} wants to:`}</p>
|
||||
<p className='PermissionRequestDialog-content-description'>
|
||||
<Glyphicon glyph={glyph(permission)}/>
|
||||
{description(permission)}
|
||||
</p>
|
||||
<p className='PermissionRequestDialog-content-buttons'>
|
||||
<Button onClick={onClickAllow}>{'Allow'}</Button>
|
||||
<Button onClick={onClickBlock}>{'Block'}</Button>
|
||||
</p>
|
||||
<Button
|
||||
className='PermissionRequestDialog-content-close'
|
||||
bsStyle='link'
|
||||
onClick={onClickClose}
|
||||
>{'×'}</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
PermissionRequestDialog.propTypes = {
|
||||
origin: PropTypes.string,
|
||||
permission: PropTypes.oneOf(['media', 'geolocation', 'notifications', 'midiSysex', 'pointerLock', 'fullscreen', 'openExternal']),
|
||||
onClickAllow: PropTypes.func,
|
||||
onClickBlock: PropTypes.func,
|
||||
onClickClose: PropTypes.func,
|
||||
};
|
|
@ -3,9 +3,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Glyphicon, Nav, NavItem, Overlay} from 'react-bootstrap';
|
||||
|
||||
import PermissionRequestDialog from './PermissionRequestDialog.jsx';
|
||||
import {Glyphicon, Nav, NavItem} from 'react-bootstrap';
|
||||
|
||||
export default class TabBar extends React.Component { // need "this"
|
||||
render() {
|
||||
|
@ -41,24 +39,6 @@ export default class TabBar extends React.Component { // need "this"
|
|||
);
|
||||
}
|
||||
const id = 'teamTabItem' + index;
|
||||
const requestingPermission = this.props.requestingPermission[index];
|
||||
const permissionOverlay = (
|
||||
<Overlay
|
||||
className='TabBar-permissionOverlay'
|
||||
placement='bottom'
|
||||
show={requestingPermission && this.props.activeKey === index}
|
||||
target={() => this.refs[id]}
|
||||
>
|
||||
<PermissionRequestDialog
|
||||
id={`${id}-permissionDialog`}
|
||||
origin={requestingPermission ? requestingPermission.origin : null}
|
||||
permission={requestingPermission ? requestingPermission.permission : null}
|
||||
onClickAllow={this.props.onClickPermissionDialog.bind(null, index, 'allow')}
|
||||
onClickBlock={this.props.onClickPermissionDialog.bind(null, index, 'block')}
|
||||
onClickClose={this.props.onClickPermissionDialog.bind(null, index, 'close')}
|
||||
/>
|
||||
</Overlay>
|
||||
);
|
||||
|
||||
// draggable=false is a workaround for https://github.com/mattermost/desktop/issues/667
|
||||
// It would obstruct https://github.com/mattermost/desktop/issues/478
|
||||
|
@ -79,7 +59,6 @@ export default class TabBar extends React.Component { // need "this"
|
|||
</span>
|
||||
{ ' ' }
|
||||
{ badgeDiv }
|
||||
{permissionOverlay}
|
||||
</NavItem>);
|
||||
});
|
||||
if (this.props.showAddServerButton === true) {
|
||||
|
@ -127,10 +106,5 @@ TabBar.propTypes = {
|
|||
mentionCounts: PropTypes.array,
|
||||
mentionAtActiveCounts: PropTypes.array,
|
||||
showAddServerButton: PropTypes.bool,
|
||||
requestingPermission: PropTypes.arrayOf(PropTypes.shape({
|
||||
origin: PropTypes.string,
|
||||
permission: PropTypes.string,
|
||||
})),
|
||||
onAddServer: PropTypes.func,
|
||||
onClickPermissionDialog: PropTypes.func,
|
||||
};
|
||||
|
|
|
@ -14,13 +14,14 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import {remote, ipcRenderer} from 'electron';
|
||||
|
||||
import utils from '../utils/util';
|
||||
|
||||
import Config from '../common/config';
|
||||
|
||||
import EnhancedNotification from './js/notification';
|
||||
import MainPage from './components/MainPage.jsx';
|
||||
import {createDataURL as createBadgeDataURL} from './js/badge';
|
||||
|
||||
Notification = EnhancedNotification; // eslint-disable-line no-global-assign, no-native-reassign
|
||||
|
||||
const config = new Config(remote.app.getPath('userData') + '/config.json', remote.getCurrentWindow().registryConfigData);
|
||||
|
||||
const teams = config.teams;
|
||||
|
@ -31,9 +32,6 @@ if (teams.length === 0) {
|
|||
remote.getCurrentWindow().loadFile('browser/settings.html');
|
||||
}
|
||||
|
||||
const permissionRequestQueue = [];
|
||||
const requestingPermission = new Array(teams.length);
|
||||
|
||||
const parsedURL = url.parse(window.location.href, true);
|
||||
const initialIndex = parsedURL.query.index ? parseInt(parsedURL.query.index, 10) : 0;
|
||||
|
||||
|
@ -44,7 +42,6 @@ if (!parsedURL.query.index || parsedURL.query.index === null) {
|
|||
|
||||
config.on('update', (configData) => {
|
||||
teams.splice(0, teams.length, ...configData.teams);
|
||||
requestingPermission.length = teams.length;
|
||||
});
|
||||
|
||||
config.on('synchronize', () => {
|
||||
|
@ -134,53 +131,10 @@ function teamConfigChange(updatedTeams) {
|
|||
config.set('teams', updatedTeams);
|
||||
}
|
||||
|
||||
function feedPermissionRequest() {
|
||||
const webviews = document.getElementsByTagName('webview');
|
||||
const webviewOrigins = Array.from(webviews).map((w) => utils.getDomain(w.getAttribute('src')));
|
||||
for (let index = 0; index < requestingPermission.length; index++) {
|
||||
if (requestingPermission[index]) {
|
||||
break;
|
||||
}
|
||||
for (const request of permissionRequestQueue) {
|
||||
if (request.origin === webviewOrigins[index]) {
|
||||
requestingPermission[index] = request;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickPermissionDialog(index, status) {
|
||||
const requesting = requestingPermission[index];
|
||||
ipcRenderer.send('update-permission', requesting.origin, requesting.permission, status);
|
||||
if (status === 'allow' || status === 'block') {
|
||||
const newRequests = permissionRequestQueue.filter((request) => {
|
||||
if (request.permission === requesting.permission && request.origin === requesting.origin) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
permissionRequestQueue.splice(0, permissionRequestQueue.length, ...newRequests);
|
||||
} else if (status === 'close') {
|
||||
const i = permissionRequestQueue.findIndex((e) => e.permission === requesting.permission && e.origin === requesting.origin);
|
||||
permissionRequestQueue.splice(i, 1);
|
||||
}
|
||||
requestingPermission[index] = null;
|
||||
feedPermissionRequest();
|
||||
}
|
||||
|
||||
function handleSelectSpellCheckerLocale(locale) {
|
||||
config.set('spellCheckerLocale', locale);
|
||||
}
|
||||
|
||||
ipcRenderer.on('request-permission', (event, origin, permission) => {
|
||||
if (permissionRequestQueue.length >= 100) {
|
||||
return;
|
||||
}
|
||||
permissionRequestQueue.push({origin, permission});
|
||||
feedPermissionRequest();
|
||||
});
|
||||
|
||||
ReactDOM.render(
|
||||
<MainPage
|
||||
teams={teams}
|
||||
|
@ -191,8 +145,6 @@ ReactDOM.render(
|
|||
onSelectSpellCheckerLocale={handleSelectSpellCheckerLocale}
|
||||
deeplinkingUrl={deeplinkingUrl}
|
||||
showAddServerButton={config.enableServerManagement}
|
||||
requestingPermission={requestingPermission}
|
||||
onClickPermissionDialog={handleClickPermissionDialog}
|
||||
/>,
|
||||
document.getElementById('content')
|
||||
);
|
||||
|
|
|
@ -3,16 +3,13 @@
|
|||
// See LICENSE.txt for license information.
|
||||
'use strict';
|
||||
|
||||
import {ipcRenderer, webFrame} from 'electron';
|
||||
/* eslint-disable no-magic-numbers */
|
||||
|
||||
import EnhancedNotification from '../js/notification';
|
||||
import {ipcRenderer, webFrame, remote} from 'electron';
|
||||
|
||||
const UNREAD_COUNT_INTERVAL = 1000;
|
||||
//eslint-disable-next-line no-magic-numbers
|
||||
const CLEAR_CACHE_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
Notification = EnhancedNotification; // eslint-disable-line no-global-assign, no-native-reassign
|
||||
|
||||
Reflect.deleteProperty(global.Buffer); // http://electron.atom.io/docs/tutorial/security/#buffer-global
|
||||
|
||||
function isReactAppInitialized() {
|
||||
|
@ -50,6 +47,46 @@ window.addEventListener('load', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// listen for messages from the webapp
|
||||
window.addEventListener('message', ({origin, data: {type, message = {}} = {}} = {}) => {
|
||||
if (origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
switch (type) {
|
||||
case 'webapp-ready': {
|
||||
// register with the webapp to enable custom integration functionality
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'register-desktop',
|
||||
message: {
|
||||
version: remote.app.getVersion(),
|
||||
},
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'dispatch-notification': {
|
||||
const {title, body, channel, teamId, silent} = message;
|
||||
ipcRenderer.sendToHost('dispatchNotification', title, body, channel, teamId, silent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('notification-clicked', (event, {channel, teamId}) => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'notification-clicked',
|
||||
message: {
|
||||
channel,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
});
|
||||
|
||||
function hasClass(element, className) {
|
||||
const rclass = /[\t\r\n\f]/g;
|
||||
if ((' ' + element.className + ' ').replace(rclass, ' ').indexOf(className) > -1) {
|
||||
|
@ -209,3 +246,5 @@ ipcRenderer.on('user-activity-update', (event, {userIsActive, isSystemEvent}) =>
|
|||
setInterval(() => {
|
||||
webFrame.clearCache();
|
||||
}, CLEAR_CACHE_INTERVAL);
|
||||
|
||||
/* eslint-enable no-magic-numbers */
|
||||
|
|
47
src/main.js
47
src/main.js
|
@ -25,8 +25,6 @@ import appMenu from './main/menus/app';
|
|||
import trayMenu from './main/menus/tray';
|
||||
import downloadURL from './main/downloadURL';
|
||||
import allowProtocolDialog from './main/allowProtocolDialog';
|
||||
import PermissionManager from './main/PermissionManager';
|
||||
import permissionRequestHandler from './main/permissionRequestHandler';
|
||||
import AppStateManager from './main/AppStateManager';
|
||||
import initCookieManager from './main/cookieManager';
|
||||
import {shouldBeHiddenOnStartup} from './main/utils';
|
||||
|
@ -61,7 +59,6 @@ let spellChecker = null;
|
|||
let deeplinkingUrl = null;
|
||||
let scheme = null;
|
||||
let appState = null;
|
||||
let permissionManager = null;
|
||||
let registryConfig = null;
|
||||
let config = null;
|
||||
let trayIcon = null;
|
||||
|
@ -233,12 +230,6 @@ function handleConfigUpdate(configData) {
|
|||
});
|
||||
}
|
||||
|
||||
if (permissionManager) {
|
||||
const trustedURLs = config.teams.map((team) => team.url);
|
||||
permissionManager.setTrustedURLs(trustedURLs);
|
||||
ipcMain.emit('update-dict', true, config.spellCheckerLocale);
|
||||
}
|
||||
|
||||
ipcMain.emit('update-menu', true, configData);
|
||||
}
|
||||
|
||||
|
@ -571,10 +562,40 @@ function initializeAfterAppReady() {
|
|||
|
||||
ipcMain.emit('update-dict');
|
||||
|
||||
const permissionFile = path.join(app.getPath('userData'), 'permission.json');
|
||||
const trustedURLs = config.teams.map((team) => team.url);
|
||||
permissionManager = new PermissionManager(permissionFile, trustedURLs);
|
||||
session.defaultSession.setPermissionRequestHandler(permissionRequestHandler(mainWindow, permissionManager));
|
||||
// supported permission types
|
||||
const supportedPermissionTypes = [
|
||||
'media',
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'fullscreen',
|
||||
'openExternal',
|
||||
];
|
||||
|
||||
// handle permission requests
|
||||
// - approve if a supported permission type and the request comes from the renderer or one of the defined servers
|
||||
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
// is the requested permission type supported?
|
||||
if (!supportedPermissionTypes.includes(permission)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// is the request coming from the renderer?
|
||||
if (webContents.id === mainWindow.webContents.id) {
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// get the requesting webContents url
|
||||
const requestingURL = webContents.getURL();
|
||||
|
||||
// is the target url trusted?
|
||||
const matchingTeamIndex = config.teams.findIndex((team) => {
|
||||
return requestingURL.startsWith(team.url);
|
||||
});
|
||||
|
||||
callback(matchingTeamIndex >= 0);
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
// Copyright (c) 2015-2016 Yuya Ochiai
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
|
||||
import utils from '../utils/util';
|
||||
|
||||
import * as Validator from './Validator';
|
||||
|
||||
const PERMISSION_GRANTED = 'granted';
|
||||
const PERMISSION_DENIED = 'denied';
|
||||
|
||||
export default class PermissionManager {
|
||||
constructor(file, trustedURLs = []) {
|
||||
this.file = file;
|
||||
this.setTrustedURLs(trustedURLs);
|
||||
if (fs.existsSync(file)) {
|
||||
try {
|
||||
this.permissions = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
|
||||
this.permissions = Validator.validatePermissionsList(this.permissions);
|
||||
if (!this.permissions) {
|
||||
throw new Error('Provided permissions file does not validate, using defaults instead.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
this.permissions = {};
|
||||
}
|
||||
} else {
|
||||
this.permissions = {};
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync() {
|
||||
fs.writeFileSync(this.file, JSON.stringify(this.permissions, null, ' '));
|
||||
}
|
||||
|
||||
grant(origin, permission) {
|
||||
if (!this.permissions[origin]) {
|
||||
this.permissions[origin] = {};
|
||||
}
|
||||
this.permissions[origin][permission] = PERMISSION_GRANTED;
|
||||
this.writeFileSync();
|
||||
}
|
||||
|
||||
deny(origin, permission) {
|
||||
if (!this.permissions[origin]) {
|
||||
this.permissions[origin] = {};
|
||||
}
|
||||
this.permissions[origin][permission] = PERMISSION_DENIED;
|
||||
this.writeFileSync();
|
||||
}
|
||||
|
||||
clear(origin, permission) {
|
||||
delete this.permissions[origin][permission];
|
||||
}
|
||||
|
||||
isGranted(origin, permission) {
|
||||
if (this.trustedOrigins[origin] === true) {
|
||||
return true;
|
||||
}
|
||||
if (this.permissions[origin]) {
|
||||
return this.permissions[origin][permission] === PERMISSION_GRANTED;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isDenied(origin, permission) {
|
||||
if (this.permissions[origin]) {
|
||||
return this.permissions[origin][permission] === PERMISSION_DENIED;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setTrustedURLs(trustedURLs) {
|
||||
this.trustedOrigins = {};
|
||||
for (const url of trustedURLs) {
|
||||
const origin = utils.getDomain(url);
|
||||
this.trustedOrigins[origin] = true;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -66,7 +66,6 @@ export default class UserActivityMonitor extends EventEmitter {
|
|||
// NOTE: electron.powerMonitor cannot be referenced until the app is ready
|
||||
electron.powerMonitor.on('suspend', this.handleSystemGoingAway);
|
||||
electron.powerMonitor.on('resume', this.handleSystemComingBack);
|
||||
electron.powerMonitor.on('shutdown', this.handleSystemGoingAway);
|
||||
electron.powerMonitor.on('lock-screen', this.handleSystemGoingAway);
|
||||
electron.powerMonitor.on('unlock-screen', this.handleSystemComingBack);
|
||||
|
||||
|
@ -87,7 +86,6 @@ export default class UserActivityMonitor extends EventEmitter {
|
|||
stopMonitoring() {
|
||||
electron.powerMonitor.off('suspend', this.handleSystemGoingAway);
|
||||
electron.powerMonitor.off('resume', this.handleSystemComingBack);
|
||||
electron.powerMonitor.off('shutdown', this.handleSystemGoingAway);
|
||||
electron.powerMonitor.off('lock-screen', this.handleSystemGoingAway);
|
||||
electron.powerMonitor.off('unlock-screen', this.handleSystemComingBack);
|
||||
|
||||
|
|
|
@ -62,16 +62,6 @@ const configDataSchemaV1 = Joi.object({
|
|||
spellCheckerLocale: Joi.string().regex(/^[a-z]{2}-[A-Z]{2}$/).default('en-US'),
|
||||
});
|
||||
|
||||
// eg. data['https://community.mattermost.com']['notifications'] = 'granted';
|
||||
// eg. data['http://localhost:8065']['notifications'] = 'denied';
|
||||
const permissionsSchema = Joi.object().pattern(
|
||||
Joi.string().uri(),
|
||||
Joi.object().pattern(
|
||||
Joi.string(),
|
||||
Joi.any().valid('granted', 'denied'),
|
||||
),
|
||||
);
|
||||
|
||||
// eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'};
|
||||
const certificateStoreSchema = Joi.object().pattern(
|
||||
Joi.string().uri(),
|
||||
|
@ -124,11 +114,6 @@ export function validateV1ConfigData(data) {
|
|||
return validateAgainstSchema(data, configDataSchemaV1);
|
||||
}
|
||||
|
||||
// validate permission.json
|
||||
export function validatePermissionsList(data) {
|
||||
return validateAgainstSchema(data, permissionsSchema);
|
||||
}
|
||||
|
||||
// validate certificate.json
|
||||
export function validateCertificateStore(data) {
|
||||
return validateAgainstSchema(data, certificateStoreSchema);
|
||||
|
|
|
@ -53,6 +53,7 @@ function createMainWindow(config, options) {
|
|||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webviewTag: true,
|
||||
disableBlinkFeatures: 'Auxclick',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
// Copyright (c) 2015-2016 Yuya Ochiai
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {URL} from 'url';
|
||||
|
||||
import {ipcMain} from 'electron';
|
||||
|
||||
function dequeueRequests(requestQueue, permissionManager, origin, permission, status) {
|
||||
switch (status) {
|
||||
case 'allow':
|
||||
permissionManager.grant(origin, permission);
|
||||
break;
|
||||
case 'block':
|
||||
permissionManager.deny(origin, permission);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (status === 'allow' || status === 'block') {
|
||||
const newQueue = requestQueue.filter((request) => {
|
||||
if (request.origin === origin && request.permission === permission) {
|
||||
request.callback(status === 'allow');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
requestQueue.splice(0, requestQueue.length, ...newQueue);
|
||||
} else {
|
||||
const index = requestQueue.findIndex((request) => {
|
||||
return request.origin === origin && request.permission === permission;
|
||||
});
|
||||
requestQueue[index].callback(false);
|
||||
requestQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export default function permissionRequestHandler(mainWindow, permissionManager) {
|
||||
const requestQueue = [];
|
||||
ipcMain.on('update-permission', (event, origin, permission, status) => {
|
||||
dequeueRequests(requestQueue, permissionManager, origin, permission, status);
|
||||
});
|
||||
return (webContents, permission, callback) => {
|
||||
let targetURL;
|
||||
try {
|
||||
targetURL = new URL(webContents.getURL());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
if (permissionManager.isDenied(targetURL.origin, permission)) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
if (permissionManager.isGranted(targetURL.origin, permission)) {
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
requestQueue.push({
|
||||
origin: targetURL.origin,
|
||||
permission,
|
||||
callback,
|
||||
});
|
||||
mainWindow.webContents.send('request-permission', targetURL.origin, permission);
|
||||
};
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
// Copyright (c) 2015-2016 Yuya Ochiai
|
||||
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import env from '../modules/environment';
|
||||
import PermissionManager from '../../src/main/PermissionManager';
|
||||
|
||||
const permissionFile = path.join(env.userDataDir, 'permission.json');
|
||||
|
||||
const ORIGIN1 = 'https://example.com';
|
||||
const PERMISSION1 = 'notifications';
|
||||
|
||||
const ORIGIN2 = 'https://example2.com';
|
||||
const PERMISSION2 = 'test';
|
||||
|
||||
const DENIED = 'denied';
|
||||
const GRANTED = 'granted';
|
||||
|
||||
describe('PermissionManager', function() {
|
||||
beforeEach(function(done) {
|
||||
fs.unlink(permissionFile, () => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should grant a permission for an origin', function() {
|
||||
const manager = new PermissionManager(permissionFile);
|
||||
|
||||
manager.isGranted(ORIGIN1, PERMISSION1).should.be.false;
|
||||
manager.isDenied(ORIGIN1, PERMISSION1).should.be.false;
|
||||
|
||||
manager.grant(ORIGIN1, PERMISSION1);
|
||||
|
||||
manager.isGranted(ORIGIN1, PERMISSION1).should.be.true;
|
||||
manager.isDenied(ORIGIN1, PERMISSION1).should.be.false;
|
||||
|
||||
manager.isGranted(ORIGIN2, PERMISSION1).should.be.false;
|
||||
manager.isGranted(ORIGIN1, PERMISSION2).should.be.false;
|
||||
});
|
||||
|
||||
it('should deny a permission for an origin', function() {
|
||||
const manager = new PermissionManager(permissionFile);
|
||||
|
||||
manager.isGranted(ORIGIN1, PERMISSION1).should.be.false;
|
||||
manager.isDenied(ORIGIN1, PERMISSION1).should.be.false;
|
||||
|
||||
manager.deny(ORIGIN1, PERMISSION1);
|
||||
|
||||
manager.isGranted(ORIGIN1, PERMISSION1).should.be.false;
|
||||
manager.isDenied(ORIGIN1, PERMISSION1).should.be.true;
|
||||
|
||||
manager.isDenied(ORIGIN2, PERMISSION1).should.be.false;
|
||||
manager.isDenied(ORIGIN1, PERMISSION2).should.be.false;
|
||||
});
|
||||
|
||||
it('should save permissions to the file', function() {
|
||||
const manager = new PermissionManager(permissionFile);
|
||||
manager.deny(ORIGIN1, PERMISSION1);
|
||||
manager.grant(ORIGIN2, PERMISSION2);
|
||||
JSON.parse(fs.readFileSync(permissionFile)).should.deep.equal({
|
||||
[ORIGIN1]: {
|
||||
[PERMISSION1]: DENIED,
|
||||
},
|
||||
[ORIGIN2]: {
|
||||
[PERMISSION2]: GRANTED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should restore permissions from the file', function() {
|
||||
fs.writeFileSync(permissionFile, JSON.stringify({
|
||||
[ORIGIN1]: {
|
||||
[PERMISSION1]: DENIED,
|
||||
},
|
||||
[ORIGIN2]: {
|
||||
[PERMISSION2]: GRANTED,
|
||||
},
|
||||
}));
|
||||
const manager = new PermissionManager(permissionFile);
|
||||
manager.isDenied(ORIGIN1, PERMISSION1).should.be.true;
|
||||
manager.isGranted(ORIGIN2, PERMISSION2).should.be.true;
|
||||
});
|
||||
|
||||
it('should allow permissions for trusted URLs', function() {
|
||||
fs.writeFileSync(permissionFile, JSON.stringify({}));
|
||||
const manager = new PermissionManager(permissionFile, [ORIGIN1, ORIGIN2]);
|
||||
manager.isGranted(ORIGIN1, PERMISSION1).should.be.true;
|
||||
manager.isGranted(ORIGIN2, PERMISSION2).should.be.true;
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue