diff --git a/.gitignore b/.gitignore index 7811cc43..d3ad9558 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ /vector/olm.* .DS_Store npm-debug.log +electron/dist diff --git a/electron/build/icon.icns b/electron/build/icon.icns new file mode 100644 index 00000000..55c03d96 Binary files /dev/null and b/electron/build/icon.icns differ diff --git a/electron/build/icon.ico b/electron/build/icon.ico new file mode 100644 index 00000000..8b681ffb Binary files /dev/null and b/electron/build/icon.ico differ diff --git a/electron/src/electron-main.js b/electron/src/electron-main.js new file mode 100644 index 00000000..13cbe57a --- /dev/null +++ b/electron/src/electron-main.js @@ -0,0 +1,158 @@ +// @flow + +/* +Copyright 2016 Aviral Dasgupta +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const electron = require('electron'); +const url = require('url'); + +const VectorMenu = require('./vectormenu'); + +const PERMITTED_URL_SCHEMES = [ + 'http:', + 'https:', + 'mailto:', +]; + +const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; + +let mainWindow = null; +let appQuitting = false; + +function safeOpenURL(target) { + // openExternal passes the target to open/start/xdg-open, + // so put fairly stringent limits on what can be opened + // (for instance, open /bin/sh does indeed open a terminal + // with a shell, albeit with no arguments) + const parsed_url = url.parse(target); + if (PERMITTED_URL_SCHEMES.indexOf(parsed_url.protocol) > -1) { + // explicitly use the URL re-assembled by the url library, + // so we know the url parser has understood all the parts + // of the input string + const new_target = url.format(parsed_url); + electron.shell.openExternal(new_target); + } +} + +function onWindowOrNavigate(ev, target) { + // always prevent the default: if something goes wrong, + // we don't want to end up opening it in the electron + // app, as we could end up opening any sort of random + // url in a window that has node scripting access. + ev.preventDefault(); + safeOpenURL(target); +} + +function onLinkContextMenu(ev, params) { + const popup_menu = new electron.Menu(); + popup_menu.append(new electron.MenuItem({ + label: params.linkURL, + click() { + safeOpenURL(params.linkURL); + }, + })); + popup_menu.popup(); + ev.preventDefault(); +} + +function installUpdate() { + // for some reason, quitAndInstall does not fire the + // before-quit event, so we need to set the flag here. + appQuitting = true; + electron.autoUpdater.quitAndInstall(); +} + +function pollForUpdates() { + try { + electron.autoUpdater.checkForUpdates(); + } catch (e) { + console.log("Couldn't check for update", e); + } +} + +electron.ipcMain.on('install_update', installUpdate); + +electron.app.on('ready', () => { + try { + // For reasons best known to Squirrel, the way it checks for updates + // is completely different between macOS and windows. On macOS, it + // hits a URL that either gives it a 200 with some json or + // 204 No Content. On windows it takes a base path and looks for + // files under that path. + if (process.platform == 'darwin') { + electron.autoUpdater.setFeedURL("https://riot.im/autoupdate/desktop/"); + } else if (process.platform == 'win32') { + electron.autoUpdater.setFeedURL("https://riot.im/download/desktop/win32/"); + } else { + // Squirrel / electron only supports auto-update on these two platforms. + // I'm not even going to try to guess which feed style they'd use if they + // implemented it on Linux, or if it would be different again. + console.log("Auto update not supported on this platform"); + } + // We check for updates ourselves rather than using 'updater' because we need to + // do it in the main process (and we don't really need to check every 10 minutes: + // every hour should be just fine for a desktop app) + // However, we still let the main window listen for the update events. + pollForUpdates(); + setInterval(pollForUpdates, UPDATE_POLL_INTERVAL_MS); + } catch (err) { + // will fail if running in debug mode + console.log("Couldn't enable update checking", err); + } + + mainWindow = new electron.BrowserWindow({ + icon: `${__dirname}/../../vector/img/logo.png`, + width: 1024, height: 768, + }); + mainWindow.loadURL(`file://${__dirname}/../../vector/index.html`); + electron.Menu.setApplicationMenu(VectorMenu); + + mainWindow.on('closed', () => { + mainWindow = null; + }); + mainWindow.on('close', (e) => { + if (process.platform == 'darwin' && !appQuitting) { + // On Mac, closing the window just hides it + // (this is generally how single-window Mac apps + // behave, eg. Mail.app) + e.preventDefault(); + mainWindow.hide(); + return false; + } + }); + + mainWindow.webContents.on('new-window', onWindowOrNavigate); + mainWindow.webContents.on('will-navigate', onWindowOrNavigate); + + mainWindow.webContents.on('context-menu', function(ev, params) { + if (params.linkURL) { + onLinkContextMenu(ev, params); + } + }); +}); + +electron.app.on('window-all-closed', () => { + electron.app.quit(); +}); + +electron.app.on('activate', () => { + mainWindow.show(); +}); + +electron.app.on('before-quit', () => { + appQuitting = true; +}); diff --git a/electron/src/vectormenu.js b/electron/src/vectormenu.js new file mode 100644 index 00000000..fd08af6c --- /dev/null +++ b/electron/src/vectormenu.js @@ -0,0 +1,188 @@ +/* +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const electron = require('electron'); + +// Menu template from http://electron.atom.io/docs/api/menu/, edited +const template = [ + { + label: 'Edit', + submenu: [ + { + role: 'undo' + }, + { + role: 'redo' + }, + { + type: 'separator' + }, + { + role: 'cut' + }, + { + role: 'copy' + }, + { + role: 'paste' + }, + { + role: 'pasteandmatchstyle' + }, + { + role: 'delete' + }, + { + role: 'selectall' + } + ] + }, + { + label: 'View', + submenu: [ + { + type: 'separator' + }, + { + role: 'resetzoom' + }, + { + role: 'zoomin' + }, + { + role: 'zoomout' + }, + { + type: 'separator' + }, + { + role: 'togglefullscreen' + }, + { + label: 'Toggle Developer Tools', + accelerator: process.platform == 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I', + click: function(item, focusedWindow) { + if (focusedWindow) focusedWindow.toggleDevTools(); + } + } + ] + }, + { + role: 'window', + submenu: [ + { + role: 'minimize' + }, + { + role: 'close' + } + ] + }, + { + role: 'help', + submenu: [ + { + label: 'riot.im', + click () { electron.shell.openExternal('https://riot.im/') } + } + ] + } +]; + +// macOS has specific menu conventions... +if (process.platform === 'darwin') { + // first macOS menu is the name of the app + const name = electron.app.getName() + template.unshift({ + label: name, + submenu: [ + { + role: 'about' + }, + { + type: 'separator' + }, + { + role: 'services', + submenu: [] + }, + { + type: 'separator' + }, + { + role: 'hide' + }, + { + role: 'hideothers' + }, + { + role: 'unhide' + }, + { + type: 'separator' + }, + { + role: 'quit' + } + ] + }) + // Edit menu. + // This has a 'speech' section on macOS + template[1].submenu.push( + { + type: 'separator' + }, + { + label: 'Speech', + submenu: [ + { + role: 'startspeaking' + }, + { + role: 'stopspeaking' + } + ] + } + ) + // Window menu. + // This also has specific functionality on macOS + template[3].submenu = [ + { + label: 'Close', + accelerator: 'CmdOrCtrl+W', + role: 'close' + }, + { + label: 'Minimize', + accelerator: 'CmdOrCtrl+M', + role: 'minimize' + }, + { + label: 'Zoom', + role: 'zoom' + }, + { + type: 'separator' + }, + { + label: 'Bring All to Front', + role: 'front' + } + ] +}; + +module.exports = electron.Menu.buildFromTemplate(template) + diff --git a/package.json b/package.json index 1c8fd007..52efab04 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "name": "vector-web", + "productName": "Riot", + "main": "electron/src/electron-main.js", "version": "0.8.4-rc.2", - "description": "Vector webapp", - "author": "matrix.org", + "description": "A feature-rich client for Matrix.org", + "author": "Vector Creations Ltd.", "repository": { "type": "git", "url": "https://github.com/vector-im/vector-web" @@ -31,6 +33,7 @@ "build:compile": "babel --source-maps -d lib src", "build:bundle": "NODE_ENV=production webpack -p --progress", "build:bundle:dev": "webpack --optimize-occurence-order --progress", + "build:electron": "build -lwm", "build": "node scripts/babelcheck.js && npm run build:emojione && npm run build:css && npm run build:bundle", "build:dev": "npm run build:emojione && npm run build:css && npm run build:bundle:dev", "dist": "scripts/package.sh", @@ -90,6 +93,7 @@ "catw": "^1.0.1", "cpx": "^1.3.2", "css-raw-loader": "^0.1.1", + "electron-builder": "^7.10.2", "emojione": "^2.2.3", "expect": "^1.16.0", "fs-extra": "^0.30.0", @@ -116,5 +120,24 @@ }, "optionalDependencies": { "olm": "https://matrix.org/packages/npm/olm/olm-2.0.0.tgz" + }, + "build": { + "appId": "im.riot.app", + "category": "Network", + "electronVersion": "1.4.2", + "//asar=false": "https://github.com/electron-userland/electron-builder/issues/675", + "asar": false, + "dereference": true, + "//files": "We bundle everything, so we only need to include vector/", + "files": [ + "!**/*", + "electron/src/**", + "vector/**", + "package.json" + ] + }, + "directories": { + "buildResources": "electron/build", + "output": "electron/dist" } } diff --git a/src/vector/platform/ElectronPlatform.js b/src/vector/platform/ElectronPlatform.js new file mode 100644 index 00000000..810fa984 --- /dev/null +++ b/src/vector/platform/ElectronPlatform.js @@ -0,0 +1,99 @@ +// @flow + +/* +Copyright 2016 Aviral Dasgupta +Copyright 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import VectorBasePlatform from './VectorBasePlatform'; +import dis from 'matrix-react-sdk/lib/dispatcher'; + +const electron = require('electron'); +const remote = electron.remote; + +electron.remote.autoUpdater.on('update-downloaded', onUpdateDownloaded); + +function onUpdateDownloaded(ev, releaseNotes, ver, date, updateURL) { + dis.dispatch({ + action: 'new_version', + currentVersion: electron.remote.app.getVersion(), + newVersion: ver, + releaseNotes: releaseNotes, + }); +} + +export default class ElectronPlatform extends VectorBasePlatform { + setNotificationCount(count: number) { + super.setNotificationCount(count); + // this sometimes throws because electron is made of fail: + // https://github.com/electron/electron/issues/7351 + // For now, let's catch the error, but I suspect it may + // continue to fail and we might just have to accept that + // electron's remote RPC is a non-starter for now and use IPC + try { + remote.app.setBadgeCount(count); + } catch (e) { + console.error("Failed to set notification count", e); + } + } + + supportsNotifications() : boolean { + return true; + } + + maySendNotifications() : boolean { + return true; + } + + displayNotification(title: string, msg: string, avatarUrl: string): Notification { + // Notifications in Electron use the HTML5 notification API + const notification = new global.Notification( + title, + { + body: msg, + icon: avatarUrl, + tag: "vector", + silent: true, // we play our own sounds + } + ); + + notification.onclick = function() { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId + }); + global.focus(); + }; + + return notification; + } + + clearNotification(notif: Notification) { + notif.close(); + } + + pollForUpdate() { + // In electron we control the update process ourselves, since + // it needs to run in the main process, so we just run the timer + // loop in the main electron process instead. + } + + installUpdate() { + // IPC to the main process to install the update, since quitAndInstall + // doesn't fire the before-quit event so the main process needs to know + // it should exit. + electron.ipcRenderer.send('install_update'); + } +} diff --git a/src/vector/platform/index.js b/src/vector/platform/index.js index 741f1df0..90714200 100644 --- a/src/vector/platform/index.js +++ b/src/vector/platform/index.js @@ -17,8 +17,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import WebPlatform from './WebPlatform'; +let Platform = null; -let Platform = WebPlatform; +if (window && window.process && window.process && window.process.type === 'renderer') { + // we're running inside electron + Platform = require('./ElectronPlatform'); +} else { + Platform = require('./WebPlatform'); +} export default Platform; diff --git a/webpack.config.js b/webpack.config.js index 7d5ba474..5d669bd2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -66,6 +66,9 @@ module.exports = { }, externals: { "olm": "Olm", + // Don't try to bundle electron: leave it as a commonjs dependency + // (the 'commonjs' here means it will output a 'require') + "electron": "commonjs electron", }, plugins: [ new webpack.DefinePlugin({