diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..9dd26e6 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,25 @@ + + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.device.camera + + com.apple.security.device.audio-input + + + diff --git a/build/icon.icns b/build/icon.icns new file mode 100644 index 0000000..eba95ec Binary files /dev/null and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000..1305b7d Binary files /dev/null and b/build/icon.ico differ diff --git a/build/icons/128x128.png b/build/icons/128x128.png new file mode 100644 index 0000000..9c52d66 Binary files /dev/null and b/build/icons/128x128.png differ diff --git a/build/icons/16x16.png b/build/icons/16x16.png new file mode 100644 index 0000000..7435c1b Binary files /dev/null and b/build/icons/16x16.png differ diff --git a/build/icons/24x24.png b/build/icons/24x24.png new file mode 100644 index 0000000..f484995 Binary files /dev/null and b/build/icons/24x24.png differ diff --git a/build/icons/256x256.png b/build/icons/256x256.png new file mode 100644 index 0000000..f52d02c Binary files /dev/null and b/build/icons/256x256.png differ diff --git a/build/icons/48x48.png b/build/icons/48x48.png new file mode 100644 index 0000000..fcbcbc4 Binary files /dev/null and b/build/icons/48x48.png differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png new file mode 100644 index 0000000..801e150 Binary files /dev/null and b/build/icons/512x512.png differ diff --git a/build/icons/64x64.png b/build/icons/64x64.png new file mode 100644 index 0000000..4a86e56 Binary files /dev/null and b/build/icons/64x64.png differ diff --git a/build/icons/96x96.png b/build/icons/96x96.png new file mode 100644 index 0000000..8a99898 Binary files /dev/null and b/build/icons/96x96.png differ diff --git a/build/install-spinner.gif b/build/install-spinner.gif new file mode 100644 index 0000000..4a68529 Binary files /dev/null and b/build/install-spinner.gif differ diff --git a/build/linux/after-install.tpl b/build/linux/after-install.tpl new file mode 100644 index 0000000..6ee772d --- /dev/null +++ b/build/linux/after-install.tpl @@ -0,0 +1,14 @@ +#!/bin/bash + +# Link to the binary +ln -sf '/opt/${productFilename}/${executable}' '/usr/bin/${executable}' + +# SUID chrome-sandbox for Electron 5+ +# Remove this entire file (after-install.tpl) and remove the reference in +# package.json once this change has been upstreamed so we go back to the copy +# from upstream. +# https://github.com/electron-userland/electron-builder/pull/4163 +chmod 4755 '/opt/${productFilename}/chrome-sandbox' || true + +update-mime-database /usr/share/mime || true +update-desktop-database /usr/share/applications || true diff --git a/res/img/riot.ico b/res/img/riot.ico new file mode 100644 index 0000000..1305b7d Binary files /dev/null and b/res/img/riot.ico differ diff --git a/res/img/riot.png b/res/img/riot.png new file mode 100644 index 0000000..f52d02c Binary files /dev/null and b/res/img/riot.png differ diff --git a/riot.im/New_Vector_Ltd.pem b/riot.im/New_Vector_Ltd.pem new file mode 100644 index 0000000..1a34127 --- /dev/null +++ b/riot.im/New_Vector_Ltd.pem @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +MIIF0jCCBLqgAwIBAgIRAISYBqZi3VvCUeSfHXF+cbwwDQYJKoZIhvcNAQELBQAw +gZExCzAJBgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAO +BgNVBAcTB1NhbGZvcmQxGjAYBgNVBAoTEUNPTU9ETyBDQSBMaW1pdGVkMTcwNQYD +VQQDEy5DT01PRE8gUlNBIEV4dGVuZGVkIFZhbGlkYXRpb24gQ29kZSBTaWduaW5n +IENBMB4XDTE3MDgyMzAwMDAwMFoXDTIwMDgyMjIzNTk1OVowgdgxETAPBgNVBAUT +CDEwODczNjYxMRMwEQYLKwYBBAGCNzwCAQMTAkdCMR0wGwYDVQQPExRQcml2YXRl +IE9yZ2FuaXphdGlvbjELMAkGA1UEBhMCR0IxETAPBgNVBBEMCFdDMVIgNEFHMQ8w +DQYDVQQIDAZMb25kb24xDzANBgNVBAcMBkxvbmRvbjEbMBkGA1UECQwSMjYgUmVk +IExpb24gU3F1YXJlMRcwFQYDVQQKDA5OZXcgVmVjdG9yIEx0ZDEXMBUGA1UEAwwO +TmV3IFZlY3RvciBMdGQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7 +X0HP3oM/SVr6PboD03ndtYTONZDcJ/GJ3EyYi6UNrcbKjuDHwPktx9hjAhNjcVkG +lmuTEPluPj9DbvjaTrers0cQsAS1vJ0RHjLfA93Flg1ys9Q6OThUMw77FtFPtiJU +z5cSYzfFAhn/4dv7BcgGptn+Mv/8CaTu+RUZJUgoSlRWcT1TREmxkzWotbblqsHO +zjDmUg20tL5/qpt6BSWsNespf5udKQFXMtqkczBcLvBLmql0vurVcQy8BibB+Q89 +QKwRzwLgaIa7O8WEssFcW8uJe9s0SNtUy8ehbuoSxpA/DbHFwsiDbNA78vp7HrqM +qY6t6OIgLtDYBFCfe/btAgMBAAGjggHaMIIB1jAfBgNVHSMEGDAWgBTfj/MgDOnK +pgTYW1g3Kj2rRtyDSTAdBgNVHQ4EFgQUH+mDOdRkF3bYDxCWEaGB4lxiCxcwDgYD +VR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMw +EQYJYIZIAYb4QgEBBAQDAgQQMEYGA1UdIAQ/MD0wOwYMKwYBBAGyMQECAQYBMCsw +KQYIKwYBBQUHAgEWHWh0dHBzOi8vc2VjdXJlLmNvbW9kby5jb20vQ1BTMFUGA1Ud +HwROMEwwSqBIoEaGRGh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0NPTU9ET1JTQUV4 +dGVuZGVkVmFsaWRhdGlvbkNvZGVTaWduaW5nQ0EuY3JsMIGGBggrBgEFBQcBAQR6 +MHgwUAYIKwYBBQUHMAKGRGh0dHA6Ly9jcnQuY29tb2RvY2EuY29tL0NPTU9ET1JT +QUV4dGVuZGVkVmFsaWRhdGlvbkNvZGVTaWduaW5nQ0EuY3J0MCQGCCsGAQUFBzAB +hhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wJgYDVR0RBB8wHaAbBggrBgEFBQcI +A6APMA0MC0dCLTEwODczNjYxMA0GCSqGSIb3DQEBCwUAA4IBAQBJ2aH4aixh0aiz +4WKlK+LMVLHpQ2POE3FZYNpAW7o1q2YDGEADXdGrygPE9NCGNBXKo0CAemCYNWfX +Ov/jdoiMfeqW3vrZ66oEy8OqbvJSwK1xmomWuYw3wYPWcPVG+YbWYD2CGdQu8jTz +fzAJCpvAuY3Wji3fQjiecAC7JCSB4fBHa0ALJOmiSqKQUUpkXs5kW7O0lPBnHzNF +2tQGltXMSIrq1QfFtcreMyKlwDOxPIh360dv5aHhaeSRDRKxq7uq5ikQF2gjKx4k +ieg2HRbAW6fVPpFr4zRS5umpeZV3i06i11VQQPS/mA/OBEXyaqzx4mr6B7U6ptrp +jMqiUv2w +-----END CERTIFICATE----- diff --git a/riot.im/README b/riot.im/README new file mode 100644 index 0000000..8e463c2 --- /dev/null +++ b/riot.im/README @@ -0,0 +1,6 @@ +This directory contains the config file for the official riot.im distribution +of Riot Desktop. + +You probably do not want to build with this config unless you're building the +official riot.im distribution, or you'll find your builds will replace +themselves with the riot.im build. diff --git a/riot.im/config.json b/riot.im/config.json new file mode 100644 index 0000000..cedb4cc --- /dev/null +++ b/riot.im/config.json @@ -0,0 +1,40 @@ +{ + "update_base_url": "https://packages.riot.im/desktop/update/", + "default_server_name": "matrix.org", + "brand": "Riot", + "integrations_ui_url": "https://scalar.vector.im/", + "integrations_rest_url": "https://scalar.vector.im/api", + "integrations_widgets_urls": [ + "https://scalar.vector.im/_matrix/integrations/v1", + "https://scalar.vector.im/api", + "https://scalar-staging.vector.im/_matrix/integrations/v1", + "https://scalar-staging.vector.im/api", + "https://scalar-staging.riot.im/scalar/api" + ], + "hosting_signup_link": "https://modular.im/?utm_source=riot-web&utm_medium=web", + "bug_report_endpoint_url": "https://riot.im/bugreports/submit", + "welcomeUserId": "@riot-bot:matrix.org", + "roomDirectory": { + "servers": [ + "matrix.org" + ] + }, + "piwik": { + "url": "https://piwik.riot.im/", + "siteId": 1, + "policyUrl": "https://matrix.org/docs/guides/riot_im_cookie_policy" + }, + "phasedRollOut": { + "feature_lazyloading": { + "offset": 1539684000000, + "period": 604800000 + } + }, + "features": { + "feature_lazyloading": "enable" + }, + "enable_presence_by_hs_url": { + "https://matrix.org": false, + "https://matrix-client.matrix.org": false + } +} diff --git a/riot.im/env.sh b/riot.im/env.sh new file mode 100644 index 0000000..0ee8105 --- /dev/null +++ b/riot.im/env.sh @@ -0,0 +1 @@ +export OSSLSIGNCODE_SIGNARGS='-pkcs11module /Library/Frameworks/eToken.framework/Versions/Current/libeToken.dylib -pkcs11engine /usr/local/lib/engines/engine_pkcs11.so -certs electron_app/riot.im/New_Vector_Ltd.pem -key 0a3271cbc1ec0fd8afb37f6bbe0cd65ba08d3b4d -t http://timestamp.comodoca.com -verbose' diff --git a/scripts/electron_afterSign.js b/scripts/electron_afterSign.js new file mode 100644 index 0000000..5952976 --- /dev/null +++ b/scripts/electron_afterSign.js @@ -0,0 +1,24 @@ +const { notarize } = require('electron-notarize'); + +exports.default = async function(context) { + const { electronPlatformName, appOutDir } = context; + + if (electronPlatformName === 'darwin') { + const appName = context.packager.appInfo.productFilename; + // We get the password from keychain. The keychain stores + // user IDs too, but apparently altool can't get the user ID + // from the keychain, so we need to get it from the environment. + const userId = process.env.NOTARIZE_APPLE_ID; + if (userId === undefined) { + throw new Error("User ID not found. Set NOTARIZE_APPLE_ID."); + } + + console.log("Notarising macOS app. This may be some time."); + return await notarize({ + appBundleId: 'im.riot.app', + appPath: `${appOutDir}/${appName}.app`, + appleId: userId, + appleIdPassword: '@keychain:NOTARIZE_CREDS', + }); + } +}; diff --git a/scripts/electron_winSign.js b/scripts/electron_winSign.js new file mode 100644 index 0000000..d55b852 --- /dev/null +++ b/scripts/electron_winSign.js @@ -0,0 +1,69 @@ +const { exec, execFile } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const shellescape = require('shell-escape'); + +exports.default = async function(options) { + const inPath = options.path; + const appOutDir = path.dirname(inPath); + + // get the token passphrase from the keychain + const tokenPassphrase = await new Promise((resolve, reject) => { + execFile( + 'security', + ['find-generic-password', '-s', 'riot_signing_token', '-w'], + {}, + (err, stdout) => { + if (err) { + console.error("Couldn't find signing token in keychain", err); + // electron-builder seems to print '[object Object]' on the + // console whether you reject with an Error or a string... + reject(err); + } else { + resolve(stdout.trim()); + } + }, + ); + }); + + return new Promise((resolve, reject) => { + let cmdLine = 'osslsigncode sign '; + if (process.env.OSSLSIGNCODE_SIGNARGS) { + cmdLine += process.env.OSSLSIGNCODE_SIGNARGS + ' '; + } + const tmpFile = path.join( + appOutDir, + 'tmp_' + Math.random().toString(36).substring(2, 15) + '.exe', + ); + const args = [ + '-h', options.hash, + '-pass', tokenPassphrase, + '-in', inPath, + '-out', tmpFile, + ]; + if (options.isNest) args.push('-nest'); + cmdLine += shellescape(args); + + let signStdout; + const signproc = exec(cmdLine, {}, (error, stdout) => { + signStdout = stdout; + }); + signproc.on('exit', (code) => { + if (code !== 0) { + console.log("Running", cmdLine); + console.log(signStdout); + console.error("osslsigncode failed with code " + code); + reject("osslsigncode failed with code " + code); + return; + } + fs.rename(tmpFile, inPath, (err) => { + if (err) { + console.error("Error renaming file", err); + reject(err); + } else { + resolve(); + } + }); + }); + }); +}; diff --git a/src/electron-main.js b/src/electron-main.js new file mode 100644 index 0000000..eaedf6b --- /dev/null +++ b/src/electron-main.js @@ -0,0 +1,626 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2016 OpenMarket Ltd +Copyright 2018, 2019 New Vector Ltd +Copyright 2017, 2019 Michael Telatynski <7t3chguy@gmail.com> + +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. +*/ + +// Squirrel on windows starts the app with various flags +// as hooks to tell us when we've been installed/uninstalled +// etc. +const checkSquirrelHooks = require('./squirrelhooks'); +if (checkSquirrelHooks()) return; + +const argv = require('minimist')(process.argv, { + alias: {help: "h"}, +}); + +const {app, ipcMain, powerSaveBlocker, BrowserWindow, Menu, autoUpdater, protocol} = require('electron'); +const AutoLaunch = require('auto-launch'); +const path = require('path'); + +const tray = require('./tray'); +const vectorMenu = require('./vectormenu'); +const webContentsHandler = require('./webcontents-handler'); +const updater = require('./updater'); +const { migrateFromOldOrigin } = require('./originMigrator'); + +const windowStateKeeper = require('electron-window-state'); +const Store = require('electron-store'); + +const fs = require('fs'); +const afs = fs.promises; + +let Seshat = null; + +try { + Seshat = require('matrix-seshat'); +} catch (e) { + console.warn("seshat unavailable", e); +} + +if (argv["help"]) { + console.log("Options:"); + console.log(" --profile-dir {path}: Path to where to store the profile."); + console.log(" --profile {name}: Name of alternate profile to use, allows for running multiple accounts."); + console.log(" --devtools: Install and use react-devtools and react-perf."); + console.log(" --no-update: Disable automatic updating."); + console.log(" --hidden: Start the application hidden in the system tray."); + console.log(" --help: Displays this help message."); + console.log("And more such as --proxy, see:" + + "https://github.com/electron/electron/blob/master/docs/api/chrome-command-line-switches.md"); + app.exit(); +} + +// boolean flag set whilst we are doing one-time origin migration +// We only serve the origin migration script while we're actually +// migrating to mitigate any risk of it being used maliciously. +let migratingOrigin = false; + +if (argv['profile-dir']) { + app.setPath('userData', argv['profile-dir']); +} else if (argv['profile']) { + app.setPath('userData', `${app.getPath('userData')}-${argv['profile']}`); +} + +let vectorConfig = {}; +try { + vectorConfig = require('../../webapp/config.json'); +} catch (e) { + // it would be nice to check the error code here and bail if the config + // is unparseable, but we get MODULE_NOT_FOUND in the case of a missing + // file or invalid json, so node is just very unhelpful. + // Continue with the defaults (ie. an empty config) +} + +try { + // Load local config and use it to override values from the one baked with the build + const localConfig = require(path.join(app.getPath('userData'), 'config.json')); + + // If the local config has a homeserver defined, don't use the homeserver from the build + // config. This is to avoid a problem where Riot thinks there are multiple homeservers + // defined, and panics as a result. + const homeserverProps = ['default_is_url', 'default_hs_url', 'default_server_name', 'default_server_config']; + if (Object.keys(localConfig).find(k => homeserverProps.includes(k))) { + // Rip out all the homeserver options from the vector config + vectorConfig = Object.keys(vectorConfig) + .filter(k => !homeserverProps.includes(k)) + .reduce((obj, key) => {obj[key] = vectorConfig[key]; return obj;}, {}); + } + + vectorConfig = Object.assign(vectorConfig, localConfig); +} catch (e) { + // Could not load local config, this is expected in most cases. +} + +const eventStorePath = path.join(app.getPath('userData'), 'EventStore'); +const store = new Store({ name: "electron-config" }); + +let eventIndex = null; + +let mainWindow = null; +global.appQuitting = false; + +// It's important to call `path.join` so we don't end up with the packaged asar in the final path. +const iconFile = `riot.${process.platform === 'win32' ? 'ico' : 'png'}`; +const iconPath = path.join(__dirname, "..", "..", "img", iconFile); +const trayConfig = { + icon_path: iconPath, + brand: vectorConfig.brand || 'Riot', +}; + +// handle uncaught errors otherwise it displays +// stack traces in popup dialogs, which is terrible (which +// it will do any time the auto update poke fails, and there's +// no other way to catch this error). +// Assuming we generally run from the console when developing, +// this is far preferable. +process.on('uncaughtException', function(error) { + console.log('Unhandled exception', error); +}); + +let focusHandlerAttached = false; +ipcMain.on('setBadgeCount', function(ev, count) { + app.setBadgeCount(count); + if (count === 0 && mainWindow) { + mainWindow.flashFrame(false); + } +}); + +ipcMain.on('loudNotification', function() { + if (process.platform === 'win32' && mainWindow && !mainWindow.isFocused() && !focusHandlerAttached) { + mainWindow.flashFrame(true); + mainWindow.once('focus', () => { + mainWindow.flashFrame(false); + focusHandlerAttached = false; + }); + focusHandlerAttached = true; + } +}); + +let powerSaveBlockerId = null; +ipcMain.on('app_onAction', function(ev, payload) { + switch (payload.action) { + case 'call_state': + if (powerSaveBlockerId !== null && powerSaveBlocker.isStarted(powerSaveBlockerId)) { + if (payload.state === 'ended') { + powerSaveBlocker.stop(powerSaveBlockerId); + powerSaveBlockerId = null; + } + } else { + if (powerSaveBlockerId === null && payload.state === 'connected') { + powerSaveBlockerId = powerSaveBlocker.start('prevent-display-sleep'); + } + } + break; + } +}); + +autoUpdater.on('update-downloaded', (ev, releaseNotes, releaseName, releaseDate, updateURL) => { + if (!mainWindow) return; + // forward to renderer + mainWindow.webContents.send('update-downloaded', { + releaseNotes, + releaseName, + releaseDate, + updateURL, + }); +}); + +ipcMain.on('ipcCall', async function(ev, payload) { + if (!mainWindow) return; + + const args = payload.args || []; + let ret; + + switch (payload.name) { + case 'getUpdateFeedUrl': + ret = autoUpdater.getFeedURL(); + break; + case 'getAutoLaunchEnabled': + ret = await launcher.isEnabled(); + break; + case 'setAutoLaunchEnabled': + if (args[0]) { + launcher.enable(); + } else { + launcher.disable(); + } + break; + case 'getMinimizeToTrayEnabled': + ret = tray.hasTray(); + break; + case 'setMinimizeToTrayEnabled': + if (args[0]) { + // Create trayIcon icon + tray.create(trayConfig); + } else { + tray.destroy(); + } + store.set('minimizeToTray', args[0]); + break; + case 'getAutoHideMenuBarEnabled': + ret = global.mainWindow.isMenuBarAutoHide(); + break; + case 'setAutoHideMenuBarEnabled': + store.set('autoHideMenuBar', args[0]); + global.mainWindow.setAutoHideMenuBar(args[0]); + global.mainWindow.setMenuBarVisibility(!args[0]); + break; + case 'getAppVersion': + ret = app.getVersion(); + break; + case 'focusWindow': + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } else if (!mainWindow.isVisible()) { + mainWindow.show(); + } else { + mainWindow.focus(); + } + break; + case 'origin_migrate': + migratingOrigin = true; + await migrateFromOldOrigin(); + migratingOrigin = false; + break; + case 'getConfig': + ret = vectorConfig; + break; + + default: + mainWindow.webContents.send('ipcReply', { + id: payload.id, + error: "Unknown IPC Call: " + payload.name, + }); + return; + } + + mainWindow.webContents.send('ipcReply', { + id: payload.id, + reply: ret, + }); +}); + +ipcMain.on('seshat', async function(ev, payload) { + if (!mainWindow) return; + + const sendError = (id, e) => { + const error = { + message: e.message + } + + mainWindow.webContents.send('seshatReply', { + id:id, + error: error + }); + } + + const args = payload.args || []; + let ret; + + switch (payload.name) { + case 'supportsEventIndexing': + if (Seshat === null) ret = false; + else ret = true; + break; + + case 'initEventIndex': + if (eventIndex === null) { + try { + await afs.mkdir(eventStorePath, {recursive: true}); + eventIndex = new Seshat(eventStorePath, {passphrase: "DEFAULT_PASSPHRASE"}); + } catch (e) { + sendError(payload.id, e); + return; + } + } + break; + + case 'closeEventIndex': + eventIndex = null; + break; + + case 'deleteEventIndex': + const deleteFolderRecursive = async(p) => { + for (let entry of await afs.readdir(p)) { + const curPath = path.join(p, entry); + await afs.unlink(curPath); + } + } + + try { + await deleteFolderRecursive(eventStorePath); + } catch (e) { + } + + break; + + case 'isEventIndexEmpty': + if (eventIndex === null) ret = true; + else ret = await eventIndex.isEmpty(); + break; + + case 'addEventToIndex': + try { + eventIndex.addEvent(args[0], args[1]); + } catch (e) { + sendError(payload.id, e); + return; + } + break; + + case 'commitLiveEvents': + try { + ret = await eventIndex.commit(); + } catch (e) { + sendError(payload.id, e); + return; + } + break; + + case 'searchEventIndex': + try { + ret = await eventIndex.search(args[0]); + } catch (e) { + sendError(payload.id, e); + return; + } + break; + + case 'addHistoricEvents': + if (eventIndex === null) ret = false; + else { + try { + ret = await eventIndex.addHistoricEvents( + args[0], args[1], args[2]); + } catch (e) { + sendError(payload.id, e); + return; + } + } + break; + + case 'removeCrawlerCheckpoint': + if (eventIndex === null) ret = false; + else { + try { + ret = await eventIndex.removeCrawlerCheckpoint(args[0]); + } catch (e) { + sendError(payload.id, e); + return; + } + } + break; + + case 'addCrawlerCheckpoint': + if (eventIndex === null) ret = false; + else { + try { + ret = await eventIndex.addCrawlerCheckpoint(args[0]); + } catch (e) { + sendError(payload.id, e); + return; + } + } + break; + + case 'loadCheckpoints': + if (eventIndex === null) ret = []; + else { + try { + ret = await eventIndex.loadCheckpoints(); + } catch (e) { + ret = []; + } + } + break; + + default: + mainWindow.webContents.send('seshatReply', { + id: payload.id, + error: "Unknown IPC Call: " + payload.name, + }); + return; + } + + mainWindow.webContents.send('seshatReply', { + id: payload.id, + reply: ret, + }); +}); + +app.commandLine.appendSwitch('--enable-usermedia-screen-capturing'); + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + console.log('Other instance detected: exiting'); + app.exit(); +} + +const launcher = new AutoLaunch({ + name: vectorConfig.brand || 'Riot', + isHidden: true, + mac: { + useLaunchAgent: true, + }, +}); + +// Register the scheme the app is served from as 'standard' +// which allows things like relative URLs and IndexedDB to +// work. +// Also mark it as secure (ie. accessing resources from this +// protocol and HTTPS won't trigger mixed content warnings). +protocol.registerSchemesAsPrivileged([{ + scheme: 'vector', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + }, +}]); + +app.on('ready', () => { + if (argv['devtools']) { + try { + const { default: installExt, REACT_DEVELOPER_TOOLS, REACT_PERF } = require('electron-devtools-installer'); + installExt(REACT_DEVELOPER_TOOLS) + .then((name) => console.log(`Added Extension: ${name}`)) + .catch((err) => console.log('An error occurred: ', err)); + installExt(REACT_PERF) + .then((name) => console.log(`Added Extension: ${name}`)) + .catch((err) => console.log('An error occurred: ', err)); + } catch (e) { + console.log(e); + } + } + + protocol.registerFileProtocol('vector', (request, callback) => { + if (request.method !== 'GET') { + callback({error: -322}); // METHOD_NOT_SUPPORTED from chromium/src/net/base/net_error_list.h + return null; + } + + const parsedUrl = new URL(request.url); + if (parsedUrl.protocol !== 'vector:') { + callback({error: -302}); // UNKNOWN_URL_SCHEME + return; + } + if (parsedUrl.host !== 'vector') { + callback({error: -105}); // NAME_NOT_RESOLVED + return; + } + + const target = parsedUrl.pathname.split('/'); + + // path starts with a '/' + if (target[0] !== '') { + callback({error: -6}); // FILE_NOT_FOUND + return; + } + + if (target[target.length - 1] == '') { + target[target.length - 1] = 'index.html'; + } + + let baseDir; + // first part of the path determines where we serve from + if (migratingOrigin && target[1] === 'origin_migrator_dest') { + // the origin migrator destination page + // (only the destination script needs to come from the + // custom protocol: the source part is loaded from a + // file:// as that's the origin we're migrating from). + baseDir = __dirname + "/../../origin_migrator/dest"; + } else if (target[1] === 'webapp') { + baseDir = __dirname + "/../../webapp"; + } else { + callback({error: -6}); // FILE_NOT_FOUND + return; + } + + // Normalise the base dir and the target path separately, then make sure + // the target path isn't trying to back out beyond its root + baseDir = path.normalize(baseDir); + + const relTarget = path.normalize(path.join(...target.slice(2))); + if (relTarget.startsWith('..')) { + callback({error: -6}); // FILE_NOT_FOUND + return; + } + const absTarget = path.join(baseDir, relTarget); + + callback({ + path: absTarget, + }); + }, (error) => { + if (error) console.error('Failed to register protocol'); + }); + + if (argv['no-update']) { + console.log('Auto update disabled via command line flag "--no-update"'); + } else if (vectorConfig['update_base_url']) { + console.log(`Starting auto update with base URL: ${vectorConfig['update_base_url']}`); + updater.start(vectorConfig['update_base_url']); + } else { + console.log('No update_base_url is defined: auto update is disabled'); + } + + // Load the previous window state with fallback to defaults + const mainWindowState = windowStateKeeper({ + defaultWidth: 1024, + defaultHeight: 768, + }); + + const preloadScript = path.normalize(`${__dirname}/preload.js`); + mainWindow = global.mainWindow = new BrowserWindow({ + icon: iconPath, + show: false, + autoHideMenuBar: store.get('autoHideMenuBar', true), + + x: mainWindowState.x, + y: mainWindowState.y, + width: mainWindowState.width, + height: mainWindowState.height, + webPreferences: { + preload: preloadScript, + nodeIntegration: false, + sandbox: true, + enableRemoteModule: false, + // We don't use this: it's useful for the preload script to + // share a context with the main page so we can give select + // objects to the main page. The sandbox option isolates the + // main page from the background script. + contextIsolation: false, + webgl: false, + }, + }); + mainWindow.loadURL('vector://vector/webapp/'); + Menu.setApplicationMenu(vectorMenu); + + // Create trayIcon icon + if (store.get('minimizeToTray', true)) tray.create(trayConfig); + + mainWindow.once('ready-to-show', () => { + mainWindowState.manage(mainWindow); + + if (!argv['hidden']) { + mainWindow.show(); + } else { + // hide here explicitly because window manage above sometimes shows it + mainWindow.hide(); + } + }); + + mainWindow.on('closed', () => { + mainWindow = global.mainWindow = null; + }); + mainWindow.on('close', (e) => { + // If we are not quitting and have a tray icon then minimize to tray + if (!global.appQuitting && (tray.hasTray() || process.platform === 'darwin')) { + // 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; + } + }); + + if (process.platform === 'win32') { + // Handle forward/backward mouse buttons in Windows + mainWindow.on('app-command', (e, cmd) => { + if (cmd === 'browser-backward' && mainWindow.webContents.canGoBack()) { + mainWindow.webContents.goBack(); + } else if (cmd === 'browser-forward' && mainWindow.webContents.canGoForward()) { + mainWindow.webContents.goForward(); + } + }); + } + + webContentsHandler(mainWindow.webContents); +}); + +app.on('window-all-closed', () => { + app.quit(); +}); + +app.on('activate', () => { + mainWindow.show(); +}); + +app.on('before-quit', () => { + global.appQuitting = true; + if (mainWindow) { + mainWindow.webContents.send('before-quit'); + } +}); + +app.on('second-instance', (ev, commandLine, workingDirectory) => { + // If other instance launched with --hidden then skip showing window + if (commandLine.includes('--hidden')) return; + + // Someone tried to run a second instance, we should focus our window. + if (mainWindow) { + if (!mainWindow.isVisible()) mainWindow.show(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } +}); + +// Set the App User Model ID to match what the squirrel +// installer uses for the shortcut icon. +// This makes notifications work on windows 8.1 (and is +// a noop on other platforms). +app.setAppUserModelId('com.squirrel.riot-web.Riot'); diff --git a/src/preload.js b/src/preload.js new file mode 100644 index 0000000..0862ec6 --- /dev/null +++ b/src/preload.js @@ -0,0 +1,20 @@ +/* +Copyright 2018, 2019 New Vector 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 { ipcRenderer } = require('electron'); + +// expose ipcRenderer to the renderer process +window.ipcRenderer = ipcRenderer; diff --git a/src/squirrelhooks.js b/src/squirrelhooks.js new file mode 100644 index 0000000..728c9cf --- /dev/null +++ b/src/squirrelhooks.js @@ -0,0 +1,51 @@ +/* +Copyright 2017 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 path = require('path'); +const spawn = require('child_process').spawn; +const {app} = require('electron'); + +function runUpdateExe(args, done) { + // Invokes Squirrel's Update.exe which will do things for us like create shortcuts + // Note that there's an Update.exe in the app-x.x.x directory and one in the parent + // directory: we need to run the one in the parent directory, because it discovers + // information about the app by inspecting the directory it's run from. + const updateExe = path.resolve(path.dirname(process.execPath), '..', 'Update.exe'); + console.log(`Spawning '${updateExe}' with args '${args}'`); + spawn(updateExe, args, { + detached: true, + }).on('close', done); +} + +function checkSquirrelHooks() { + if (process.platform !== 'win32') return false; + + const cmd = process.argv[1]; + const target = path.basename(process.execPath); + if (cmd === '--squirrel-install' || cmd === '--squirrel-updated') { + runUpdateExe(['--createShortcut=' + target + ''], app.quit); + return true; + } else if (cmd === '--squirrel-uninstall') { + runUpdateExe(['--removeShortcut=' + target + ''], app.quit); + return true; + } else if (cmd === '--squirrel-obsolete') { + app.quit(); + return true; + } + return false; +} + +module.exports = checkSquirrelHooks; diff --git a/src/tray.js b/src/tray.js new file mode 100644 index 0000000..04aaa1f --- /dev/null +++ b/src/tray.js @@ -0,0 +1,106 @@ +/* +Copyright 2017 Karl Glatz +Copyright 2017 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 {app, Tray, Menu, nativeImage} = require('electron'); +const pngToIco = require('png-to-ico'); +const path = require('path'); +const fs = require('fs'); + +let trayIcon = null; + +exports.hasTray = function hasTray() { + return (trayIcon !== null); +}; + +exports.destroy = function() { + if (trayIcon) { + trayIcon.destroy(); + trayIcon = null; + } +}; + +exports.create = function(config) { + // no trays on darwin + if (process.platform === 'darwin' || trayIcon) return; + + const toggleWin = function() { + if (global.mainWindow.isVisible() && !global.mainWindow.isMinimized()) { + global.mainWindow.hide(); + } else { + if (global.mainWindow.isMinimized()) global.mainWindow.restore(); + if (!global.mainWindow.isVisible()) global.mainWindow.show(); + global.mainWindow.focus(); + } + }; + + const contextMenu = Menu.buildFromTemplate([ + { + label: `Show/Hide ${config.brand}`, + click: toggleWin, + }, + { type: 'separator' }, + { + label: 'Quit', + click: function() { + app.quit(); + }, + }, + ]); + + const defaultIcon = nativeImage.createFromPath(config.icon_path); + + trayIcon = new Tray(defaultIcon); + trayIcon.setToolTip(config.brand); + trayIcon.setContextMenu(contextMenu); + trayIcon.on('click', toggleWin); + + let lastFavicon = null; + global.mainWindow.webContents.on('page-favicon-updated', async function(ev, favicons) { + if (!favicons || favicons.length <= 0 || !favicons[0].startsWith('data:')) { + if (lastFavicon !== null) { + global.mainWindow.setIcon(defaultIcon); + trayIcon.setImage(defaultIcon); + lastFavicon = null; + } + return; + } + + // No need to change, shortcut + if (favicons[0] === lastFavicon) return; + lastFavicon = favicons[0]; + + let newFavicon = nativeImage.createFromDataURL(favicons[0]); + + // Windows likes ico's too much. + if (process.platform === 'win32') { + try { + const icoPath = path.join(app.getPath('temp'), 'win32_riot_icon.ico'); + fs.writeFileSync(icoPath, await pngToIco(newFavicon.toPNG())); + newFavicon = nativeImage.createFromPath(icoPath); + } catch (e) { + console.error("Failed to make win32 ico", e); + } + } + + trayIcon.setImage(newFavicon); + global.mainWindow.setIcon(newFavicon); + }); + + global.mainWindow.webContents.on('page-title-updated', function(ev, title) { + trayIcon.setToolTip(title); + }); +}; diff --git a/src/updater.js b/src/updater.js new file mode 100644 index 0000000..49fa4e0 --- /dev/null +++ b/src/updater.js @@ -0,0 +1,84 @@ +const { app, autoUpdater, ipcMain } = require('electron'); + +const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; +const INITIAL_UPDATE_DELAY_MS = 30 * 1000; + +function installUpdate() { + // for some reason, quitAndInstall does not fire the + // before-quit event, so we need to set the flag here. + global.appQuitting = true; + autoUpdater.quitAndInstall(); +} + +function pollForUpdates() { + try { + autoUpdater.checkForUpdates(); + } catch (e) { + console.log('Couldn\'t check for update', e); + } +} + +module.exports = {}; +module.exports.start = function startAutoUpdate(updateBaseUrl) { + if (updateBaseUrl.slice(-1) !== '/') { + updateBaseUrl = updateBaseUrl + '/'; + } + try { + let url; + // 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') { + // include the current version in the URL we hit. Electron doesn't add + // it anywhere (apart from the User-Agent) so it's up to us. We could + // (and previously did) just use the User-Agent, but this doesn't + // rely on NSURLConnection setting the User-Agent to what we expect, + // and also acts as a convenient cache-buster to ensure that when the + // app updates it always gets a fresh value to avoid update-looping. + url = `${updateBaseUrl}macos/?localVersion=${encodeURIComponent(app.getVersion())}`; + + } else if (process.platform === 'win32') { + url = `${updateBaseUrl}win32/${process.arch}/`; + } 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'); + } + + if (url) { + autoUpdater.setFeedURL(url); + // 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. + // We also wait a short time before checking for updates the first time because + // of squirrel on windows and it taking a small amount of time to release a + // lock file. + setTimeout(pollForUpdates, INITIAL_UPDATE_DELAY_MS); + setInterval(pollForUpdates, UPDATE_POLL_INTERVAL_MS); + } + } catch (err) { + // will fail if running in debug mode + console.log('Couldn\'t enable update checking', err); + } +} + +ipcMain.on('install_update', installUpdate); +ipcMain.on('check_updates', pollForUpdates); + +function ipcChannelSendUpdateStatus(status) { + if (global.mainWindow) { + global.mainWindow.webContents.send('check_updates', status); + } +} + +autoUpdater.on('update-available', function() { + ipcChannelSendUpdateStatus(true); +}).on('update-not-available', function() { + ipcChannelSendUpdateStatus(false); +}).on('error', function(error) { + ipcChannelSendUpdateStatus(error.message); +}); diff --git a/src/vectormenu.js b/src/vectormenu.js new file mode 100644 index 0000000..a8f998b --- /dev/null +++ b/src/vectormenu.js @@ -0,0 +1,139 @@ +/* +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 {app, shell, Menu} = 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', accelerator: 'CommandOrControl+=' }, + { role: 'zoomout' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + { role: 'toggledevtools' }, + ], + }, + { + label: '&Window', + role: 'window', + submenu: [ + { role: 'minimize' }, + { role: 'close' }, + ], + }, + { + label: '&Help', + role: 'help', + submenu: [ + { + label: 'Riot Help', + click() { shell.openExternal('https://about.riot.im/help'); }, + }, + ], + }, +]; + +// macOS has specific menu conventions... +if (process.platform === 'darwin') { + // first macOS menu is the name of the app + const name = 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', + }, + ]; +} else { + template.unshift({ + label: '&File', + submenu: [ + // For some reason, 'about' does not seem to work on windows. + /*{ + role: 'about' + },*/ + { role: 'quit' }, + ], + }); +} + +module.exports = Menu.buildFromTemplate(template); + diff --git a/src/webcontents-handler.js b/src/webcontents-handler.js new file mode 100644 index 0000000..2880cf3 --- /dev/null +++ b/src/webcontents-handler.js @@ -0,0 +1,196 @@ +const {clipboard, nativeImage, Menu, MenuItem, shell, dialog} = require('electron'); +const url = require('url'); +const fs = require('fs'); +const request = require('request'); + +const MAILTO_PREFIX = "mailto:"; + +const PERMITTED_URL_SCHEMES = [ + 'http:', + 'https:', + MAILTO_PREFIX, +]; + +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 parsedUrl = url.parse(target); + if (PERMITTED_URL_SCHEMES.indexOf(parsedUrl.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 newTarget = url.format(parsedUrl); + shell.openExternal(newTarget); + } +} + +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) { + let url = params.linkURL || params.srcURL; + + if (url.startsWith('vector://vector/webapp')) { + url = "https://riot.im/app/" + url.substring(23); + } + + const popupMenu = new Menu(); + // No point trying to open blob: URLs in an external browser: it ain't gonna work. + if (!url.startsWith('blob:')) { + popupMenu.append(new MenuItem({ + label: url, + click() { + safeOpenURL(url); + }, + })); + } + + let addSaveAs = false; + if (params.mediaType && params.mediaType === 'image' && !url.startsWith('file://')) { + popupMenu.append(new MenuItem({ + label: 'Copy image', + click() { + if (url.startsWith('data:')) { + clipboard.writeImage(nativeImage.createFromDataURL(url)); + } else { + ev.sender.copyImageAt(params.x, params.y); + } + }, + })); + + // We want the link to be ordered below the copy stuff, but don't want to duplicate + // the `if` statement, so use a flag. + addSaveAs = true; + } + + // No point offering to copy a blob: URL either + if (!url.startsWith('blob:')) { + // Special-case e-mail URLs to strip the `mailto:` like modern browsers do + if (url.startsWith(MAILTO_PREFIX)) { + popupMenu.append(new MenuItem({ + label: 'Copy email address', + click() { + clipboard.writeText(url.substr(MAILTO_PREFIX.length)); + }, + })); + } else { + popupMenu.append(new MenuItem({ + label: 'Copy link address', + click() { + clipboard.writeText(url); + }, + })); + } + } + + if (addSaveAs) { + popupMenu.append(new MenuItem({ + label: 'Save image as...', + click() { + const targetFileName = params.titleText || "image.png"; + const filePath = dialog.showSaveDialog({ + defaultPath: targetFileName, + }); + + if (!filePath) return; // user cancelled dialog + + try { + if (url.startsWith("data:")) { + fs.writeFileSync(filePath, nativeImage.createFromDataURL(url)); + } else { + request.get(url).pipe(fs.createWriteStream(filePath)); + } + } catch (err) { + console.error(err); + dialog.showMessageBox({ + type: "error", + title: "Failed to save image", + message: "The image failed to save", + }); + } + }, + })); + } + + // popup() requires an options object even for no options + popupMenu.popup({}); + ev.preventDefault(); +} + +function _CutCopyPasteSelectContextMenus(params) { + return [{ + role: 'cut', + enabled: params.editFlags.canCut, + }, { + role: 'copy', + enabled: params.editFlags.canCopy, + }, { + role: 'paste', + enabled: params.editFlags.canPaste, + }, { + role: 'pasteandmatchstyle', + enabled: params.editFlags.canPaste, + }, { + role: 'selectall', + enabled: params.editFlags.canSelectAll, + }]; +} + +function onSelectedContextMenu(ev, params) { + const items = _CutCopyPasteSelectContextMenus(params); + const popupMenu = Menu.buildFromTemplate(items); + + // popup() requires an options object even for no options + popupMenu.popup({}); + ev.preventDefault(); +} + +function onEditableContextMenu(ev, params) { + const items = [ + { role: 'undo' }, + { role: 'redo', enabled: params.editFlags.canRedo }, + { type: 'separator' }, + ].concat(_CutCopyPasteSelectContextMenus(params)); + + const popupMenu = Menu.buildFromTemplate(items); + + // popup() requires an options object even for no options + popupMenu.popup({}); + ev.preventDefault(); +} + + +module.exports = (webContents) => { + webContents.on('new-window', onWindowOrNavigate); + // XXX: The below now does absolutely nothing because of + // https://github.com/electron/electron/issues/8841 + // Whilst this isn't a security issue since without + // node integration and with the sandbox, it should be + // no worse than opening the site in Chrome, it obviously + // means the user has to restart Riot to make it usable + // again (often unintuitive because it minimises to the + // system tray). We therefore need to be vigilant about + // putting target="_blank" on links in Riot (although + // we should generally be doing this anyway since links + // navigating you away from Riot in the browser is + // also annoying). + webContents.on('will-navigate', onWindowOrNavigate); + + webContents.on('context-menu', function(ev, params) { + if (params.linkURL || params.srcURL) { + onLinkContextMenu(ev, params); + } else if (params.selectionText) { + onSelectedContextMenu(ev, params); + } else if (params.isEditable) { + onEditableContextMenu(ev, params); + } + }); +};