Merge pull request #609 from yuya-oc/permission-request-handler

Dialog to confirm requested permissions
This commit is contained in:
Yuya Ochiai
2017-11-07 21:29:07 +09:00
committed by GitHub
11 changed files with 487 additions and 68 deletions

View File

@@ -25,7 +25,9 @@ const MainPage = createReactClass({
useSpellChecker: PropTypes.bool.isRequired, useSpellChecker: PropTypes.bool.isRequired,
onSelectSpellCheckerLocale: PropTypes.func.isRequired, onSelectSpellCheckerLocale: PropTypes.func.isRequired,
deeplinkingUrl: PropTypes.string, deeplinkingUrl: PropTypes.string,
showAddServerButton: PropTypes.bool.isRequired showAddServerButton: PropTypes.bool.isRequired,
requestingPermission: TabBar.propTypes.requestingPermission,
onClickPermissionDialog: PropTypes.func
}, },
getInitialState() { getInitialState() {
@@ -260,6 +262,8 @@ const MainPage = createReactClass({
onSelect={this.handleSelect} onSelect={this.handleSelect}
onAddServer={this.addServer} onAddServer={this.addServer}
showAddServerButton={this.props.showAddServerButton} showAddServerButton={this.props.showAddServerButton}
requestingPermission={this.props.requestingPermission}
onClickPermissionDialog={this.props.onClickPermissionDialog}
/> />
</Row> </Row>
); );

View File

@@ -0,0 +1,89 @@
const React = require('react');
const PropTypes = require('prop-types');
const {Button, Glyphicon, Popover} = require('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`;
}
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.isRequired,
permission: PropTypes.oneOf(['media', 'geolocation', 'notifications', 'midiSysex', 'pointerLock', 'fullscreen', 'openExternal']).isRequired,
onClickAllow: PropTypes.func,
onClickBlock: PropTypes.func,
onClickClose: PropTypes.func
};
module.exports = PermissionRequestDialog;

View File

@@ -1,34 +1,68 @@
const React = require('react'); const React = require('react');
const {findDOMNode} = require('react-dom');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const {Glyphicon, Nav, NavItem} = require('react-bootstrap'); const {Glyphicon, Nav, NavItem, Overlay} = require('react-bootstrap');
const PermissionRequestDialog = require('./PermissionRequestDialog.jsx');
function TabBar(props) { class TabBar extends React.Component { // need "this"
const tabs = props.teams.map((team, index) => { render() {
let unreadCount = 0; const tabs = this.props.teams.map((team, index) => {
if (props.unreadCounts[index] > 0) { let unreadCount = 0;
unreadCount = props.unreadCounts[index]; if (this.props.unreadCounts[index] > 0) {
} unreadCount = this.props.unreadCounts[index];
if (props.unreadAtActive[index]) { }
unreadCount += 1; if (this.props.unreadAtActive[index]) {
} unreadCount += 1;
}
let mentionCount = 0; let mentionCount = 0;
if (props.mentionCounts[index] > 0) { if (this.props.mentionCounts[index] > 0) {
mentionCount = props.mentionCounts[index]; mentionCount = this.props.mentionCounts[index];
} }
if (props.mentionAtActiveCounts[index] > 0) { if (this.props.mentionAtActiveCounts[index] > 0) {
mentionCount += props.mentionAtActiveCounts[index]; mentionCount += this.props.mentionAtActiveCounts[index];
} }
let badgeDiv; let badgeDiv;
if (mentionCount !== 0) { if (mentionCount !== 0) {
badgeDiv = ( badgeDiv = (
<div className='TabBar-badge'> <div className='TabBar-badge'>
{mentionCount} {mentionCount}
</div>); </div>);
} }
const id = 'teamTabItem' + index; const id = 'teamTabItem' + index;
if (unreadCount === 0) { const permissionOverlay = this.props.requestingPermission[index] ? (
<Overlay
className='TabBar-permissionOverlay'
placement='bottom'
show={this.props.activeKey === index}
target={() => findDOMNode(this.refs[id])}
>
<PermissionRequestDialog
id={`${id}-permissionDialog`}
origin={this.props.requestingPermission[index].origin}
permission={this.props.requestingPermission[index].permission}
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>
) : null;
if (unreadCount === 0) {
return (
<NavItem
className='teamTabItem'
key={id}
id={id}
eventKey={index}
ref={id}
>
{ team.name }
{ ' ' }
{ badgeDiv }
{permissionOverlay}
</NavItem>);
}
return ( return (
<NavItem <NavItem
className='teamTabItem' className='teamTabItem'
@@ -36,53 +70,42 @@ function TabBar(props) {
id={id} id={id}
eventKey={index} eventKey={index}
> >
{ team.name } <b>{ team.name }</b>
{ ' ' } { ' ' }
{ badgeDiv } { badgeDiv }
</NavItem>); </NavItem>);
});
if (this.props.showAddServerButton === true) {
tabs.push(
<NavItem
className='TabBar-addServerButton'
key='addServerButton'
id='addServerButton'
eventKey='addServerButton'
title='Add new server'
>
<Glyphicon glyph='plus'/>
</NavItem>
);
} }
return ( return (
<NavItem <Nav
className='teamTabItem' className='TabBar'
key={id} id={this.props.id}
id={id} bsStyle='tabs'
eventKey={index} activeKey={this.props.activeKey}
onSelect={(eventKey) => {
if (eventKey === 'addServerButton') {
this.props.onAddServer();
} else {
this.props.onSelect(eventKey);
}
}}
> >
<b>{ team.name }</b> { tabs }
{ ' ' } </Nav>
{ badgeDiv }
</NavItem>);
});
if (props.showAddServerButton === true) {
tabs.push(
<NavItem
className='TabBar-addServerButton'
key='addServerButton'
id='addServerButton'
eventKey='addServerButton'
title='Add new server'
>
<Glyphicon glyph='plus'/>
</NavItem>
); );
} }
return (
<Nav
className='TabBar'
id={props.id}
bsStyle='tabs'
activeKey={props.activeKey}
onSelect={(eventKey) => {
if (eventKey === 'addServerButton') {
props.onAddServer();
} else {
props.onSelect(eventKey);
}
}}
>
{ tabs }
</Nav>
);
} }
TabBar.propTypes = { TabBar.propTypes = {
@@ -94,8 +117,13 @@ TabBar.propTypes = {
unreadAtActive: PropTypes.array, unreadAtActive: PropTypes.array,
mentionCounts: PropTypes.array, mentionCounts: PropTypes.array,
mentionAtActiveCounts: PropTypes.array, mentionAtActiveCounts: PropTypes.array,
showAddServerButton: PropTypes.bool,
requestingPermission: PropTypes.arrayOf(PropTypes.shape({
origin: PropTypes.string,
permission: PropTypes.string
})),
onAddServer: PropTypes.func, onAddServer: PropTypes.func,
showAddServerButton: PropTypes.bool onClickPermissionDialog: PropTypes.func
}; };
module.exports = TabBar; module.exports = TabBar;

View File

@@ -0,0 +1,44 @@
.PermissionRequestDialog-content{
min-width: 250px;
}
.PermissionRequestDialog-content .popover-content {
padding: 14px;
}
.PermissionRequestDialog-content > *:nth-child(1) {
margin-right: 14px;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-description {
text-indent: 0.25em;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-description .glyphicon {
margin-right: 0.5em;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-buttons {
margin-bottom: 0;
text-align: right;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-buttons > * {
margin-right: 7px;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-buttons > *:last-child {
margin-right: 0;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-close {
position:absolute;
top: 0px;
right:0px;
color: gray;
text-decoration-line: none;
}
.PermissionRequestDialog-content .PermissionRequestDialog-content-close:hover {
color: black;
}

View File

@@ -36,4 +36,8 @@
margin-left: 5px; margin-left: 5px;
margin-top: 5px; margin-top: 5px;
border-radius: 50%; border-radius: 50%;
}; }
div[id*="-permissionDialog"] {
max-width: 350px;
}

View File

@@ -3,5 +3,6 @@
@import url("MainPage.css"); @import url("MainPage.css");
@import url("MattermostView.css"); @import url("MattermostView.css");
@import url("NewTeamModal.css"); @import url("NewTeamModal.css");
@import url("PermissionRequestDialog.css");
@import url("TabBar.css"); @import url("TabBar.css");
@import url("TeamListItem.css"); @import url("TeamListItem.css");

View File

@@ -15,6 +15,7 @@ const AppConfig = require('./config/AppConfig.js');
const url = require('url'); const url = require('url');
const badge = require('./js/badge'); const badge = require('./js/badge');
const utils = require('../utils/util');
remote.getCurrentWindow().removeAllListeners('focus'); remote.getCurrentWindow().removeAllListeners('focus');
@@ -86,12 +87,59 @@ function showUnreadBadge(unreadCount, mentionCount) {
} }
} }
const permissionRequestQueue = [];
const requestingPermission = new Array(AppConfig.data.teams.length);
function teamConfigChange(teams) { function teamConfigChange(teams) {
AppConfig.set('teams', teams); AppConfig.set('teams', teams);
requestingPermission.length = teams.length;
ipcRenderer.send('update-menu', AppConfig.data); ipcRenderer.send('update-menu', AppConfig.data);
ipcRenderer.send('update-config'); ipcRenderer.send('update-config');
} }
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();
}
ipcRenderer.on('request-permission', (event, origin, permission) => {
if (permissionRequestQueue.length >= 100) {
return;
}
permissionRequestQueue.push({origin, permission});
feedPermissionRequest();
});
function handleSelectSpellCheckerLocale(locale) { function handleSelectSpellCheckerLocale(locale) {
console.log(locale); console.log(locale);
AppConfig.set('spellCheckerLocale', locale); AppConfig.set('spellCheckerLocale', locale);
@@ -117,6 +165,8 @@ ReactDOM.render(
onSelectSpellCheckerLocale={handleSelectSpellCheckerLocale} onSelectSpellCheckerLocale={handleSelectSpellCheckerLocale}
deeplinkingUrl={deeplinkingUrl} deeplinkingUrl={deeplinkingUrl}
showAddServerButton={AppConfig.data.enableServerManagement} showAddServerButton={AppConfig.data.enableServerManagement}
requestingPermission={requestingPermission}
onClickPermissionDialog={handleClickPermissionDialog}
/>, />,
document.getElementById('content') document.getElementById('content')
); );

View File

@@ -37,6 +37,7 @@ const appMenu = require('./main/menus/app');
const trayMenu = require('./main/menus/tray'); const trayMenu = require('./main/menus/tray');
const downloadURL = require('./main/downloadURL'); const downloadURL = require('./main/downloadURL');
const allowProtocolDialog = require('./main/allowProtocolDialog'); const allowProtocolDialog = require('./main/allowProtocolDialog');
const permissionRequestHandler = require('./main/permissionRequestHandler');
const SpellChecker = require('./main/SpellChecker'); const SpellChecker = require('./main/SpellChecker');
@@ -577,6 +578,9 @@ app.on('ready', () => {
}); });
ipcMain.emit('update-dict'); ipcMain.emit('update-dict');
const permissionFile = path.join(app.getPath('userData'), 'permission.json');
session.defaultSession.setPermissionRequestHandler(permissionRequestHandler(mainWindow, permissionFile));
// Open the DevTools. // Open the DevTools.
// mainWindow.openDevTools(); // mainWindow.openDevTools();
}); });

View File

@@ -0,0 +1,114 @@
const {ipcMain} = require('electron');
const {URL} = require('url');
const fs = require('fs');
const PERMISSION_GRANTED = 'granted';
const PERMISSION_DENIED = 'denied';
class PermissionManager {
constructor(file) {
this.file = file;
if (fs.existsSync(file)) {
this.permissions = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
} 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.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;
}
}
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);
}
}
function permissionRequestHandler(mainWindow, permissionFile) {
const permissionManager = new PermissionManager(permissionFile);
const requestQueue = [];
ipcMain.on('update-permission', (event, origin, permission, status) => {
dequeueRequests(requestQueue, permissionManager, origin, permission, status);
});
return (webContents, permission, callback) => {
const targetURL = new URL(webContents.getURL());
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);
};
}
permissionRequestHandler.PermissionManager = PermissionManager;
module.exports = permissionRequestHandler;

View File

@@ -23,6 +23,7 @@ const mattermostURL = 'http://example.com/team';
module.exports = { module.exports = {
sourceRootDir, sourceRootDir,
configFilePath, configFilePath,
userDataDir,
boundsInfoPath, boundsInfoPath,
mattermostURL, mattermostURL,

View File

@@ -0,0 +1,80 @@
/* eslint-disable no-unused-expressions */
const fs = require('fs');
const path = require('path');
const env = require('../modules/environment');
const {PermissionManager} = require('../../src/main/permissionRequestHandler');
const permissionFile = path.join(env.userDataDir, 'permission.json');
describe('PermissionManager', function() {
beforeEach(function(done) {
fs.unlink(permissionFile, () => {
done();
});
});
it('should grant a permisson for an origin', function() {
const ORIGIN = 'origin';
const PERMISSION = 'permission';
const manager = new PermissionManager(permissionFile);
manager.isGranted(ORIGIN, PERMISSION).should.be.false;
manager.isDenied(ORIGIN, PERMISSION).should.be.false;
manager.grant(ORIGIN, PERMISSION);
manager.isGranted(ORIGIN, PERMISSION).should.be.true;
manager.isDenied(ORIGIN, PERMISSION).should.be.false;
manager.isGranted(ORIGIN + '_another', PERMISSION).should.be.false;
manager.isGranted(ORIGIN, PERMISSION + '_another').should.be.false;
});
it('should deny a permisson for an origin', function() {
const ORIGIN = 'origin';
const PERMISSION = 'permission';
const manager = new PermissionManager(permissionFile);
manager.isGranted(ORIGIN, PERMISSION).should.be.false;
manager.isDenied(ORIGIN, PERMISSION).should.be.false;
manager.deny(ORIGIN, PERMISSION);
manager.isGranted(ORIGIN, PERMISSION).should.be.false;
manager.isDenied(ORIGIN, PERMISSION).should.be.true;
manager.isDenied(ORIGIN + '_another', PERMISSION).should.be.false;
manager.isDenied(ORIGIN, PERMISSION + '_another').should.be.false;
});
it('should save permissons to the file', function() {
const ORIGIN = 'origin';
const PERMISSION = 'permission';
const manager = new PermissionManager(permissionFile);
manager.deny(ORIGIN, PERMISSION);
manager.grant(ORIGIN + '_another', PERMISSION + '_another');
JSON.parse(fs.readFileSync(permissionFile)).should.deep.equal({
origin: {
permission: 'denied'
},
origin_another: {
permission_another: 'granted'
}
});
});
it('should restore permissions from the file', function() {
fs.writeFileSync(permissionFile, JSON.stringify({
origin: {
permission: 'denied'
},
origin_another: {
permission_another: 'granted'
}
}));
const manager = new PermissionManager(permissionFile);
manager.isDenied('origin', 'permission').should.be.true;
manager.isGranted('origin_another', 'permission_another').should.be.true;
});
});