From 3f0354f09ebb6f48423f83b2554518f5cce2e18b Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 29 Jan 2018 16:57:49 -0800 Subject: [PATCH 01/19] Harden production against NODE_ENV environment variable (#2010) Fixes #1999 --- app/config.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/config.js b/app/config.js index 237b781b4b..d0eff76d76 100644 --- a/app/config.js +++ b/app/config.js @@ -2,8 +2,14 @@ const path = require('path'); const electronIsDev = require('electron-is-dev'); -const defaultEnvironment = electronIsDev ? 'development' : 'production'; -const environment = process.env.NODE_ENV || defaultEnvironment; +let environment; + +// In production mode, NODE_ENV cannot be customized by the user +if (electronIsDev) { + environment = process.env.NODE_ENV || 'development'; +} else { + environment = 'production'; +} // Set environment vars to configure node-config before requiring it process.env.NODE_ENV = environment; From 72b7e4ec34f06c890c8b6a907ad9c06da304d59e Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 29 Jan 2018 18:14:39 -0800 Subject: [PATCH 02/19] Process expireTimer and block status along with contact/group sync (#1980) * Mark group as left = false if it is active in contact sync * Handle expireTimer + blocked state along with contact/group sync --- _locales/en/messages.json | 10 +++ js/background.js | 93 ++++++++++++++++++-------- js/models/blockedNumbers.js | 23 ++++++- js/models/conversations.js | 51 +++++++++++--- js/views/message_view.js | 14 ++-- protos/IncomingPushMessageSignal.proto | 25 ++++--- 6 files changed, 162 insertions(+), 54 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4f4564b4c3..b13bbab2f5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -748,6 +748,16 @@ } } }, + "timerSetOnSync": { + "message": "Updating timer to $time$.", + "description": "Message displayed when timer is set on initial link of desktop device.", + "placeholders": { + "time": { + "content": "$1", + "example": "10m" + } + } + }, "theyChangedTheTimer": { "message": "$name$ set the timer to $time$.", "description": "Message displayed when someone else changes the message expiration timer in a conversation.", diff --git a/js/background.js b/js/background.js index 238394f345..8fbee9684b 100644 --- a/js/background.js +++ b/js/background.js @@ -8,6 +8,12 @@ console.log(e); }; + window.wrapDeferred = function(deferred) { + return new Promise(function(resolve, reject) { + deferred.then(resolve, reject); + }); + }; + console.log('background page reloaded'); console.log('environment:', window.config.environment); @@ -377,38 +383,58 @@ return ConversationController.getOrCreateAndWait(id, 'private') .then(function(conversation) { - return new Promise(function(resolve, reject) { - var activeAt = conversation.get('active_at'); + var activeAt = conversation.get('active_at'); - // The idea is to make any new contact show up in the left pane. If - // activeAt is null, then this contact has been purposefully hidden. - if (activeAt !== null) { - activeAt = activeAt || Date.now(); - } + // The idea is to make any new contact show up in the left pane. If + // activeAt is null, then this contact has been purposefully hidden. + if (activeAt !== null) { + activeAt = activeAt || Date.now(); + } - if (details.profileKey) { - conversation.set({profileKey: details.profileKey}); + if (details.profileKey) { + conversation.set({profileKey: details.profileKey}); + } + + if (typeof details.blocked !== 'undefined') { + if (details.blocked) { + storage.addBlockedNumber(id); + } else { + storage.removeBlockedNumber(id); } - conversation.save({ - name: details.name, - avatar: details.avatar, - color: details.color, - active_at: activeAt, - }).then(resolve, reject); - }).then(function() { - if (details.verified) { - var verified = details.verified; - var ev = new Event('verified'); - ev.verified = { - state: verified.state, - destination: verified.destination, - identityKey: verified.identityKey.toArrayBuffer(), - }; - ev.viaContactSync = true; - return onVerified(ev); + } + + return wrapDeferred(conversation.save({ + name: details.name, + avatar: details.avatar, + color: details.color, + active_at: activeAt, + })).then(function() { + // this needs to be inline to get access to conversation model + if (typeof details.expireTimer !== 'undefined') { + var source = textsecure.storage.user.getNumber(); + var receivedAt = Date.now(); + return conversation.updateExpirationTimer( + details.expireTimer, + source, + receivedAt, + {fromSync: true} + ); } }); }) + .then(function() { + if (details.verified) { + var verified = details.verified; + var ev = new Event('verified'); + ev.verified = { + state: verified.state, + destination: verified.destination, + identityKey: verified.identityKey.toArrayBuffer(), + }; + ev.viaContactSync = true; + return onVerified(ev); + } + }) .then(ev.confirm) .catch(function(error) { console.log( @@ -437,11 +463,22 @@ if (activeAt !== null) { updates.active_at = activeAt || Date.now(); } + updates.left = false; } else { updates.left = true; } - return new Promise(function(resolve, reject) { - conversation.save(updates).then(resolve, reject); + + return wrapDeferred(conversation.save(updates)).then(function() { + if (typeof details.expireTimer !== 'undefined') { + var source = textsecure.storage.user.getNumber(); + var receivedAt = Date.now(); + return conversation.updateExpirationTimer( + details.expireTimer, + source, + receivedAt, + {fromSync: true} + ); + } }).then(ev.confirm); }); } diff --git a/js/models/blockedNumbers.js b/js/models/blockedNumbers.js index 76aa6a996d..1eaa7af617 100644 --- a/js/models/blockedNumbers.js +++ b/js/models/blockedNumbers.js @@ -3,8 +3,27 @@ */ (function () { 'use strict'; - window.Whisper = window.Whisper || {}; storage.isBlocked = function(number) { - return storage.get('blocked', []).indexOf(number) >= 0; + var numbers = storage.get('blocked', []); + + return _.include(numbers, number); + }; + storage.addBlockedNumber = function(number) { + var numbers = storage.get('blocked', []); + if (_.include(numbers, number)) { + return; + } + + console.log('adding', number, 'to blocked list'); + storage.put('blocked', numbers.concat(number)); + }; + storage.removeBlockedNumber = function(number) { + var numbers = storage.get('blocked', []); + if (!_.include(numbers, number)) { + return; + } + + console.log('removing', number, 'from blocked list'); + storage.put('blocked', _.without(numbers, number)); }; })(); diff --git a/js/models/conversations.js b/js/models/conversations.js index fa5cfdf222..04a256c63f 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -668,11 +668,28 @@ }.bind(this)); }, - updateExpirationTimer: function(expireTimer, source, received_at) { - if (!expireTimer) { expireTimer = null; } + updateExpirationTimer: function(expireTimer, source, received_at, options) { + options = options || {}; + _.defaults(options, {fromSync: false}); + + if (!expireTimer) { + expireTimer = null; + } + if (this.get('expireTimer') === expireTimer + || (!expireTimer && !this.get('expireTimer'))) { + + return; + } + + console.log( + 'Updating expireTimer for conversation', + this.idForLogging(), + 'via', + source + ); source = source || textsecure.storage.user.getNumber(); var timestamp = received_at || Date.now(); - this.save({ expireTimer: expireTimer }); + var message = this.messageCollection.add({ conversationId : this.id, type : received_at ? 'incoming' : 'outgoing', @@ -681,7 +698,8 @@ flags : textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate : { expireTimer : expireTimer, - source : source + source : source, + fromSync : options.fromSync, } }); if (this.isPrivate()) { @@ -690,8 +708,16 @@ if (message.isOutgoing()) { message.set({recipients: this.getRecipients() }); } - message.save(); - if (message.isOutgoing()) { // outgoing update, send it to the number/group + + return Promise.all([ + wrapDeferred(message.save()), + wrapDeferred(this.save({ expireTimer: expireTimer })), + ]).then(function() { + if (message.isIncoming()) { + return message; + } + + // change was made locally, send it to the number/group var sendFunc; if (this.get('type') == 'private') { sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber; @@ -703,9 +729,16 @@ if (this.get('profileSharing')) { profileKey = storage.get('profileKey'); } - message.send(sendFunc(this.get('id'), this.get('expireTimer'), message.get('sent_at'), profileKey)); - } - return message; + var promise = sendFunc(this.get('id'), + this.get('expireTimer'), + message.get('sent_at'), + profileKey + ); + + return message.send(promise).then(function() { + return message; + }); + }.bind(this)); }, isSearchable: function() { diff --git a/js/views/message_view.js b/js/views/message_view.js index 90cfca7bf5..2811ead3a5 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -82,13 +82,19 @@ render_attributes: function() { var seconds = this.model.get('expirationTimerUpdate').expireTimer; var timerMessage; - if (this.conversation.id === textsecure.storage.user.getNumber()) { - timerMessage = i18n('youChangedTheTimer', - Whisper.ExpirationTimerOptions.getName(seconds)); + + var timerUpdate = this.model.get('expirationTimerUpdate'); + var prettySeconds = Whisper.ExpirationTimerOptions.getName(seconds); + + if (timerUpdate && timerUpdate.fromSync) { + timerMessage = i18n('timerSetOnSync', prettySeconds); + } else if (this.conversation.id === textsecure.storage.user.getNumber()) { + timerMessage = i18n('youChangedTheTimer', prettySeconds); } else { timerMessage = i18n('theyChangedTheTimer', [ this.conversation.getTitle(), - Whisper.ExpirationTimerOptions.getName(seconds)]); + prettySeconds, + ]); } return { content: timerMessage }; } diff --git a/protos/IncomingPushMessageSignal.proto b/protos/IncomingPushMessageSignal.proto index 02160e5f38..75858caa57 100644 --- a/protos/IncomingPushMessageSignal.proto +++ b/protos/IncomingPushMessageSignal.proto @@ -194,12 +194,14 @@ message ContactDetails { optional uint32 length = 2; } - optional string number = 1; - optional string name = 2; - optional Avatar avatar = 3; - optional string color = 4; - optional Verified verified = 5; - optional bytes profileKey = 6; + optional string number = 1; + optional string name = 2; + optional Avatar avatar = 3; + optional string color = 4; + optional Verified verified = 5; + optional bytes profileKey = 6; + optional bool blocked = 7; + optional uint32 expireTimer = 8; } message GroupDetails { @@ -208,9 +210,10 @@ message GroupDetails { optional uint32 length = 2; } - optional bytes id = 1; - optional string name = 2; - repeated string members = 3; - optional Avatar avatar = 4; - optional bool active = 5 [default = true]; + optional bytes id = 1; + optional string name = 2; + repeated string members = 3; + optional Avatar avatar = 4; + optional bool active = 5 [default = true]; + optional uint32 expireTimer = 6; } From c64f0fc75e5b57659f7e2c55174c1cae4f3a6a76 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 30 Jan 2018 10:55:49 -0800 Subject: [PATCH 03/19] app.quit() -> app.exit(), immediate app close on second instance (#2017) --- main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.js b/main.js index f154bad466..8996dc419d 100644 --- a/main.js +++ b/main.js @@ -83,7 +83,7 @@ if (!process.mas) { if (shouldQuit) { console.log('quitting; we are the second instance'); - app.quit(); + app.exit(); } } From 7b49180bc0eb2538002b68baf67c8bb75f40abf4 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 30 Jan 2018 14:22:51 -0800 Subject: [PATCH 04/19] Big update to new signal branding (#2018) --- CONTRIBUTING.md | 12 ++++++------ Gruntfile.js | 2 +- README.md | 10 +++++----- _locales/en/messages.json | 4 ++-- app/auto_update.js | 2 +- background.html | 4 ++-- bower.json | 4 ++-- js/signal_protocol_store.js | 2 +- js/views/install_view.js | 2 +- libtextsecure/message_receiver.js | 4 ++-- libtextsecure/websocket-resources.js | 2 +- main.js | 6 +++--- package.json | 4 ++-- test/index.html | 4 ++-- 14 files changed, 31 insertions(+), 31 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3289ae3a09..d866823dfb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ for it or creating a new one yourself. You can use also that issue as a place to your intentions and get feedback from the users most likely to appreciate your changes. You're most likely to have your pull request accepted easily if it addresses bugs already -in the [Next Steps project](https://github.com/WhisperSystems/Signal-Desktop/projects/1), +in the [Next Steps project](https://github.com/signalapp/Signal-Desktop/projects/1), especially if they are near the top of the Backlog column. Those are what we'll be looking at next, so it would be a great help if you helped us out! @@ -24,7 +24,7 @@ ounce of prevention, as they say!](https://www.goodreads.com/quotes/247269-an-ou ## Developer Setup First, you'll need [Node.js](https://nodejs.org/) which matches our current version. -You can check [`.nvmrc` in the `development` branch](https://github.com/WhisperSystems/Signal-Desktop/blob/development/.nvmrc) to see what the current version is. If you have [nvm](https://github.com/creationix/nvm) +You can check [`.nvmrc` in the `development` branch](https://github.com/signalapp/Signal-Desktop/blob/development/.nvmrc) to see what the current version is. If you have [nvm](https://github.com/creationix/nvm) you can just run `nvm use` in the project directory and it will switch to the project's desired Node.js version. [nvm for windows](https://github.com/coreybutler/nvm-windows) is still useful, but it doesn't support `.nvmrc` files. @@ -46,7 +46,7 @@ favorite package manager. Python 2.x and GCC are two key necessary components. Now, run these commands in your preferred terminal in a good directory for development: ``` -git clone https://github.com/WhisperSystems/Signal-Desktop.git +git clone https://github.com/signalapp/Signal-Desktop.git cd Signal-Desktop npm install -g yarn # (only if you don't already have yarn) npm install -g grunt-cli # (only if you don't already have grunt) @@ -215,13 +215,13 @@ and register it with one of your extra phone numbers: First, build Signal for Android or iOS from source, and point its TextSecure service URL to `textsecure-service-staging.whispersystems.org`: -**on Android:** Replace the `SIGNAL_URL` value in [build.gradle](https://github.com/WhisperSystems/Signal-Android/blob/master/build.gradle) +**on Android:** Replace the `SIGNAL_URL` value in [build.gradle](https://github.com/signalapp/Signal-Android/blob/master/build.gradle) **on iOS:** Replace the `textSecureServerURL` value in `TSConstants.h`(located in the SignalServiceKit pod) This task is 1% search and replace, 99% setting up your build environment. Instructions are available for both -the [Android](https://github.com/WhisperSystems/Signal-Android/blob/master/BUILDING.md) -and [iOS](https://github.com/WhisperSystems/Signal-iOS/blob/master/BUILDING.md) projects. +the [Android](https://github.com/signalapp/Signal-Android/blob/master/BUILDING.md) +and [iOS](https://github.com/signalapp/Signal-iOS/blob/master/BUILDING.md) projects. Then you can set up your development build of Signal Desktop as normal. If you've already set up as a standalone install, you can switch by opening the DevTools (View -> Toggle diff --git a/Gruntfile.js b/Gruntfile.js index 2b3181c35d..e286687dcd 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -282,7 +282,7 @@ module.exports = function(grunt) { var https = require('https'); var urlBase = "https://s3-us-west-1.amazonaws.com/signal-desktop-builds"; - var keyBase = 'WhisperSystems/Signal-Desktop'; + var keyBase = 'signalapp/Signal-Desktop'; var sha = gitinfo.local.branch.current.SHA; var files = [{ zip: packageJson.name + '-' + packageJson.version + '.zip', diff --git a/README.md b/README.md index faf16c093e..9a74be0d3c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ -[![Build Status](https://travis-ci.org/WhisperSystems/Signal-Desktop.svg?branch=master)](https://travis-ci.org/WhisperSystems/Signal-Desktop) +[![Build Status](https://travis-ci.org/signalapp/Signal-Desktop.svg?branch=master)](https://travis-ci.org/signalapp/Signal-Desktop) Signal Desktop ========================== Signal Desktop is an Electron application that links with your -[Signal Android](https://github.com/WhisperSystems/Signal-Android) - or [Signal iOS](https://github.com/WhisperSystems/Signal-iOS) app. +[Signal Android](https://github.com/signalapp/Signal-Android) + or [Signal iOS](https://github.com/signalapp/Signal-iOS) app. ## Install production version: https://signal.org/download/ @@ -27,7 +27,7 @@ The discussion groups are another good place for questions: https://whispersyste Please search the existing issues for your bug and create a new one if the issue is not yet tracked! -https://github.com/WhisperSystems/Signal-Desktop/issues +https://github.com/signalapp/Signal-Desktop/issues ## Contributing Translations @@ -38,7 +38,7 @@ https://www.transifex.com/projects/p/signal-desktop ## Contributing Code -Please see [CONTRIBUTING.md](https://github.com/WhisperSystems/Signal-Desktop/blob/master/CONTRIBUTING.md) +Please see [CONTRIBUTING.md](https://github.com/signalapp/Signal-Desktop/blob/master/CONTRIBUTING.md) for setup instructions and contributor guidelines. And don't forget to sign the [CLA](https://signal.org/cla/). diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b13bbab2f5..acd6808d63 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -52,7 +52,7 @@ "description": "Message shown on the migration screen while we export data" }, "exportComplete": { - "message": "Your data has been exported to:

$location$

You'll be able to import this data as you set up the new Signal Desktop.", + "message": "Your data has been exported to:

$location$

You'll be able to import this data as you set up the new Signal Desktop.", "description": "Message shown on the migration screen when we are done exporting data", "placeholders": { "location": { @@ -74,7 +74,7 @@ "description": "Title of the popup window used to select data previously exported" }, "importError": { - "message": "Unfortunately, something went wrong during the import.

First, double-check your target directory. It should start with 'Signal Export.'

Next, try with a new export of your data from the Chrome App.

If that still fails, please submit a debug log so we can help you get migrated!", + "message": "Unfortunately, something went wrong during the import.

First, double-check your target directory. It should start with 'Signal Export.'

Next, try with a new export of your data from the Chrome App.

If that still fails, please submit a debug log so we can help you get migrated!", "description": "Message shown if the import went wrong." }, "tryAgain": { diff --git a/app/auto_update.js b/app/auto_update.js index efcb70e672..9badffb65a 100644 --- a/app/auto_update.js +++ b/app/auto_update.js @@ -42,7 +42,7 @@ function showUpdateDialog(mainWindow, messages) { if (response === RESTART_BUTTON) { // We delay these update calls because they don't seem to work in this // callback - but only if the message box has a parent window. - // Fixes this bug: https://github.com/WhisperSystems/Signal-Desktop/issues/1864 + // Fixes this bug: https://github.com/signalapp/Signal-Desktop/issues/1864 setTimeout(() => { windowState.markShouldQuit(); autoUpdater.quitAndInstall(); diff --git a/background.html b/background.html index d094aadde1..b4af657e37 100644 --- a/background.html +++ b/background.html @@ -536,7 +536,7 @@ @@ -562,7 +562,7 @@

+ href='https://github.com/signalapp/Signal-Desktop/issues/new/'> {{ reportIssue }}

diff --git a/bower.json b/bower.json index ad3eafc4d5..a49dba3849 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { - "name": "textsecure-chrome", + "name": "signal-desktop", "version": "0.0.0", - "homepage": "https://github.com/WhisperSystems/TextSecure-Browser", + "homepage": "https://github.com/signalapp/Signal-Desktop", "license": "GPLV3", "private": true, "dependencies": { diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index f3eb489a5a..245c7c5c08 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -688,7 +688,7 @@ }.bind(this)); }, // This matches the Java method as of - // https://github.com/WhisperSystems/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 + // https://github.com/signalapp/Signal-Android/blob/d0bb68e1378f689e4d10ac6a46014164992ca4e4/src/org/thoughtcrime/securesms/util/IdentityUtil.java#L188 processVerifiedMessage: function(identifier, verifiedStatus, publicKey) { if (identifier === null || identifier === undefined) { throw new Error("Tried to set verified for undefined/null key"); diff --git a/js/views/install_view.js b/js/views/install_view.js index e971ee46be..c5abeea539 100644 --- a/js/views/install_view.js +++ b/js/views/install_view.js @@ -18,7 +18,7 @@ templateName: 'install_flow_template', className: 'main install', render_attributes: function() { - var twitterHref = 'https://twitter.com/whispersystems'; + var twitterHref = 'https://twitter.com/signalapp'; var signalHref = 'https://signal.org/install'; return { installWelcome: i18n('installWelcome'), diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index befba50d2c..35c8c15086 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -815,8 +815,8 @@ MessageReceiver.prototype.extend({ // Before June, all incoming messages were still DataMessage: // - iOS: Michael Kirk says that they were sending Legacy messages until June - // - Desktop: https://github.com/WhisperSystems/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f - // - Android: https://github.com/WhisperSystems/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 + // - Desktop: https://github.com/signalapp/Signal-Desktop/commit/e8548879db405d9bcd78b82a456ad8d655592c0f + // - Android: https://github.com/signalapp/libsignal-service-java/commit/61a75d023fba950ff9b4c75a249d1a3408e12958 // // var d = new Date('2017-06-01T07:00:00.000Z'); // d.getTime(); diff --git a/libtextsecure/websocket-resources.js b/libtextsecure/websocket-resources.js index 30a519fb15..fc3672f573 100644 --- a/libtextsecure/websocket-resources.js +++ b/libtextsecure/websocket-resources.js @@ -22,7 +22,7 @@ * error: function(message, status, request) {...} * }); * - * 1. https://github.com/WhisperSystems/WebSocket-Resources + * 1. https://github.com/signalapp/WebSocket-Resources * */ diff --git a/main.js b/main.js index 8996dc419d..6bb2c400e7 100644 --- a/main.js +++ b/main.js @@ -54,7 +54,7 @@ function showWindow() { // Using focus() instead of show() seems to be important on Windows when our window // has been docked using Aero Snap/Snap Assist. A full .show() call here will cause // the window to reposition: - // https://github.com/WhisperSystems/Signal-Desktop/issues/1429 + // https://github.com/signalapp/Signal-Desktop/issues/1429 if (mainWindow.isVisible()) { mainWindow.focus(); } else { @@ -315,11 +315,11 @@ function showDebugLog() { } function openReleaseNotes() { - shell.openExternal(`https://github.com/WhisperSystems/Signal-Desktop/releases/tag/v${app.getVersion()}`); + shell.openExternal(`https://github.com/signalapp/Signal-Desktop/releases/tag/v${app.getVersion()}`); } function openNewBugForm() { - shell.openExternal('https://github.com/WhisperSystems/Signal-Desktop/issues/new'); + shell.openExternal('https://github.com/signalapp/Signal-Desktop/issues/new'); } function openSupportPage() { diff --git a/package.json b/package.json index d2294b80b3..e1c0cd00e2 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,12 @@ "name": "signal-desktop", "productName": "Signal", "description": "Private messaging from your desktop", - "repository": "https://github.com/WhisperSystems/Signal-Desktop.git", + "repository": "https://github.com/signalapp/Signal-Desktop.git", "version": "1.3.0-beta.1", "license": "GPL-3.0", "author": { "name": "Open Whisper Systems", - "email": "support@whispersystems.org" + "email": "support@signal.org" }, "main": "main.js", "scripts": { diff --git a/test/index.html b/test/index.html index 050d8b5d55..44b45f0ab1 100644 --- a/test/index.html +++ b/test/index.html @@ -450,7 +450,7 @@ @@ -476,7 +476,7 @@

+ href='https://github.com/signalapp/Signal-Desktop/issues/new/'> {{ reportIssue }}

From 742fe73d3480d157aa49c28c89e5703ef55d241b Mon Sep 17 00:00:00 2001 From: Steven Leiva Date: Thu, 1 Feb 2018 13:35:15 -0600 Subject: [PATCH 05/19] 'Restart' -> 'Restart Signal' button in update dialog (#2024) --- _locales/en/messages.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index acd6808d63..b6ed1662b2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -918,10 +918,10 @@ "message": "There is a new version of Signal available." }, "autoUpdateNewVersionInstructions": { - "message": "Press Restart to apply the updates." + "message": "Press Restart Signal to apply the updates." }, "autoUpdateRestartButtonLabel": { - "message": "Restart" + "message": "Restart Signal" }, "autoUpdateLaterButtonLabel": { "message": "Later" From 7ceeb8bac480bca2b1e63372b80b60f2e50878e4 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 2 Feb 2018 10:04:38 -0800 Subject: [PATCH 06/19] Update electron to 1.7.12 (#2029) --- package.json | 2 +- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e1c0cd00e2..cbda03b4a8 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "asar": "^0.14.0", "bower": "^1.8.2", "chai": "^4.1.2", - "electron": "1.7.11", + "electron": "1.7.12", "electron-builder": "^19.53.7", "electron-icon-maker": "0.0.3", "electron-publisher-s3": "^19.53.7", diff --git a/yarn.lock b/yarn.lock index b618af367f..9a17142f83 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1516,9 +1516,9 @@ electron-updater@^2.19.0: semver "^5.4.1" source-map-support "^0.5.0" -electron@1.7.11: - version "1.7.11" - resolved "https://registry.yarnpkg.com/electron/-/electron-1.7.11.tgz#993b6aa79e0e79a7cfcc369f4c813fbd9a0b08d9" +electron@1.7.12: + version "1.7.12" + resolved "https://registry.yarnpkg.com/electron/-/electron-1.7.12.tgz#dcc61a2c1b0c3df25f68b3425379a01abd01190e" dependencies: "@types/node" "^7.0.18" electron-download "^3.0.1" From 4033a9f8137e62ed286170ed5d4941982b1d3a64 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 2 Feb 2018 10:43:48 -0800 Subject: [PATCH 07/19] Fix typo in issue template (#2020) --- .github/ISSUE_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index f7228df2e4..a471eff112 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -59,7 +59,7 @@ Operating System: Linked device version: - + ### Link to debug log From 2cfdaca3c1e2dd232d2c18bb4fd231f181aec7ad Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 14 Feb 2018 12:15:26 -0800 Subject: [PATCH 08/19] Make our binary comparisons constant time (#2047) --- js/models/conversations.js | 6 +++--- js/signal_protocol_store.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 04a256c63f..45320b8613 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -32,13 +32,13 @@ if (ab1.byteLength !== ab2.byteLength) { return false; } - var result = true; + var result = 0; var ta1 = new Uint8Array(ab1); var ta2 = new Uint8Array(ab2); for (var i = 0; i < ab1.byteLength; ++i) { - if (ta1[i] !== ta2[i]) { result = false; } + result = result | ta1[i] ^ ta2[i]; } - return result; + return result === 0; } Whisper.Conversation = Backbone.Model.extend({ diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index 245c7c5c08..456fefa9b3 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -78,13 +78,13 @@ if (ab1.byteLength !== ab2.byteLength) { return false; } - var result = true; + var result = 0; var ta1 = new Uint8Array(ab1); var ta2 = new Uint8Array(ab2); for (var i = 0; i < ab1.byteLength; ++i) { - if (ta1[i] !== ta2[i]) { result = false; } + result = result | ta1[i] ^ ta2[i]; } - return result; + return result === 0; } var Model = Backbone.Model.extend({ database: Whisper.Database }); From 391ec29f8d7fca5b824cc0fb858d458a8f9ba3ba Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 14 Feb 2018 12:24:17 -0800 Subject: [PATCH 09/19] Update to libsignal-protocol-javascript v1.3.0 (#2050) --- libtextsecure/libsignal-protocol.js | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/libtextsecure/libsignal-protocol.js b/libtextsecure/libsignal-protocol.js index 0b825072a1..2cf63c33ce 100644 --- a/libtextsecure/libsignal-protocol.js +++ b/libtextsecure/libsignal-protocol.js @@ -35291,8 +35291,6 @@ var Internal = Internal || {}; result = result | (a[i] ^ b[i]); } if (result !== 0) { - console.log('Our MAC ', dcodeIO.ByteBuffer.wrap(calculated_mac).toHex()); - console.log('Their MAC', dcodeIO.ByteBuffer.wrap(mac).toHex()); throw new Error("Bad MAC"); } }); @@ -36016,14 +36014,7 @@ libsignal.SessionBuilder = function (storage, remoteAddress) { this.processV3 = builder.processV3.bind(builder); }; -function SessionCipher(storage, remoteAddress, options) { - options = options || {}; - - if (typeof options.messageKeysLimit === 'undefined') { - options.messageKeysLimit = 1000; - } - - this.messageKeysLimit = options.messageKeysLimit; +function SessionCipher(storage, remoteAddress) { this.remoteAddress = remoteAddress; this.storage = storage; } @@ -36296,15 +36287,14 @@ SessionCipher.prototype = { }); }, fillMessageKeys: function(chain, counter) { - if (this.messageKeysLimit && Object.keys(chain.messageKeys).length >= this.messageKeysLimit) { - console.log("Too many message keys for chain"); - return Promise.resolve(); // Stalker, much? - } - if (chain.chainKey.counter >= counter) { return Promise.resolve(); // Already calculated } + if (counter - chain.chainKey.counter > 2000) { + throw new Error('Over 2000 messages into the future!'); + } + if (chain.chainKey.key === undefined) { throw new Error("Got invalid request to extend chain after it was already closed"); } @@ -36433,8 +36423,8 @@ SessionCipher.prototype = { } }; -libsignal.SessionCipher = function(storage, remoteAddress, options) { - var cipher = new SessionCipher(storage, remoteAddress, options); +libsignal.SessionCipher = function(storage, remoteAddress) { + var cipher = new SessionCipher(storage, remoteAddress); // returns a Promise that resolves to a ciphertext object this.encrypt = cipher.encrypt.bind(cipher); From 3a159d5cb8b1cdc5d4ff57e961ee0d284c1ded5b Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 14 Feb 2018 12:35:19 -0800 Subject: [PATCH 10/19] v1.4.0-beta.1 Update electron to 1.7.12 (#2029) Fixed: incoming messages sometimes didn't appear at all (#2050) Fixed: 'Cannot find module ./app/locale' error popup when attempting to start another instance of the app on Windows (#2017) Fixed: Setting NODE_ENV environment variable to 'development' would point it at staging servers (#2010) 'Restart' -> 'Restart Signal' button in update dialog, thanks @StevenXL (#2024) Set conversation disappearing messages state and contact block state on initial link (#1980) Dev: - Update to libsignal-protocol-javascript v1.3.0 (#2050) - Make our binary comparisons constant time (#2047) - Fix typo in issue template (#2020) - Update to new signal branding (#2018) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cbda03b4a8..a6f4de219e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "Signal", "description": "Private messaging from your desktop", "repository": "https://github.com/signalapp/Signal-Desktop.git", - "version": "1.3.0-beta.1", + "version": "1.4.0-beta.1", "license": "GPL-3.0", "author": { "name": "Open Whisper Systems", From b6ef67c402c4ad9c5afc59877526463e0b11dd6f Mon Sep 17 00:00:00 2001 From: Daniel Gasienica Date: Thu, 15 Feb 2018 14:59:46 -0500 Subject: [PATCH 11/19] Sync Protocol Buffers with `libsignal-service-java` (#2046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronizes our protocol buffers with `libsignal-service-java` project. **Changes** - [x] **BREAKING:** Rename `package` from `textsecure` to `signalservice`. ⚠️~~Workaround: Rename back to `textsecure`.~~ Changed all protobuf `package` names across our project. - [x] Rename `java_` metadata. - [x] Move `NullMessage`, `ReceiptMessage`, and `Verified`. - [x] Rename `Contacts.isComplete` to `Contacts.complete`. Confirmed to be unreferenced in our project. - [x] Rename `Settings` to `Configuration` (`textsecure.protobuf.Settings` seems to be unused) - [x] Rename `libtextsecure` `MessageReceiver` `settings` event to `configuration`. - [x] Rename `ReceiptMessage.timestamps` to `ReceiptMessage.timestamp`. - [x] Add `AttachmentPointer` `width` and `height`. - [x] Renamed `IncomingPushMessageSignal.proto` to `SignalService.proto` to match server. --- commit 2b6aa19bf9ea5d8f2f4fd9e4102699a9d06a2b45 Author: Daniel Gasienica Date: Wed Feb 14 19:41:24 2018 -0500 Rename protobuf `package`: `textsecure` --> `signalservice` Brings us closer to `libsignal-service-java`. commit 91612880a5bf2c9ae8a9334877ac24e91102b905 Author: Daniel Gasienica Date: Wed Feb 14 19:19:35 2018 -0500 Rename `SyncMessage.Settings` to `SyncMessage.Configuration` commit 848eb9559928c54dffd3426bba8e7cd7b1687cdc Author: Daniel Gasienica Date: Tue Feb 13 20:16:43 2018 -0500 Rename `ReadReceipt` `timestamps` --> `timestamp` commit 39859e64b41ddf41127b52d963fe7cc2b9fcad68 Author: Daniel Gasienica Date: Wed Feb 14 18:43:42 2018 -0500 Rename `IncomingPushMessageSignal.proto` to `SignalService.proto` This matches the `libsignal-service-java` filename. commit fd4bfd76af57ffa44178caf21d350cca211dc048 Author: Daniel Gasienica Date: Tue Feb 13 16:24:36 2018 -0500 Revert protobuf `package` to `textsecure` This was a breaking change and would need to be introduced with additional changes. commit 9f618fa91717a0349f1ea28bf6d365ef0a5c9ca5 Author: Daniel Gasienica Date: Tue Feb 13 16:09:55 2018 -0500 Sync service protocol buffers with Java project Snapshot: https://github.com/signalapp/libsignal-service-java/blob/4684a49b2ed8f32be619e0d0eea423626b6cb2cb/protobuf/SignalService.proto --- js/background.js | 10 +- libtextsecure/message_receiver.js | 20 ++-- libtextsecure/protobufs.js | 4 +- libtextsecure/sendmessage.js | 2 +- protos/DeviceMessages.proto | 2 +- ...essageSignal.proto => SignalService.proto} | 96 ++++++++++--------- protos/SubProtocol.proto | 4 +- protos/WhisperTextProtocol.proto | 2 +- 8 files changed, 70 insertions(+), 70 deletions(-) rename protos/{IncomingPushMessageSignal.proto => SignalService.proto} (78%) diff --git a/js/background.js b/js/background.js index 8fbee9684b..e39ff252bf 100644 --- a/js/background.js +++ b/js/background.js @@ -272,7 +272,7 @@ messageReceiver.addEventListener('error', onError); messageReceiver.addEventListener('empty', onEmpty); messageReceiver.addEventListener('progress', onProgress); - messageReceiver.addEventListener('settings', onSettings); + messageReceiver.addEventListener('configuration', onConfiguration); window.textsecure.messaging = new textsecure.MessageSender( SERVER_URL, USERNAME, PASSWORD, CDN_URL @@ -351,12 +351,8 @@ view.onProgress(count); } } - function onSettings(ev) { - if (ev.settings.readReceipts) { - storage.put('read-receipt-setting', true); - } else { - storage.put('read-receipt-setting', false); - } + function onConfiguration(ev) { + storage.put('read-receipt-setting', ev.configuration.readReceipts); } function onContactReceived(ev) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 35c8c15086..582084ffd1 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -558,23 +558,23 @@ MessageReceiver.prototype.extend({ handleReceiptMessage: function(envelope, receiptMessage) { var results = []; if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.DELIVERY) { - for (var i = 0; i < receiptMessage.timestamps.length; ++i) { + for (var i = 0; i < receiptMessage.timestamp.length; ++i) { var ev = new Event('delivery'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.deliveryReceipt = { - timestamp : receiptMessage.timestamps[i].toNumber(), + timestamp : receiptMessage.timestamp[i].toNumber(), source : envelope.source, sourceDevice : envelope.sourceDevice }; results.push(this.dispatchAndWait(ev)); } } else if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.READ) { - for (var i = 0; i < receiptMessage.timestamps.length; ++i) { + for (var i = 0; i < receiptMessage.timestamp.length; ++i) { var ev = new Event('read'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.timestamp = envelope.timestamp.toNumber(); ev.read = { - timestamp : receiptMessage.timestamps[i].toNumber(), + timestamp : receiptMessage.timestamp[i].toNumber(), reader : envelope.source } results.push(this.dispatchAndWait(ev)); @@ -626,17 +626,17 @@ MessageReceiver.prototype.extend({ return this.handleRead(envelope, syncMessage.read); } else if (syncMessage.verified) { return this.handleVerified(envelope, syncMessage.verified); - } else if (syncMessage.settings) { - return this.handleSettings(envelope, syncMessage.settings); + } else if (syncMessage.configuration) { + return this.handleConfiguration(envelope, syncMessage.configuration); } else { throw new Error('Got empty SyncMessage'); } }, - handleSettings: function(envelope, settings) { - var ev = new Event('settings'); + handleConfiguration: function(envelope, configuration) { + var ev = new Event('configuration'); ev.confirm = this.removeFromCache.bind(this, envelope); - ev.settings = { - readReceipts: settings.readReceipts + ev.configuration = { + readReceipts: configuration.readReceipts }; return this.dispatchAndWait(ev); }, diff --git a/libtextsecure/protobufs.js b/libtextsecure/protobufs.js index 9f578ec29c..9f7088815c 100644 --- a/libtextsecure/protobufs.js +++ b/libtextsecure/protobufs.js @@ -11,7 +11,7 @@ console.log(text); throw error; } - var protos = result.build('textsecure'); + var protos = result.build('signalservice'); if (!protos) { var text = 'Error loading protos from ' + filename + ' (root: ' + window.PROTO_ROOT + ')'; console.log(text); @@ -23,7 +23,7 @@ }); }; - loadProtoBufs('IncomingPushMessageSignal.proto'); + loadProtoBufs('SignalService.proto'); loadProtoBufs('SubProtocol.proto'); loadProtoBufs('DeviceMessages.proto'); })(); diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index c3a7762ac3..34072020f1 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -388,7 +388,7 @@ MessageSender.prototype = { sendReadReceipts: function(sender, timestamps) { var receiptMessage = new textsecure.protobuf.ReceiptMessage(); receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ; - receiptMessage.timestamps = timestamps; + receiptMessage.timestamp = timestamps; var contentMessage = new textsecure.protobuf.Content(); contentMessage.receiptMessage = receiptMessage; diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto index d63739ca5d..3e96e5f582 100644 --- a/protos/DeviceMessages.proto +++ b/protos/DeviceMessages.proto @@ -1,4 +1,4 @@ -package textsecure; +package signalservice; message ProvisioningUuid { optional string uuid = 1; diff --git a/protos/IncomingPushMessageSignal.proto b/protos/SignalService.proto similarity index 78% rename from protos/IncomingPushMessageSignal.proto rename to protos/SignalService.proto index 75858caa57..1110c36058 100644 --- a/protos/IncomingPushMessageSignal.proto +++ b/protos/SignalService.proto @@ -1,7 +1,8 @@ -package textsecure; +// Source: https://github.com/signalapp/libsignal-service-java/blob/4684a49b2ed8f32be619e0d0eea423626b6cb2cb/protobuf/SignalService.proto +package signalservice; -option java_package = "org.whispersystems.textsecure.internal.push"; -option java_outer_classname = "TextSecureProtos"; +option java_package = "org.whispersystems.signalservice.internal.push"; +option java_outer_classname = "SignalServiceProtos"; message Envelope { enum Type { @@ -22,40 +23,13 @@ message Envelope { } message Content { - optional DataMessage dataMessage = 1; - optional SyncMessage syncMessage = 2; - optional CallMessage callMessage = 3; - optional NullMessage nullMessage = 4; + optional DataMessage dataMessage = 1; + optional SyncMessage syncMessage = 2; + optional CallMessage callMessage = 3; + optional NullMessage nullMessage = 4; optional ReceiptMessage receiptMessage = 5; } -message ReceiptMessage { - enum Type { - DELIVERY = 0; - READ = 1; - } - - optional Type type = 1; - repeated uint64 timestamps = 2; -} - -message NullMessage { - optional bytes padding = 1; -} - -message Verified { - enum State { - DEFAULT = 0; - VERIFIED = 1; - UNVERIFIED = 2; - } - - optional string destination = 1; - optional bytes identityKey = 2; - optional State state = 3; - optional bytes nullMessage = 4; -} - message CallMessage { message Offer { optional uint64 id = 1; @@ -92,7 +66,7 @@ message CallMessage { message DataMessage { enum Flags { - END_SESSION = 1; + END_SESSION = 1; EXPIRATION_TIMER_UPDATE = 2; PROFILE_KEY_UPDATE = 4; } @@ -103,6 +77,34 @@ message DataMessage { optional uint32 flags = 4; optional uint32 expireTimer = 5; optional bytes profileKey = 6; + optional uint64 timestamp = 7; +} + +message NullMessage { + optional bytes padding = 1; +} + +message ReceiptMessage { + enum Type { + DELIVERY = 0; + READ = 1; + } + + optional Type type = 1; + repeated uint64 timestamp = 2; +} + +message Verified { + enum State { + DEFAULT = 0; + VERIFIED = 1; + UNVERIFIED = 2; + } + + optional string destination = 1; + optional bytes identityKey = 2; + optional State state = 3; + optional bytes nullMessage = 4; } message SyncMessage { @@ -115,7 +117,7 @@ message SyncMessage { message Contacts { optional AttachmentPointer blob = 1; - optional bool isComplete = 2 [default = false]; + optional bool complete = 2 [default = false]; } message Groups { @@ -143,19 +145,19 @@ message SyncMessage { optional uint64 timestamp = 2; } - message Settings { + message Configuration { optional bool readReceipts = 1; } - optional Sent sent = 1; - optional Contacts contacts = 2; - optional Groups groups = 3; - optional Request request = 4; - repeated Read read = 5; - optional Blocked blocked = 6; - optional Verified verified = 7; - optional bytes padding = 8; - optional Settings settings = 9; + optional Sent sent = 1; + optional Contacts contacts = 2; + optional Groups groups = 3; + optional Request request = 4; + repeated Read read = 5; + optional Blocked blocked = 6; + optional Verified verified = 7; + optional Configuration configuration = 9; + optional bytes padding = 8; } message AttachmentPointer { @@ -171,6 +173,8 @@ message AttachmentPointer { optional bytes digest = 6; optional string fileName = 7; optional uint32 flags = 8; + optional uint32 width = 9; + optional uint32 height = 10; } message GroupContext { diff --git a/protos/SubProtocol.proto b/protos/SubProtocol.proto index 0fc5ce75d9..52dbe37b7f 100644 --- a/protos/SubProtocol.proto +++ b/protos/SubProtocol.proto @@ -14,7 +14,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -package textsecure; +package signalservice; option java_package = "org.whispersystems.websocket.messages.protobuf"; @@ -42,4 +42,4 @@ message WebSocketMessage { optional Type type = 1; optional WebSocketRequestMessage request = 2; optional WebSocketResponseMessage response = 3; -} \ No newline at end of file +} diff --git a/protos/WhisperTextProtocol.proto b/protos/WhisperTextProtocol.proto index eaa9a75c4e..852d5640b5 100644 --- a/protos/WhisperTextProtocol.proto +++ b/protos/WhisperTextProtocol.proto @@ -1,4 +1,4 @@ -package textsecure; +package signalservice; option java_package = "org.whispersystems.libsignal.protocol"; option java_outer_classname = "WhisperProtos"; From be5cbc9d2b22f66d89c311958d9c61abbf847cbc Mon Sep 17 00:00:00 2001 From: Daniel Gasienica Date: Thu, 15 Feb 2018 15:12:20 -0500 Subject: [PATCH 12/19] Move ESLint environment configuration into `.eslintrc` (#2051) --- js/.eslintrc | 6 ++++++ js/modules/.eslintrc | 10 ++++++++++ 2 files changed, 16 insertions(+) create mode 100644 js/.eslintrc create mode 100644 js/modules/.eslintrc diff --git a/js/.eslintrc b/js/.eslintrc new file mode 100644 index 0000000000..f12ba53923 --- /dev/null +++ b/js/.eslintrc @@ -0,0 +1,6 @@ +{ + "env": { + "browser": true, + "node": false + }, +} diff --git a/js/modules/.eslintrc b/js/modules/.eslintrc new file mode 100644 index 0000000000..99263e496f --- /dev/null +++ b/js/modules/.eslintrc @@ -0,0 +1,10 @@ +{ + "env": { + "browser": false, + "commonjs": true, + "node": false + }, + "globals": { + "console": true + } +} From a0da73ca8d60d59b4687e60e44f85bca4ef58b07 Mon Sep 17 00:00:00 2001 From: Daniel Gasienica Date: Wed, 21 Feb 2018 10:26:59 -0500 Subject: [PATCH 13/19] Auto-orient image attachments based on EXIF metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As described in #998, images are sometimes displayed with an incorrect orientation. This is because cameras often write files in the native sensor byte order and attach the `Orientation` EXIF metadata to tell end-user devices how to display the images based on the original author’s capture orientation. Electron/Chromium (and therefore Signal Desktop) currently doesn’t support applying this metadata for `` tags, e.g. CSS `image-orientation: from- image`. As a workaround, this change uses the `loadImage` library with the `orientation: true` flag to auto-orient images ~~before display~~ upon receipt and before sending. **Changes** - [x] ~~Auto-orient images during display in message list view~~ - [x] Ensure image is not displayed until loaded (to prevent layout reflow) . - [x] Auto-orient images upon receipt and before storing in IndexedDB (~~or preserve original data until Chromium offers native fix?~~) - [x] Auto-orient images in compose area preview. - [x] ~~Auto-orient images in lightbox view~~ - [x] Auto-orient images before sending / storage. - [x] Add EditorConfig for sharing code styles across editors. - [x] Fix ESLint ignore file. - [x] Change `function-paren-newline` ESLint rule from `consistent` to `multiline`. - [x] Add `operator-linebreak` ESLint rule for consistency. - [x] Added `blob-util` dependency for converting between array buffers, blobs, etc. - [x] Extracted `createMessageHandler` to consolidate logic for `onMessageReceived` and `onSentMessage`. - [x] Introduce `async` / `await` to simplify async coding (restore control flow for branching, loops, and exceptions). - [x] Introduce `window.Signal` namespace for exposing ES2015+ CommonJS modules. - [x] Introduce rudimentary `Message` and `Attachment` types to begin defining a schema and versioning. This will allow us to track which changes, e.g. auto-orient JPEGs, per message / attachment as well as which fields are stored. - [x] Renamed `window.dataURLtoBlob` to `window.dataURLToBlobSync` to both fix the strange `camelCase` as well as to highlight that this operation is synchronous and therefore blocks the user thread. - [x] Normalize all JPEG MIME types to `image/jpeg`, eliminating the invalid `image/jpg`. - [x] Add `npm run test-modules` command for testing non-browser specific CommonJS modules. - **Stretch Goals** - [ ] ~~Restrict `autoOrientImage` to `Blob` to narrow API interface.~~ Do this once we use PureScript. - [ ] ~~Return non-JPEGs as no-op from `autoOrientImage`.~~ Skipping `autoOrientImage` for non-JPEGs altogether. - [ ] Retroactively auto-orient existing JPEG image attachments in the background. --- Fixes #998 --- - **Blog:** EXIF Orientation Handling Is a Ghetto: https://www.daveperrett.com/articles/2012/07/28/exif-orientation-handling-is-a-ghetto/ - **Chromium Bug:** EXIF orientation is ignored: https://bugs.chromium.org/p/chromium/issues/detail?id=56845 - **Chromium Bug:** Support for the CSS image-orientation CSS property: https://bugs.chromium.org/p/chromium/issues/detail?id=158753 --- commit ce5090b473a2448229dc38e4c3f15d7ad0137714 Author: Daniel Gasienica Date: Fri Feb 16 10:35:36 2018 -0500 Inline message descriptors commit 329036e59c138c1e950ec7c654eebd7d87076de5 Author: Daniel Gasienica Date: Thu Feb 15 17:34:40 2018 -0500 Clarify order of operations Semantically, it makes more sense to do `getFile` before `clearForm` even though it seems to work either way. commit f9d4cfb2ba0d8aa308b0923bbe6066ea34cb97bd Author: Daniel Gasienica Date: Thu Feb 15 17:18:26 2018 -0500 Simplify `operator-linebreak` configuration Enabling `before` caused more code changes and it turns out our previous configuration is already the default. commit db588997acdd90ed2ad829174ecbba744383c78b Author: Daniel Gasienica Date: Thu Feb 15 17:15:59 2018 -0500 Remove obsolete TODO commit 799c8817633f6afa0b731fc3b5434e463bd850e3 Author: Daniel Gasienica Date: Thu Feb 15 17:12:18 2018 -0500 Enable ESLint `function-paren-newline` `multiline` Per discussion. commit b660b6bc8ef41df7601a411213d6cda80821df87 Author: Daniel Gasienica Date: Thu Feb 15 17:10:48 2018 -0500 Use `messageDescriptor.id` not `source` commit 5e7309d176f4a7e97d3dc4c738e6b0ccd4792871 Author: Daniel Gasienica Date: Wed Feb 14 16:29:01 2018 -0500 Remove unnecessary `eslint-env` commit 393b3da55eabd7413596c86cc3971b063a0efe31 Author: Daniel Gasienica Date: Wed Feb 14 16:19:17 2018 -0500 Refactor `onSentMessage` and `onMessageReceived` Since they are so similar, we create the handlers using new `createMessageHandler` function. This allows us to ensure both synced and received messages go through schema upgrade pipeline. commit b3db0bf179c9a5bea96480cde28c6fa7193ac117 Author: Daniel Gasienica Date: Wed Feb 14 16:18:21 2018 -0500 Add `Message` descriptor functions commit 8febf125b1b42fe4ae1888dd50fcee2749dc1ff0 Author: Daniel Gasienica Date: Wed Feb 14 14:46:56 2018 -0500 Fix typo commit 98d951ef77bd578b313a4ff4b496b793e82e88d5 Author: Daniel Gasienica Date: Wed Feb 14 12:22:39 2018 -0500 Remove `promises` reference commit a0e9559ed5bed947dabf28cb672e63d39948d854 Author: Daniel Gasienica Date: Wed Feb 14 12:22:13 2018 -0500 Fix `AttachmentView::mediaType` fall-through commit 67be916a83951b8a1f9b22efe78a6da6b1825f38 Author: Daniel Gasienica Date: Wed Feb 14 12:03:41 2018 -0500 Remove minor TODOs commit 0af186e118256b62905de38487ffacc41693ff47 Author: Daniel Gasienica Date: Wed Feb 14 11:44:41 2018 -0500 Enable ESLint for `js/views/attachment_view.js` commit 28a2dc5b8a28e1a087924fdc7275bf7d9a577b92 Author: Daniel Gasienica Date: Wed Feb 14 11:44:12 2018 -0500 Remove dynamic type checks commit f4ce36fcfc2737de32d911cd6103f889097813f6 Author: Daniel Gasienica Date: Wed Feb 14 11:27:56 2018 -0500 Rename `process` to `upgradeSchema` - `Message.process` -> `Message.upgradeSchema` - `Attachment.process` -> `Attachment.upgradeSchema` - `Attachment::processVersion` -> `Attachment::schemaVersion` Document version history. commit 41b92c0a31050ba05ddb1c43171d651f3568b9ac Author: Daniel Gasienica Date: Wed Feb 14 11:11:50 2018 -0500 Add `operator-linebreak` ESLint rule Based on the following discussion: https://github.com/signalapp/Signal-Desktop/pull/2040#discussion_r168029106 commit 462defbe55879060fe25bc69103d4429bae2b2f6 Author: Daniel Gasienica Date: Wed Feb 14 11:01:30 2018 -0500 Add missing `await` for `ConversationController.getOrCreateAndWait` Tested this by setting `if` condition to `true` and confirming it works. It turns rotating a profile key is more involved and might require registering a new account according to Matthew. commit c08058ee4b883b3e23a40683de802ac81ed74874 Author: Daniel Gasienica Date: Tue Feb 13 16:32:24 2018 -0500 Convert `FileList` to `Array` commit 70a6c4201925f57be1f94d9da3547fdefc7bbb53 Author: Daniel Gasienica Date: Tue Feb 13 15:46:34 2018 -0500 :art: Fix lint errors commit 2ca7cdbc31d4120d6c6a838a6dcf43bc209d9788 Author: Daniel Gasienica Date: Tue Feb 13 15:07:09 2018 -0500 Skip `autoOrientImage` for non-JPEG images commit 58eac383013c16ca363a4ed33dca5c7ba61284e5 Author: Daniel Gasienica Date: Tue Feb 13 14:55:35 2018 -0500 Move new-style modules to `window.Signal` namespace commit 02c9328877dce289d6116a18b1c223891bd3cd0b Author: Daniel Gasienica Date: Tue Feb 13 14:35:23 2018 -0500 Extract `npm run test-modules` command commit 2c708eb94fba468b81ea9427734896114f5a7807 Author: Daniel Gasienica Date: Tue Feb 13 13:25:51 2018 -0500 Extract `Message.process` commit 4a2e52f68a77536a0fa04aa3c29ad3e541a8fa7e Author: Daniel Gasienica Date: Tue Feb 13 13:25:12 2018 -0500 Fix EditorConfig commit a346bab5db082720f5d47363f06301380e870425 Author: Daniel Gasienica Date: Tue Feb 13 13:13:02 2018 -0500 Remove `vim` directives on ESLint-ed files commit 7ec885c6359e495b407d5bc3eac9431d47c37fc6 Author: Daniel Gasienica Date: Tue Feb 13 13:08:24 2018 -0500 Remove CSP whitelisting of `blob:` We no longer use `autoOrientImage` using blob URLs. Bring this back if we decide to auto-orient legacy attachments. commit 879b6f58f4a3f4a9ed6915af6b1be46c1e90e0ca Author: Daniel Gasienica Date: Tue Feb 13 12:57:05 2018 -0500 Use `Message` type to determine send function Throws on invalid message type. commit 5203d945c98fd2562ae4e22c5c9838d27dec305b Author: Daniel Gasienica Date: Tue Feb 13 12:56:48 2018 -0500 Whitelist `Whisper` global commit 8ad0b066a3690d3382b86bf6ac00c03df7d1e20b Author: Daniel Gasienica Date: Tue Feb 13 12:56:32 2018 -0500 Add `Whisper.Types` namespace This avoids namespace collision for `Whisper.Message`. commit 785a949fce2656ca7dcaf0869d6b9e0648114e80 Author: Daniel Gasienica Date: Tue Feb 13 12:55:43 2018 -0500 Add `Message` type commit 674a7357abf0dcc365455695d56c0479998ebf27 Author: Daniel Gasienica Date: Tue Feb 13 12:35:23 2018 -0500 Run ESLint on `Conversation::sendMessage` commit cd985aa700caa80946245b17ea1b856449f152a0 Author: Daniel Gasienica Date: Tue Feb 13 12:34:38 2018 -0500 Document type signature of `FileInputView::readFile` commit d70d70e52c49588a1dc9833dfe5dd7128e13607f Author: Daniel Gasienica Date: Tue Feb 13 12:31:16 2018 -0500 Move attachment processing closer to sending This helps ensure processing happens uniformly, regardless of which code paths are taken to send an attachment. commit 532ac3e273a26b97f831247f9ee3412621b5c112 Author: Daniel Gasienica Date: Tue Feb 13 12:22:29 2018 -0500 Process attachment before it’s sent Picked this place since it already had various async steps, similar to `onMessageReceived` for the incoming `Attachment.process`. Could we try have this live closer to where we store it in IndexedDB, e.g. `Conversation::sendMessage`? commit a4582ae2fb6e1d3487131ba1f8fa6a00170cb32c Author: Daniel Gasienica Date: Tue Feb 13 12:21:42 2018 -0500 Refactor `getFile` and `getFiles` Lint them using ESLint. commit 07e9114e65046d791fc4f6ed90d6e2e938ad559d Author: Daniel Gasienica Date: Tue Feb 13 11:37:31 2018 -0500 Document incoming and outgoing attachments fields Note how outgoing message attachments only have 4 fields. This presumably means the others are not used in our code and could be discarded for simplicity. commit fdc3ef289d6ec1be344a12d496839d5ba747bb6a Author: Daniel Gasienica Date: Tue Feb 13 11:36:21 2018 -0500 Highlight that `dataURLToBlob` is synchronous commit b9c6bf600fcecedfd649ef2ae3c8629cced4e45a Author: Daniel Gasienica Date: Tue Feb 13 11:35:49 2018 -0500 Add EditorConfig configuration commit e56101e229d56810c8e31ad7289043a152c6c449 Author: Daniel Gasienica Date: Tue Feb 13 11:34:23 2018 -0500 Replace custom with `blob-util` functions IMPORTANT: All of them are async so we need to use `await`, otherwise we get strange or silent errors. commit f95150f6a9569fabcb31f3acd9f6b7bf50b5d145 Author: Daniel Gasienica Date: Tue Feb 13 11:17:30 2018 -0500 Revert "Replace custom functions with `blob-util`" This reverts commit 8a81e9c01bfe80c0e1bf76737092206c06949512. commit 33860d93f3d30ec55c32f3f4a58729df2eb43f0d Author: Daniel Gasienica Date: Tue Feb 13 11:13:02 2018 -0500 Revert "Replace `blueimp-canvas-to-blob` with `blob-util`" This reverts commit 31b3e853e4afc78fe80995921aa4152d9f6e4783. commit 7a0ba6fed622d76a3c39c7f03de541a7edb5b8dd Author: Daniel Gasienica Date: Tue Feb 13 11:12:58 2018 -0500 Replace `blueimp-canvas-to-blob` with `blob-util` commit 47a5f2bfd8b3f546e27e8d2b7e1969755d825a66 Author: Daniel Gasienica Date: Tue Feb 13 10:55:34 2018 -0500 Replace custom functions with `blob-util` commit 1cfa0efdb4fb1265369e2bf243c21f04f044fa01 Author: Daniel Gasienica Date: Tue Feb 13 10:47:02 2018 -0500 Add `blob-util` dependency commit 9ac26be1bd783cd5070d886de107dd3ad9c91ad1 Author: Daniel Gasienica Date: Tue Feb 13 10:46:44 2018 -0500 Document why we drop original image data during auto-orient commit 4136d6c382b99f41760a4da519d0db537fa7de8d Author: Daniel Gasienica Date: Tue Feb 13 10:46:27 2018 -0500 Extract `DEFAULT_JPEG_QUALITY` commit 4a7156327eb5f94dba80cb300b344ac591226b0e Author: Daniel Gasienica Date: Tue Feb 13 10:37:11 2018 -0500 Drop support for invalid `image/jpg` MIME type commit 69fe96581f25413194032232f1bf704312e4754c Author: Daniel Gasienica Date: Tue Feb 13 09:54:30 2018 -0500 Document `window.onInvalidStateError` global commit a48ba1c77458da38583ee9cd488f70a59f6ee0fd Author: Daniel Gasienica Date: Tue Feb 13 09:54:04 2018 -0500 Selectively run ESLint on `js/background.js` Enabling ESLint on a per function basis allows us to incrementally improve the codebase without requiring large and potentially risky refactorings. commit e6d1cf826befc17ad4ec72fda8e761701665635e Author: Daniel Gasienica Date: Tue Feb 13 09:16:23 2018 -0500 Move async attachment processing to `onMessageReceived` We previously processed attachments in `handleDataMessage` which is mostly a synchronous function, except for the saving of the model. Moving the processing into the already async `onMessageReceived` improves code clarity. commit be6ca2a9aae5b59c360817deb1e18d39d705755e Author: Daniel Gasienica Date: Tue Feb 13 09:14:49 2018 -0500 Document import of ES2015+ modules commit eaaf7c41608fb988b8f4bbaa933cff110115610e Author: Daniel Gasienica Date: Tue Feb 13 09:14:29 2018 -0500 :art: Fix lint error commit a25b0e2e3d0f72c6a7bf0a15683f02450d5209ee Author: Daniel Gasienica Date: Tue Feb 13 09:13:57 2018 -0500 :art: Organize `require`s commit e0cc3d8fab6529d01b388acddf8605908c3d236b Author: Daniel Gasienica Date: Tue Feb 13 09:07:17 2018 -0500 Implement attachment process version Instead of keeping track of last normalization (processing) date, we now keep track of an internal processing version that will help us understand what kind of processing has already been completed for a given attachment. This will let us retroactively upgrade existing attachments. As we add more processing steps, we can build a processing pipeline that can convert any attachment processing version into a higher one, e.g. 4 -> 5 -> 6 -> 7. commit ad9083d0fdb880bc518e02251e51a39f7e1c585f Author: Daniel Gasienica Date: Tue Feb 13 08:50:31 2018 -0500 Ignore ES2015+ files during JSCS linting commit 96641205f734927aaebc2342d977c555799c3e3b Author: Daniel Gasienica Date: Tue Feb 13 08:48:07 2018 -0500 Improve ESLint ignore rules Apparently, using unqualified `/**` patterns prevents `!` include patterns. Using qualified glob patterns, e.g. `js/models/**/*.js`, lets us work around this. commit 255e0ab15bd1a0ca8ca5746e42d23977c8765d01 Author: Daniel Gasienica Date: Tue Feb 13 08:44:59 2018 -0500 :abc: ESLint ignored files commit ebcb70258a26f234bd602072ac7c0a1913128132 Author: Daniel Gasienica Date: Tue Feb 13 08:35:47 2018 -0500 Whitelist `browser` environment for ESLint commit 3eaace6f3a21421c5aaaaf01592408c7ed83ecd3 Author: Daniel Gasienica Date: Tue Feb 13 08:35:05 2018 -0500 Use `MIME` module commit ba2cf7770e614415733414a2dcc48f110b929892 Author: Daniel Gasienica Date: Tue Feb 13 08:32:54 2018 -0500 :art: Fix lint errors commit 65acc86e8580e88f7a6611eb4b8fa5d7291f7a3f Author: Daniel Gasienica Date: Tue Feb 13 08:30:42 2018 -0500 Add ES2015+ files to JSHint ignored list commit 8b6494ae6c9247acdfa059a9b361ec5ffcdb39f0 Author: Daniel Gasienica Date: Tue Feb 13 08:29:20 2018 -0500 Document potentially unexpected `autoScale` behavior commit 8b4c69b2002d1777d3621be10f92cbf432f9d4d6 Author: Daniel Gasienica Date: Tue Feb 13 08:26:47 2018 -0500 Test CommonJS modules separately Not sure how to test them as part of Grunt `unit-tests` task as `test/index.html` doesn’t allow for inclusion of CommonJS modules that use `require`. The tests are silently skipped. commit 213400e4b2bba3efee856a25b40e269221c3c39d Author: Daniel Gasienica Date: Tue Feb 13 08:24:27 2018 -0500 Add `MIME` type module commit 37a726e4fb4b3ed65914463122a5662847b5adee Author: Daniel Gasienica Date: Mon Feb 12 20:18:05 2018 -0500 Return proper `Error` from `blobArrayToBuffer` commit 164752db5612220e4dcf58d57bcd682cb489a399 Author: Daniel Gasienica Date: Mon Feb 12 20:15:41 2018 -0500 :art: Fix ESLint errors commit d498dd79a067c75098dd3179814c914780e5cb4f Author: Daniel Gasienica Date: Mon Feb 12 20:14:33 2018 -0500 Update `Attachment` type field definitions commit 141155a1533ff8fb616b70ea313432781bbebffd Author: Daniel Gasienica Date: Mon Feb 12 20:12:50 2018 -0500 Move `blueimp-canvas-to-blob` from Bower to npm commit 7ccb833e5d286ddd6235d3e491c62ac1e4544510 Author: Daniel Gasienica Date: Mon Feb 12 16:33:50 2018 -0500 :art: Clarify data flow commit e7da41591fde5a830467bebf1b6f51c1f7293e74 Author: Daniel Gasienica Date: Mon Feb 12 16:31:21 2018 -0500 Use `blobUrl` for consistency commit 523a80eefe0e2858aa1fb2bb9539ec44da502963 Author: Daniel Gasienica Date: Mon Feb 12 16:28:06 2018 -0500 Remove just-in-time image auto-orient for lightbox We can bring this back if our users would like auto-orient for old attachments. commit 0739feae9c47dd523c10740d6cdf746d539f270c Author: Daniel Gasienica Date: Mon Feb 12 16:27:21 2018 -0500 Remove just-in-time auto-orient of message attachments We can bring this back if our users would like auto-orient for old attachments. But better yet, we might implement this as database migration. commit ed43c66f92830ee233d5a94d0545eea4da43894d Author: Daniel Gasienica Date: Mon Feb 12 16:26:24 2018 -0500 Auto-orient JPEG attachments upon receipt commit e2eb8e36b017b048d57602fca14e45d657e0e1a1 Author: Daniel Gasienica Date: Mon Feb 12 16:25:26 2018 -0500 Expose `Attachment` type through `Whisper.Attachment` commit 9638fbc987b84f143ca34211dc4666d96248ea2f Author: Daniel Gasienica Date: Mon Feb 12 16:23:39 2018 -0500 Use `contentType` from `model` commit 032c0ced46c3876cb9474b26f9d53d6f1c6b16a0 Author: Daniel Gasienica Date: Mon Feb 12 16:23:04 2018 -0500 Return `Error` object for `autoOrientImage` failures commit ff04bad8510c4b21aef350bed2b1887d0e055b98 Author: Daniel Gasienica Date: Mon Feb 12 16:22:32 2018 -0500 Add `options` for `autoOrientImage` output type / quality commit 87745b5586d1e182b51c9f9bc5e4eaf6dbc16722 Author: Daniel Gasienica Date: Mon Feb 12 16:18:46 2018 -0500 Add `Attachment` type Defines various functions on attachments, e.g. normalization (auto-orient JPEGs, etc.) commit de27fdc10a53bc8882a9c978e82265db9ac6d6f5 Author: Daniel Gasienica Date: Mon Feb 12 16:16:34 2018 -0500 Add `yarn grunt` shortcut This allows us to use local `grunt-cli` for `grunt dev`. commit 59974db5a5da0d8f4cdc8ce5c4e3c974ecd5e754 Author: Daniel Gasienica Date: Mon Feb 12 10:10:11 2018 -0500 Improve readability commit b5ba96f1e6f40f2e1fa77490c583217768e1f412 Author: Daniel Gasienica Date: Mon Feb 12 10:08:12 2018 -0500 Use `snake_case` for module names Prevents problems across case-sensitive and case-insensitive file systems. We can work around this in the future using a lint rule such as `eslint-plugin-require-path-exists`. See discussion: https://github.com/signalapp/Signal-Desktop/pull/2040#discussion_r167365931 commit 48c5d3155c96ef628b00d99b52975e580d1d5501 Author: Daniel Gasienica Date: Mon Feb 12 10:05:44 2018 -0500 :art: Use destructuring commit 4822f49f22382a99ebf142b337375f7c25251d76 Author: Daniel Gasienica Date: Fri Feb 9 17:41:40 2018 -0500 Auto-orient images in lightbox view commit 7317110809677dddbbef3fadbf912cdba1c010bf Author: Daniel Gasienica Date: Fri Feb 9 17:40:14 2018 -0500 Document magic number for escape key commit c790d07389a7d0bbf5298de83dbcfa8be1e7696b Author: Daniel Gasienica Date: Fri Feb 9 17:38:35 2018 -0500 Make second `View` argument an `options` object commit fbe010bb63d0088af9dfe11f153437fab34247e0 Author: Daniel Gasienica Date: Fri Feb 9 17:27:40 2018 -0500 Allow `loadImage` to fetch `blob://` URLs commit ec35710d002b019a273eeb48f94dcaf2babe5d96 Author: Daniel Gasienica Date: Fri Feb 9 16:57:48 2018 -0500 :art: Shorten `autoOrientImage` import commit d07433e3cf316c6a143a0c9393ba26df9e3af17b Author: Daniel Gasienica Date: Fri Feb 9 16:57:19 2018 -0500 Make `autoOrientImage` module standalone commit c285bf5e33cdf10e0ef71e72cd6f55aef0df96ef Author: Daniel Gasienica Date: Fri Feb 9 16:55:44 2018 -0500 Replace `loadImage` with `autoOrientImage` commit 44318549235af01fd061c25f557c93fd21cebb7a Author: Daniel Gasienica Date: Fri Feb 9 16:53:23 2018 -0500 Add `autoOrientImage` module This module exposes `loadImage` with a `Promise` based interface and pre- populates `orientation: true` option to auto-orient input. Returns data URL as string. The module uses a named export as refactoring references of modules with `default` (`module.exports`) export references can be error-prone. See: https://basarat.gitbooks.io/typescript/docs/tips/defaultIsBad.html commit c77063afc6366fe49615052796fe46f9b369de39 Author: Daniel Gasienica Date: Fri Feb 9 16:44:30 2018 -0500 Auto-orient preview images See: #998 commit 06dba5eb8f662c11af3a9ba8395bb453ab2e5f8d Author: Daniel Gasienica Date: Fri Feb 9 16:43:23 2018 -0500 TODO: Use native `Canvas::toBlob` One challenge is that `Canvas::toBlob` is async whereas `dataURLtoBlob` is sync. commit b15c304a3125dd023fd90990e6225a7303f3596f Author: Daniel Gasienica Date: Fri Feb 9 16:42:45 2018 -0500 Make `null` check strict Appeases JSHint. ESLint has a nice `smart` option for `eqeqeq` rule: https://eslint.org/docs/rules/eqeqeq#smart commit ea70b92d9b18201758e11fdc25b09afc97b50055 Author: Daniel Gasienica Date: Fri Feb 9 15:23:58 2018 -0500 Use `Canvas::toDataURL` to preserve `ImageView` logic This way, all the other code paths remain untouched in case we want to remove the auto-orient code once Chrome supports the `image-orientation` CSS property. See: - #998 - https://developer.mozilla.org/en-US/docs/Web/CSS/image-orientation commit 62fd744f9f27d951573a68d2cdfe7ba2a3784b41 Author: Daniel Gasienica Date: Fri Feb 9 14:38:04 2018 -0500 Use CSS to constrain auto-oriented images commit f4d3392687168c237441b29140c7968b49dbef9e Author: Daniel Gasienica Date: Fri Feb 9 14:35:02 2018 -0500 Replace `ImageView` `el` with auto-oriented `canvas` See: #998 commit 1602d7f610e4993ad1291f88197f9ead1e25e776 Author: Daniel Gasienica Date: Fri Feb 9 14:25:48 2018 -0500 Pass `Blob` to `View` (for `ImageView`) This allows us to do JPEG auto-orientation based on EXIF metadata. commit e6a414f2b2a80da1137b839b348a38510efd04bb Author: Daniel Gasienica Date: Fri Feb 9 14:25:12 2018 -0500 :hocho: Remove newline commit 5f0d9570d7862fc428ff89c2ecfd332a744537e5 Author: Daniel Gasienica Date: Fri Feb 9 11:17:02 2018 -0500 Expose `blueimp-load-image` as `window.loadImage` commit 1e1c62fe2f6a76dbcf1998dd468c26187c9871dc Author: Daniel Gasienica Date: Fri Feb 9 11:16:46 2018 -0500 Add `blueimp-load-image` npm dependency commit ad17fa8a68a21ca5ddec336801b8568009bef3d4 Author: Daniel Gasienica Date: Fri Feb 9 11:14:40 2018 -0500 Remove `blueimp-load-image` Bower dependency --- .editorconfig | 14 + .eslintignore | 22 +- .eslintrc.js | 4 +- Gruntfile.js | 3 + app/logging.js | 4 +- background.html | 2 +- bower.json | 9 - js/background.js | 194 +++++---- js/models/conversations.js | 111 +++-- js/models/messages.js | 2 +- js/modules/auto_orient_image.js | 40 ++ js/modules/types/attachment.js | 98 +++++ js/modules/types/message.js | 17 + js/modules/types/mime.js | 2 + js/views/attachment_view.js | 471 +++++++++++---------- js/views/file_input_view.js | 92 ++-- libtextsecure/test/contacts_parser_test.js | 8 +- main.js | 18 +- package.json | 7 +- preload.js | 12 + test/modules/types/mime_test.js | 30 ++ yarn.lock | 35 ++ 22 files changed, 781 insertions(+), 414 deletions(-) create mode 100644 .editorconfig create mode 100644 js/modules/auto_orient_image.js create mode 100644 js/modules/types/attachment.js create mode 100644 js/modules/types/message.js create mode 100644 js/modules/types/mime.js create mode 100644 test/modules/types/mime_test.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..5878c72c87 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{js/modules/**/*.js, test/modules/**/*.js}] +indent_size = 2 diff --git a/.eslintignore b/.eslintignore index 9569b6efc8..c4a7080040 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,17 +1,23 @@ build/** components/** +coverage/** dist/** libtextsecure/** -coverage/** # these aren't ready yet, pulling files in one-by-one -js/** -test/** +js/*.js +js/models/**/*.js +js/react/**/*.js +js/views/**/*.js +test/*.js +test/models/*.js +test/views/*.js /*.js + +# ES2015+ files +!js/background.js +!js/models/conversations.js +!js/views/file_input_view.js +!js/views/attachment_view.js !main.js !prepare_build.js - -# all of these files will be new -!test/server/**/*.js - -# all of app/ is included diff --git a/.eslintrc.js b/.eslintrc.js index f4db62c1e5..55d953c9a0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -21,7 +21,7 @@ module.exports = { }], // putting params on their own line helps stay within line length limit - 'function-paren-newline': ['error', 'consistent'], + 'function-paren-newline': ['error', 'multiline'], // 90 characters allows three+ side-by-side screens on a standard-size monitor 'max-len': ['error', { @@ -37,5 +37,7 @@ module.exports = { // though we have a logger, we still remap console to log to disk 'no-console': 'off', + + 'operator-linebreak': 'error', } }; diff --git a/Gruntfile.js b/Gruntfile.js index e286687dcd..8eb6f81d77 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -103,6 +103,7 @@ module.exports = function(grunt) { '!js/Mp3LameEncoder.min.js', '!js/libsignal-protocol-worker.js', '!js/components.js', + '!js/modules/**/*.js', '!js/signal_protocol_store.js', '_locales/**/*' ], @@ -174,8 +175,10 @@ module.exports = function(grunt) { '!js/Mp3LameEncoder.min.js', '!js/libsignal-protocol-worker.js', '!js/components.js', + '!js/modules/**/*.js', 'test/**/*.js', '!test/blanket_mocha.js', + '!test/modules/**/*.js', '!test/test.js', ] } diff --git a/app/logging.js b/app/logging.js index bad52467ab..52bf2cc04f 100644 --- a/app/logging.js +++ b/app/logging.js @@ -117,8 +117,8 @@ function eliminateOutOfDateFiles(logPath, date) { const file = { path: target, start: isLineAfterDate(start, date), - end: isLineAfterDate(end[end.length - 1], date) - || isLineAfterDate(end[end.length - 2], date), + end: isLineAfterDate(end[end.length - 1], date) || + isLineAfterDate(end[end.length - 2], date), }; if (!file.start && !file.end) { diff --git a/background.html b/background.html index b4af657e37..193e4a19b3 100644 --- a/background.html +++ b/background.html @@ -8,7 +8,7 @@ ({ + type: Message.GROUP, + id: group.id, + }); - var type, id; - if (data.message.group) { - type = 'group'; - id = data.message.group.id; - } else { - type = 'private'; - id = data.source; - } + // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: + const getDescriptorForSent = ({ message, destination }) => ( + message.group + ? getGroupDescriptor(message.group) + : { type: Message.PRIVATE, id: destination } + ); - return ConversationController.getOrCreateAndWait(id, type).then(function() { - return message.handleDataMessage(data.message, ev.confirm, { - initialLoadComplete: initialLoadComplete - }); - }); - }); - } + // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: + const getDescriptorForReceived = ({ message, source }) => ( + message.group + ? getGroupDescriptor(message.group) + : { type: Message.PRIVATE, id: source } + ); - function onSentMessage(ev) { - var now = new Date().getTime(); - var data = ev.data; + function createMessageHandler({ + createMessage, + getMessageDescriptor, + handleProfileUpdate, + }) { + return async (event) => { + const { data, confirm } = event; - var type, id; - if (data.message.group) { - type = 'group'; - id = data.message.group.id; - } else { - type = 'private'; - id = data.destination; - } + const messageDescriptor = getMessageDescriptor(data); - if (data.message.flags & textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE) { - return ConversationController.getOrCreateAndWait(id, type).then(function(convo) { - return convo.save({profileSharing: true}).then(ev.confirm); - }); - } + const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags; + // eslint-disable-next-line no-bitwise + const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); + if (isProfileUpdate) { + return handleProfileUpdate({ data, confirm, messageDescriptor }); + } - var message = new Whisper.Message({ - source : textsecure.storage.user.getNumber(), - sourceDevice : data.device, - sent_at : data.timestamp, - received_at : now, - conversationId : data.destination, - type : 'outgoing', - sent : true, - expirationStartTimestamp: data.expirationStartTimestamp, - }); + const message = createMessage(data); + const isDuplicate = await isMessageDuplicate(message); + if (isDuplicate) { + console.log('Received duplicate message', message.idForLogging()); + return event.confirm(); + } - return isMessageDuplicate(message).then(function(isDuplicate) { - if (isDuplicate) { - console.log('Received duplicate message', message.idForLogging()); - ev.confirm(); - return; - } + const upgradedMessage = await Message.upgradeSchema(data.message); + await ConversationController.getOrCreateAndWait( + messageDescriptor.id, + messageDescriptor.type + ); + return message.handleDataMessage( + upgradedMessage, + event.confirm, + { initialLoadComplete } + ); + }; + } - return ConversationController.getOrCreateAndWait(id, type).then(function() { - return message.handleDataMessage(data.message, ev.confirm, { - initialLoadComplete: initialLoadComplete - }); - }); - }); - } + // Received: + async function handleMessageReceivedProfileUpdate({ + data, + confirm, + messageDescriptor, + }) { + const profileKey = data.message.profileKey.toArrayBuffer(); + const sender = await ConversationController.getOrCreateAndWait( + messageDescriptor.id, + 'private' + ); + await sender.setProfileKey(profileKey); + return confirm(); + } + + const onMessageReceived = createMessageHandler({ + handleProfileUpdate: handleMessageReceivedProfileUpdate, + getMessageDescriptor: getDescriptorForReceived, + createMessage: initIncomingMessage, + }); + + // Sent: + async function handleMessageSentProfileUpdate({ confirm, messageDescriptor }) { + const conversation = await ConversationController.getOrCreateAndWait( + messageDescriptor.id, + messageDescriptor.type + ); + await conversation.save({ profileSharing: true }); + return confirm(); + } + + function createSentMessage(data) { + const now = Date.now(); + return new Whisper.Message({ + source: textsecure.storage.user.getNumber(), + sourceDevice: data.device, + sent_at: data.timestamp, + received_at: now, + conversationId: data.destination, + type: 'outgoing', + sent: true, + expirationStartTimestamp: data.expirationStartTimestamp, + }); + } + + const onSentMessage = createMessageHandler({ + handleProfileUpdate: handleMessageSentProfileUpdate, + getMessageDescriptor: getDescriptorForSent, + createMessage: createSentMessage, + }); + /* jshint ignore:end */ + /* eslint-disable */ function isMessageDuplicate(message) { return new Promise(function(resolve) { diff --git a/js/models/conversations.js b/js/models/conversations.js index 45320b8613..45bc96c941 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1,10 +1,16 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-disable */ + +/* global Signal: false */ +/* global storage: false */ +/* global textsecure: false */ +/* global Whisper: false */ + (function () { 'use strict'; window.Whisper = window.Whisper || {}; + const { Attachment, Message } = window.Signal.Types; + // TODO: Factor out private and group subclasses of Conversation var COLORS = [ @@ -598,54 +604,71 @@ } }, - sendMessage: function(body, attachments) { - this.queueJob(function() { - var now = Date.now(); + /* jshint ignore:start */ + /* eslint-enable */ + sendMessage(body, attachments) { + this.queueJob(async () => { + const now = Date.now(); - console.log( - 'Sending message to conversation', - this.idForLogging(), - 'with timestamp', - now - ); + console.log( + 'Sending message to conversation', + this.idForLogging(), + 'with timestamp', + now + ); - var message = this.messageCollection.add({ - body : body, - conversationId : this.id, - type : 'outgoing', - attachments : attachments, - sent_at : now, - received_at : now, - expireTimer : this.get('expireTimer'), - recipients : this.getRecipients() - }); - if (this.isPrivate()) { - message.set({destination: this.id}); - } - message.save(); + const upgradedAttachments = + await Promise.all(attachments.map(Attachment.upgradeSchema)); + const message = this.messageCollection.add({ + body, + conversationId: this.id, + type: 'outgoing', + attachments: upgradedAttachments, + sent_at: now, + received_at: now, + expireTimer: this.get('expireTimer'), + recipients: this.getRecipients(), + }); + if (this.isPrivate()) { + message.set({ destination: this.id }); + } + message.save(); - this.save({ - active_at : now, - timestamp : now, - lastMessage : message.getNotificationText() - }); + this.save({ + active_at: now, + timestamp: now, + lastMessage: message.getNotificationText(), + }); - var sendFunc; - if (this.get('type') == 'private') { - sendFunc = textsecure.messaging.sendMessageToNumber; - } - else { - sendFunc = textsecure.messaging.sendMessageToGroup; - } + const conversationType = this.get('type'); + const sendFunc = (() => { + switch (conversationType) { + case Message.PRIVATE: + return textsecure.messaging.sendMessageToNumber; + case Message.GROUP: + return textsecure.messaging.sendMessageToGroup; + default: + throw new TypeError(`Invalid conversation type: '${conversationType}'`); + } + })(); - var profileKey; - if (this.get('profileSharing')) { - profileKey = storage.get('profileKey'); - } + let profileKey; + if (this.get('profileSharing')) { + profileKey = storage.get('profileKey'); + } - message.send(sendFunc(this.get('id'), body, attachments, now, this.get('expireTimer'), profileKey)); - }.bind(this)); + message.send(sendFunc( + this.get('id'), + body, + upgradedAttachments, + now, + this.get('expireTimer'), + profileKey + )); + }); }, + /* jshint ignore:end */ + /* eslint-disable */ updateLastMessage: function() { var collection = new Whisper.MessageCollection(); diff --git a/js/models/messages.js b/js/models/messages.js index 7f8d5ddfe6..dbde2dd90b 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -373,7 +373,7 @@ // 1. on an incoming message // 2. on a sent message sync'd from another device // 3. in rare cases, an incoming message can be retried, though it will - // still through one of the previous two codepaths. + // still go through one of the previous two codepaths var message = this; var source = message.get('source'); var type = message.get('type'); diff --git a/js/modules/auto_orient_image.js b/js/modules/auto_orient_image.js new file mode 100644 index 0000000000..efe37da05c --- /dev/null +++ b/js/modules/auto_orient_image.js @@ -0,0 +1,40 @@ +const loadImage = require('blueimp-load-image'); + +const DEFAULT_JPEG_QUALITY = 0.85; + +// File | Blob | URLString -> LoadImageOptions -> Promise +// +// Documentation for `options` (`LoadImageOptions`): +// https://github.com/blueimp/JavaScript-Load-Image/tree/v2.18.0#options +exports.autoOrientImage = (fileOrBlobOrURL, options = {}) => { + const optionsWithDefaults = Object.assign( + { + type: 'image/jpeg', + quality: DEFAULT_JPEG_QUALITY, + }, + options, + { + canvas: true, + orientation: true, + } + ); + + return new Promise((resolve, reject) => { + loadImage(fileOrBlobOrURL, (canvasOrError) => { + if (canvasOrError.type === 'error') { + const error = new Error('autoOrientImage: Failed to process image'); + error.cause = canvasOrError; + reject(error); + return; + } + + const canvas = canvasOrError; + const dataURL = canvas.toDataURL( + optionsWithDefaults.type, + optionsWithDefaults.quality + ); + + resolve(dataURL); + }, optionsWithDefaults); + }); +}; diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js new file mode 100644 index 0000000000..048f4f368c --- /dev/null +++ b/js/modules/types/attachment.js @@ -0,0 +1,98 @@ +const MIME = require('./mime'); +const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util'); +const { autoOrientImage } = require('../auto_orient_image'); + +// Increment this everytime we change how attachments are upgraded. This allows us to +// retroactively upgrade existing attachments. As we add more upgrade steps, we could +// design a pipeline that does this incrementally, e.g. from version 0 (unknown) -> 1, +// 1 --> 2, etc., similar to how we do database migrations: +const CURRENT_PROCESS_VERSION = 1; + +// Schema version history +// +// Version 1 +// - Auto-orient JPEG attachments using EXIF `Orientation` data +// - Add `schemaVersion` property + +// // Incoming message attachment fields +// { +// id: string +// contentType: MIMEType +// data: ArrayBuffer +// digest: ArrayBuffer +// fileName: string +// flags: null +// key: ArrayBuffer +// size: integer +// thumbnail: ArrayBuffer +// schemaVersion: integer +// } + +// // Outgoing message attachment fields +// { +// contentType: MIMEType +// data: ArrayBuffer +// fileName: string +// size: integer +// schemaVersion: integer +// } + +// Middleware +// type UpgradeStep = Attachment -> Promise Attachment + +// UpgradeStep -> SchemaVersion -> UpgradeStep +const setSchemaVersion = (next, schemaVersion) => async (attachment) => { + const isAlreadyUpgraded = attachment.schemaVersion >= schemaVersion; + if (isAlreadyUpgraded) { + return attachment; + } + + let upgradedAttachment; + try { + upgradedAttachment = await next(attachment); + } catch (error) { + console.error('Attachment.setSchemaVersion: error:', error); + upgradedAttachment = null; + } + + const hasSuccessfullyUpgraded = upgradedAttachment !== null; + if (!hasSuccessfullyUpgraded) { + return attachment; + } + + return Object.assign( + {}, + upgradedAttachment, + { schemaVersion } + ); +}; + +// Upgrade steps +const autoOrientJPEG = async (attachment) => { + if (!MIME.isJPEG(attachment.contentType)) { + return attachment; + } + + const dataBlob = await arrayBufferToBlob(attachment.data, attachment.contentType); + const newDataBlob = await dataURLToBlob(await autoOrientImage(dataBlob)); + const newDataArrayBuffer = await blobToArrayBuffer(newDataBlob); + + // IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original + // image data. Ideally, we’d preserve the original image data for users who want to + // retain it but due to reports of data loss, we don’t want to overburden IndexedDB + // by potentially doubling stored image data. + // See: https://github.com/signalapp/Signal-Desktop/issues/1589 + const newAttachment = Object.assign({}, attachment, { + data: newDataArrayBuffer, + size: newDataArrayBuffer.byteLength, + }); + + // `digest` is no longer valid for auto-oriented image data, so we discard it: + delete newAttachment.digest; + + return newAttachment; +}; + +// Public API +// UpgradeStep +exports.upgradeSchema = setSchemaVersion(autoOrientJPEG, CURRENT_PROCESS_VERSION); diff --git a/js/modules/types/message.js b/js/modules/types/message.js new file mode 100644 index 0000000000..195359b64a --- /dev/null +++ b/js/modules/types/message.js @@ -0,0 +1,17 @@ +const Attachment = require('./attachment'); + + +const GROUP = 'group'; +const PRIVATE = 'private'; + +// Public API +exports.GROUP = GROUP; +exports.PRIVATE = PRIVATE; + +// Schema +// Message -> Promise Message +exports.upgradeSchema = async message => + Object.assign({}, message, { + attachments: + await Promise.all(message.attachments.map(Attachment.upgradeSchema)), + }); diff --git a/js/modules/types/mime.js b/js/modules/types/mime.js new file mode 100644 index 0000000000..82228f9dc6 --- /dev/null +++ b/js/modules/types/mime.js @@ -0,0 +1,2 @@ +exports.isJPEG = mimeType => + mimeType === 'image/jpeg'; diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js index 0d10691136..03e477dc33 100644 --- a/js/views/attachment_view.js +++ b/js/views/attachment_view.js @@ -1,271 +1,290 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-env browser */ + +/* global $: false */ +/* global _: false */ +/* global Backbone: false */ +/* global moment: false */ + +/* global i18n: false */ +/* global textsecure: false */ +/* global Whisper: false */ + +// eslint-disable-next-line func-names (function () { - 'use strict'; + const ESCAPE_KEY_CODE = 27; - var FileView = Whisper.View.extend({ - tagName: 'div', - className: 'fileView', - templateName: 'file-view', - render_attributes: function() { - return this.model; - } + const FileView = Whisper.View.extend({ + tagName: 'div', + className: 'fileView', + templateName: 'file-view', + render_attributes() { + return this.model; + }, }); - var ImageView = Backbone.View.extend({ - tagName: 'img', - initialize: function(dataUrl) { - this.dataUrl = dataUrl; - }, - events: { - 'load': 'update', - }, - update: function() { - this.trigger('update'); - }, - render: function() { - this.$el.attr('src', this.dataUrl); - return this; - } + const ImageView = Backbone.View.extend({ + tagName: 'img', + initialize(blobUrl) { + this.blobUrl = blobUrl; + }, + events: { + load: 'update', + }, + update() { + this.trigger('update'); + }, + render() { + this.$el.attr('src', this.blobUrl); + return this; + }, }); - var MediaView = Backbone.View.extend({ - initialize: function(dataUrl, contentType) { - this.dataUrl = dataUrl; - this.contentType = contentType; - this.$el.attr('controls', ''); - }, - events: { - 'canplay': 'canplay' - }, - canplay: function() { - this.trigger('update'); - }, - render: function() { - var $el = $(''); - $el.attr('src', this.dataUrl); - this.$el.append($el); - return this; - } + const MediaView = Backbone.View.extend({ + initialize(dataUrl, { contentType } = {}) { + this.dataUrl = dataUrl; + this.contentType = contentType; + this.$el.attr('controls', ''); + }, + events: { + canplay: 'canplay', + }, + canplay() { + this.trigger('update'); + }, + render() { + const $el = $(''); + $el.attr('src', this.dataUrl); + this.$el.append($el); + return this; + }, }); - var AudioView = MediaView.extend({ tagName: 'audio' }); - var VideoView = MediaView.extend({ tagName: 'video' }); + const AudioView = MediaView.extend({ tagName: 'audio' }); + const VideoView = MediaView.extend({ tagName: 'video' }); // Blacklist common file types known to be unsupported in Chrome - var UnsupportedFileTypes = [ + const UnsupportedFileTypes = [ 'audio/aiff', - 'video/quicktime' + 'video/quicktime', ]; Whisper.AttachmentView = Backbone.View.extend({ tagName: 'span', - className: function() { + className() { if (this.isImage()) { return 'attachment'; - } else { - return 'attachment bubbled'; + } + return 'attachment bubbled'; + }, + initialize(options) { + this.blob = new Blob([this.model.data], { type: this.model.contentType }); + if (!this.model.size) { + this.model.size = this.model.data.byteLength; + } + if (options.timestamp) { + this.timestamp = options.timestamp; } }, - initialize: function(options) { - this.blob = new Blob([this.model.data], {type: this.model.contentType}); - if (!this.model.size) { - this.model.size = this.model.data.byteLength; - } - if (options.timestamp) { - this.timestamp = options.timestamp; - } - }, events: { - 'click': 'onclick' + click: 'onclick', }, - unload: function() { - this.blob = null; + unload() { + this.blob = null; - if (this.lightBoxView) { - this.lightBoxView.remove(); - } - if (this.fileView) { - this.fileView.remove(); - } - if (this.view) { - this.view.remove(); - } + if (this.lightBoxView) { + this.lightBoxView.remove(); + } + if (this.fileView) { + this.fileView.remove(); + } + if (this.view) { + this.view.remove(); + } - this.remove(); + this.remove(); }, - getFileType: function() { - switch(this.model.contentType) { - case 'video/quicktime': return 'mov'; - default: return this.model.contentType.split('/')[1]; - } + getFileType() { + switch (this.model.contentType) { + case 'video/quicktime': return 'mov'; + default: return this.model.contentType.split('/')[1]; + } }, - onclick: function(e) { - if (this.isImage()) { - this.lightBoxView = new Whisper.LightboxView({ model: this }); - this.lightBoxView.render(); - this.lightBoxView.$el.appendTo(this.el); - this.lightBoxView.$el.trigger('show'); + onclick() { + if (this.isImage()) { + this.lightBoxView = new Whisper.LightboxView({ model: this }); + this.lightBoxView.render(); + this.lightBoxView.$el.appendTo(this.el); + this.lightBoxView.$el.trigger('show'); + } else { + this.saveFile(); + } + }, + isVoiceMessage() { + // eslint-disable-next-line no-bitwise + if (this.model.flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE) { + return true; + } - } else { - this.saveFile(); - } - }, - isVoiceMessage: function() { - if (this.model.flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE) { - return true; - } + // Support for android legacy voice messages + if (this.isAudio() && this.model.fileName === null) { + return true; + } - // Support for android legacy voice messages - if (this.isAudio() && this.model.fileName === null) { - return true; - } + return false; }, - isAudio: function() { - return this.model.contentType.startsWith('audio/'); + isAudio() { + return this.model.contentType.startsWith('audio/'); }, - isVideo: function() { - return this.model.contentType.startsWith('video/'); + isVideo() { + return this.model.contentType.startsWith('video/'); }, - isImage: function() { - var type = this.model.contentType; - return type.startsWith('image/') && type !== 'image/tiff'; + isImage() { + const type = this.model.contentType; + return type.startsWith('image/') && type !== 'image/tiff'; }, - mediaType: function() { - if (this.isVoiceMessage()) { - return 'voice'; - } else if (this.isAudio()) { - return 'audio'; - } else if (this.isVideo()) { - return 'video'; - } else if (this.isImage()) { - return 'image'; - } - }, - displayName: function() { - if (this.isVoiceMessage()) { - return i18n('voiceMessage'); - } - if (this.model.fileName) { - return this.model.fileName; - } - if (this.isAudio() || this.isVideo()) { - return i18n('mediaMessage'); - } + mediaType() { + if (this.isVoiceMessage()) { + return 'voice'; + } else if (this.isAudio()) { + return 'audio'; + } else if (this.isVideo()) { + return 'video'; + } else if (this.isImage()) { + return 'image'; + } - return i18n('unnamedFile'); + // NOTE: The existing code had no `return` but ESLint insists. Thought + // about throwing an error assuming this was unreachable code but it turns + // out that content type `image/tiff` falls through here: + return undefined; }, - suggestedName: function() { - if (this.model.fileName) { - return this.model.fileName; - } + displayName() { + if (this.isVoiceMessage()) { + return i18n('voiceMessage'); + } + if (this.model.fileName) { + return this.model.fileName; + } + if (this.isAudio() || this.isVideo()) { + return i18n('mediaMessage'); + } - var suggestion = 'signal'; - if (this.timestamp) { - suggestion += moment(this.timestamp).format('-YYYY-MM-DD-HHmmss'); - } - var fileType = this.getFileType(); - if (fileType) { - suggestion += '.' + fileType; - } - return suggestion; + return i18n('unnamedFile'); }, - saveFile: function() { - var url = window.URL.createObjectURL(this.blob, { type: 'octet/stream' }); - var a = $('').attr({ href: url, download: this.suggestedName() }); - a[0].click(); - window.URL.revokeObjectURL(url); - }, - render: function() { - if (!this.isImage()) { - this.renderFileView(); - } - var View; - if (this.isImage()) { - View = ImageView; - } else if (this.isAudio()) { - View = AudioView; - } else if (this.isVideo()) { - View = VideoView; - } + suggestedName() { + if (this.model.fileName) { + return this.model.fileName; + } - if (!View || _.contains(UnsupportedFileTypes, this.model.contentType)) { - this.update(); - return this; - } - - if (!this.objectUrl) { - this.objectUrl = window.URL.createObjectURL(this.blob); - } - this.view = new View(this.objectUrl, this.model.contentType); - this.view.$el.appendTo(this.$el); - this.listenTo(this.view, 'update', this.update); - this.view.render(); - if (View !== ImageView) { - this.timeout = setTimeout(this.onTimeout.bind(this), 5000); - } - return this; + let suggestion = 'signal'; + if (this.timestamp) { + suggestion += moment(this.timestamp).format('-YYYY-MM-DD-HHmmss'); + } + const fileType = this.getFileType(); + if (fileType) { + suggestion += `.${fileType}`; + } + return suggestion; }, - onTimeout: function() { - // Image or media element failed to load. Fall back to FileView. - this.stopListening(this.view); + saveFile() { + const url = window.URL.createObjectURL(this.blob, { type: 'octet/stream' }); + const a = $('').attr({ href: url, download: this.suggestedName() }); + a[0].click(); + window.URL.revokeObjectURL(url); + }, + render() { + if (!this.isImage()) { + this.renderFileView(); + } + let View; + if (this.isImage()) { + View = ImageView; + } else if (this.isAudio()) { + View = AudioView; + } else if (this.isVideo()) { + View = VideoView; + } + + if (!View || _.contains(UnsupportedFileTypes, this.model.contentType)) { this.update(); - }, - renderFileView: function() { - this.fileView = new FileView({ - model: { - mediaType: this.mediaType(), - fileName: this.displayName(), - fileSize: window.filesize(this.model.size), - altText: i18n('clickToSave') - } - }); - - this.fileView.$el.appendTo(this.$el.empty()); - this.fileView.render(); return this; + } + + if (!this.objectUrl) { + this.objectUrl = window.URL.createObjectURL(this.blob); + } + + const { blob } = this; + const { contentType } = this.model; + this.view = new View(this.objectUrl, { blob, contentType }); + this.view.$el.appendTo(this.$el); + this.listenTo(this.view, 'update', this.update); + this.view.render(); + if (View !== ImageView) { + this.timeout = setTimeout(this.onTimeout.bind(this), 5000); + } + return this; + }, + onTimeout() { + // Image or media element failed to load. Fall back to FileView. + this.stopListening(this.view); + this.update(); + }, + renderFileView() { + this.fileView = new FileView({ + model: { + mediaType: this.mediaType(), + fileName: this.displayName(), + fileSize: window.filesize(this.model.size), + altText: i18n('clickToSave'), + }, + }); + + this.fileView.$el.appendTo(this.$el.empty()); + this.fileView.render(); + return this; + }, + update() { + clearTimeout(this.timeout); + this.trigger('update'); }, - update: function() { - clearTimeout(this.timeout); - this.trigger('update'); - } }); Whisper.LightboxView = Whisper.View.extend({ - templateName: 'lightbox', - className: 'modal lightbox', - initialize: function() { - this.window = window; - this.$document = $(this.window.document); - this.listener = this.onkeyup.bind(this); - this.$document.on('keyup', this.listener); - }, - events: { - 'click .save': 'save', - 'click .close': 'remove', - 'click': 'onclick' - }, - save: function(e) { - this.model.saveFile(); - }, - onclick: function(e) { - var $el = this.$(e.target); - if (!$el.hasClass('image') && !$el.closest('.controls').length ) { - e.preventDefault(); - this.remove(); - return false; - } - }, - onkeyup: function(e) { - if (e.keyCode === 27) { - this.remove(); - this.$document.off('keyup', this.listener); - } - }, - render_attributes: function() { - return { url: this.model.objectUrl }; + templateName: 'lightbox', + className: 'modal lightbox', + initialize() { + this.window = window; + this.$document = $(this.window.document); + this.listener = this.onkeyup.bind(this); + this.$document.on('keyup', this.listener); + }, + events: { + 'click .save': 'save', + 'click .close': 'remove', + click: 'onclick', + }, + save() { + this.model.saveFile(); + }, + onclick(e) { + const $el = this.$(e.target); + if (!$el.hasClass('image') && !$el.closest('.controls').length) { + e.preventDefault(); + this.remove(); + return false; } - }); -})(); + return true; + }, + onkeyup(e) { + if (e.keyCode === ESCAPE_KEY_CODE) { + this.remove(); + this.$document.off('keyup', this.listener); + } + }, + render_attributes() { + return { url: this.model.objectUrl }; + }, + }); +}()); diff --git a/js/views/file_input_view.js b/js/views/file_input_view.js index d892286254..943771af14 100644 --- a/js/views/file_input_view.js +++ b/js/views/file_input_view.js @@ -1,10 +1,13 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-disable */ + +/* global textsecure: false */ + (function () { 'use strict'; window.Whisper = window.Whisper || {}; + const { MIME } = window.Signal.Types; + Whisper.FileSizeToast = Whisper.ToastView.extend({ templateName: 'file-size-modal', render_attributes: function() { @@ -30,6 +33,7 @@ this.thumb = new Whisper.AttachmentPreviewView(); this.$el.addClass('file-input'); this.window = options.window; + this.previewObjectUrl = null; }, events: { @@ -93,7 +97,6 @@ return; } - // loadImage.scale -> components/blueimp-load-image var canvas = loadImage.scale(img, { canvas: true, maxWidth: maxWidth, maxHeight: maxHeight }); @@ -103,11 +106,13 @@ var blob; do { i = i - 1; - // dataURLtoBlob -> components/blueimp-canvas-to-blob - blob = dataURLtoBlob( + blob = window.dataURLToBlobSync( canvas.toDataURL('image/jpeg', quality) ); quality = quality * maxSize / blob.size; + // NOTE: During testing with a large image, we observed the + // `quality` value being > 1. Should we clamp it to [0.5, 1.0]? + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax if (quality < 0.5) { quality = 0.5; } @@ -132,8 +137,14 @@ case 'audio': this.addThumb('images/audio.svg'); break; case 'video': this.addThumb('images/video.svg'); break; case 'image': - this.oUrl = URL.createObjectURL(file); - this.addThumb(this.oUrl); + if (!MIME.isJPEG(file.type)) { + this.previewObjectUrl = URL.createObjectURL(file); + this.addThumb(this.previewObjectUrl); + break; + } + + window.autoOrientImage(file) + .then(dataURL => this.addThumb(dataURL)); break; default: this.addThumb('images/file.svg'); break; @@ -177,30 +188,38 @@ return files && files.length && files.length > 0; }, - getFiles: function() { - var promises = []; - var files = this.file ? [this.file] : this.$input.prop('files'); - for (var i = 0; i < files.length; i++) { - promises.push(this.getFile(files[i])); - } - this.clearForm(); - return Promise.all(promises); - }, + /* eslint-enable */ + /* jshint ignore:start */ + getFiles() { + const files = this.file ? [this.file] : Array.from(this.$input.prop('files')); + const promise = Promise.all(files.map(file => this.getFile(file))); + this.clearForm(); + return promise; + }, - getFile: function(file) { - file = file || this.file || this.$input.prop('files')[0]; - if (file === undefined) { return Promise.resolve(); } - var flags; - if (this.isVoiceNote) { - flags = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE; - } - return this.autoScale(file).then(this.readFile).then(function(attachment) { - if (flags) { - attachment.flags = flags; - } - return attachment; - }.bind(this)); - }, + getFile(rawFile) { + const file = rawFile || this.file || this.$input.prop('files')[0]; + if (file === undefined) { + return Promise.resolve(); + } + const attachmentFlags = this.isVoiceNote + ? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE + : null; + + const setFlags = flags => (attachment) => { + const newAttachment = Object.assign({}, attachment); + if (flags) { + newAttachment.flags = flags; + } + return newAttachment; + }; + + return this.autoScale(file) + .then(this.readFile) + .then(setFlags(attachmentFlags)); + }, + /* jshint ignore:end */ + /* eslint-disable */ getThumbnail: function() { // Scale and crop an image to 256px square @@ -228,8 +247,7 @@ crop: true, minWidth: size, minHeight: size }); - // dataURLtoBlob -> components/blueimp-canvas-to-blob - var blob = dataURLtoBlob(canvas.toDataURL('image/png')); + var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png')); resolve(blob); }; @@ -237,6 +255,7 @@ }).then(this.readFile); }, + // File -> Promise Attachment readFile: function(file) { return new Promise(function(resolve, reject) { var FR = new FileReader(); @@ -255,10 +274,11 @@ }, clearForm: function() { - if (this.oUrl) { - URL.revokeObjectURL(this.oUrl); - this.oUrl = null; + if (this.previewObjectUrl) { + URL.revokeObjectURL(this.previewObjectUrl); + this.previewObjectUrl = null; } + this.thumb.remove(); this.$('.avatar').show(); this.$el.trigger('force-resize'); diff --git a/libtextsecure/test/contacts_parser_test.js b/libtextsecure/test/contacts_parser_test.js index 22261dafc7..fa4e25264a 100644 --- a/libtextsecure/test/contacts_parser_test.js +++ b/libtextsecure/test/contacts_parser_test.js @@ -17,7 +17,7 @@ describe("ContactBuffer", function() { var contactInfo = new textsecure.protobuf.ContactDetails({ name: "Zero Cool", number: "+10000000000", - avatar: { contentType: "image/jpg", length: avatarLen } + avatar: { contentType: "image/jpeg", length: avatarLen } }); var contactInfoBuffer = contactInfo.encode().toArrayBuffer(); @@ -41,7 +41,7 @@ describe("ContactBuffer", function() { count++; assert.strictEqual(contact.name, "Zero Cool"); assert.strictEqual(contact.number, "+10000000000"); - assert.strictEqual(contact.avatar.contentType, "image/jpg"); + assert.strictEqual(contact.avatar.contentType, "image/jpeg"); assert.strictEqual(contact.avatar.length, 255); assert.strictEqual(contact.avatar.data.byteLength, 255); var avatarBytes = new Uint8Array(contact.avatar.data); @@ -68,7 +68,7 @@ describe("GroupBuffer", function() { id: new Uint8Array([1, 3, 3, 7]).buffer, name: "Hackers", members: ['cereal', 'burn', 'phreak', 'joey'], - avatar: { contentType: "image/jpg", length: avatarLen } + avatar: { contentType: "image/jpeg", length: avatarLen } }); var groupInfoBuffer = groupInfo.encode().toArrayBuffer(); @@ -93,7 +93,7 @@ describe("GroupBuffer", function() { assert.strictEqual(group.name, "Hackers"); assertEqualArrayBuffers(group.id.toArrayBuffer(), new Uint8Array([1,3,3,7]).buffer); assert.sameMembers(group.members, ['cereal', 'burn', 'phreak', 'joey']); - assert.strictEqual(group.avatar.contentType, "image/jpg"); + assert.strictEqual(group.avatar.contentType, "image/jpeg"); assert.strictEqual(group.avatar.length, 255); assert.strictEqual(group.avatar.data.byteLength, 255); var avatarBytes = new Uint8Array(group.avatar.data); diff --git a/main.js b/main.js index 6bb2c400e7..584c92d1b0 100644 --- a/main.js +++ b/main.js @@ -157,10 +157,10 @@ function isVisible(window, bounds) { const topClearOfUpperBound = window.y >= boundsY; const topClearOfLowerBound = (window.y <= (boundsY + boundsHeight) - BOUNDS_BUFFER); - return rightSideClearOfLeftBound - && leftSideClearOfRightBound - && topClearOfUpperBound - && topClearOfLowerBound; + return rightSideClearOfLeftBound && + leftSideClearOfRightBound && + topClearOfUpperBound && + topClearOfLowerBound; } function createWindow() { @@ -277,8 +277,8 @@ function createWindow() { // Emitted when the window is about to be closed. mainWindow.on('close', (e) => { // If the application is terminating, just do the default - if (windowState.shouldQuit() - || config.environment === 'test' || config.environment === 'test-lib') { + if (windowState.shouldQuit() || + config.environment === 'test' || config.environment === 'test-lib') { return; } @@ -422,9 +422,9 @@ app.on('before-quit', () => { app.on('window-all-closed', () => { // On OS X it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q - if (process.platform !== 'darwin' - || config.environment === 'test' - || config.environment === 'test-lib') { + if (process.platform !== 'darwin' || + config.environment === 'test' || + config.environment === 'test-lib') { app.quit(); } }); diff --git a/package.json b/package.json index a6f4de219e..0ae6567a79 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "main": "main.js", "scripts": { "postinstall": "electron-builder install-app-deps && rimraf node_modules/dtrace-provider", - "test": "npm run eslint && npm run test-server && grunt test", + "test": "npm run eslint && npm run test-server && grunt test && npm run test-modules", "lint": "grunt jshint", "start": "electron .", "asarl": "asar l release/mac/Signal.app/Contents/Resources/app.asar", @@ -28,6 +28,7 @@ "build-release": "SIGNAL_ENV=production npm run build -- --config.directories.output=release", "build-mas-release": "npm run build-release -- -m --config.mac.target=mas", "build-mas-dev": "npm run build-release -- -m --config.mac.target=mas --config.type=development", + "grunt": "grunt", "prep-mac-release": "npm run build-release -- -m --dir", "prep-release": "npm run generate && grunt prep-release && npm run build-release && npm run build-mas-release && grunt test-release", "release-mac": "npm run build-release -- -m --prepackaged release/mac/Signal*.app --publish=always", @@ -36,10 +37,14 @@ "release": "npm run release-mac && npm run release-win && npm run release-lin", "test-server": "mocha --recursive test/server", "test-server-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/server", + "test-modules": "mocha --recursive test/modules", "eslint": "eslint .", "open-coverage": "open coverage/lcov-report/index.html" }, "dependencies": { + "blob-util": "^1.3.0", + "blueimp-canvas-to-blob": "^3.14.0", + "blueimp-load-image": "^2.18.0", "bunyan": "^1.8.12", "config": "^1.28.1", "electron-config": "^1.0.0", diff --git a/preload.js b/preload.js index 03423c3894..ed616e9133 100644 --- a/preload.js +++ b/preload.js @@ -60,6 +60,8 @@ window.nodeSetImmediate(function() {}); }, 1000); + window.dataURLToBlobSync = require('blueimp-canvas-to-blob'); + window.loadImage = require('blueimp-load-image'); window.ProxyAgent = require('proxy-agent'); window.EmojiConvertor = require('emoji-js'); window.emojiData = require('emoji-datasource'); @@ -70,6 +72,16 @@ window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; window.nodeNotifier = require('node-notifier'); + const { autoOrientImage } = require('./js/modules/auto_orient_image'); + window.autoOrientImage = autoOrientImage; + + // ES2015+ modules + window.Signal = window.Signal || {}; + window.Signal.Types = window.Signal.Types || {}; + window.Signal.Types.Attachment = require('./js/modules/types/attachment'); + window.Signal.Types.Message = require('./js/modules/types/message'); + window.Signal.Types.MIME = require('./js/modules/types/mime'); + // We pull this in last, because the native module involved appears to be sensitive to // /tmp mounted as noexec on Linux. require('./js/spell_check'); diff --git a/test/modules/types/mime_test.js b/test/modules/types/mime_test.js new file mode 100644 index 0000000000..b56f831c46 --- /dev/null +++ b/test/modules/types/mime_test.js @@ -0,0 +1,30 @@ +const { assert } = require('chai'); + +const MIME = require('../../../js/modules/types/mime'); + + +describe('MIME', () => { + describe('isJPEG', () => { + it('should return true for `image/jpeg`', () => { + assert.isTrue(MIME.isJPEG('image/jpeg')); + }); + + [ + 'jpg', + 'jpeg', + 'image/jpg', // invalid MIME type: https://stackoverflow.com/a/37266399/125305 + 'image/gif', + 'image/tiff', + 'application/json', + 0, + false, + null, + undefined, + ] + .forEach((value) => { + it(`should return false for \`${value}\``, () => { + assert.isFalse(MIME.isJPEG(value)); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 9a17142f83..b4d5e145e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -437,6 +437,17 @@ bl@^1.0.0: dependencies: readable-stream "^2.0.5" +blob-util@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-1.3.0.tgz#dbb4e8caffd50b5720d347e1169b6369ba34fe95" + dependencies: + blob "0.0.4" + native-or-lie "1.0.2" + +blob@0.0.4: + version "0.0.4" + resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" + block-stream@*: version "0.0.9" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -457,6 +468,14 @@ bluebird@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" +blueimp-canvas-to-blob@^3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.14.0.tgz#ea075ffbfb1436607b0c75e951fb1ceb3ca0288e" + +blueimp-load-image@^2.18.0: + version "2.18.0" + resolved "https://registry.yarnpkg.com/blueimp-load-image/-/blueimp-load-image-2.18.0.tgz#03b93687eb382a7136cfbcbd4f0e936b6763fc0e" + bmp-js@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/bmp-js/-/bmp-js-0.0.1.tgz#5ad0147099d13a9f38aa7b99af1d6e78666ed37f" @@ -2606,6 +2625,10 @@ ignore@^3.3.3: version "3.3.7" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021" +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + import-lazy@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" @@ -3150,6 +3173,12 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lie@*: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.2.0.tgz#4f13f2f8bbb027d383db338c43043545791aa8dc" + dependencies: + immediate "~3.0.5" + livereload-js@^2.2.0: version "2.2.2" resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.2.2.tgz#6c87257e648ab475bc24ea257457edcc1f8d0bc2" @@ -3490,6 +3519,12 @@ nan@^2.0.0, nan@^2.3.2, nan@^2.3.3: version "2.6.2" resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45" +native-or-lie@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/native-or-lie/-/native-or-lie-1.0.2.tgz#c870ee0ba0bf0ff11350595d216cfea68a6d8086" + dependencies: + lie "*" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" From 06a16baaa56af51e27d5931b5716f4a82bf5bf6a Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 21 Feb 2018 14:18:04 -0800 Subject: [PATCH 14/19] Protect against null mainWindow in captureAndSaveWindowStats (#2061) --- main.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/main.js b/main.js index 584c92d1b0..e74a3e8c92 100644 --- a/main.js +++ b/main.js @@ -216,6 +216,10 @@ function createWindow() { mainWindow = new BrowserWindow(windowOptions); function captureAndSaveWindowStats() { + if (!mainWindow) { + return; + } + const size = mainWindow.getSize(); const position = mainWindow.getPosition(); From a1ac8103436362d5bcfe6a1d2b6991af46a9478c Mon Sep 17 00:00:00 2001 From: Daniel Gasienica Date: Thu, 22 Feb 2018 13:21:53 -0500 Subject: [PATCH 15/19] Security: Replace Unicode order overrides in attachment names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As a user, when I receive a file attachment, I want to have confidence that the filename I see in the Signal Desktop app is the same as it will be on disk. To prevent user confusion when receiving files with Unicode order override characters, e.g. `testfig.exe` appearing as `testexe.gif`, we replace all occurrences of order overrides (`U+202D` and `U+202E`) with `U+FFFD`. **Changes** - [x] Bump `Attachment` `schemaVersion` to 2. - [x] Replace all Unicode order overrides in `attachment.filename`: `Attachment.replaceUnicodeOrderOverrides`. - [x] Add tests for existing `Attachment.upgradeSchema` - [x] Add tests for existing `Attachment.withSchemaVersion` - [x] Add tests for `Attachment.replaceUnicodeOrderOverrides` positives. - [x] Add `testcheck` generative property-based testing library (based on QuickCheck) to ensure valid filenames are preserved. --- commit 855bdbc7e647e44f73b9e1f5e6d64f734c61169a Author: Daniel Gasienica Date: Thu Feb 22 13:02:01 2018 -0500 Log error stack in case of error commit 6e053ed66aee136f186568fa88aacd4814b2ab07 Author: Daniel Gasienica Date: Thu Feb 22 12:30:28 2018 -0500 Improve `upgradeStep` error handling commit 8c226a2523b701cb578b2137832c3eaf3475bb2b Author: Daniel Gasienica Date: Thu Feb 22 12:30:08 2018 -0500 Check for expected version before upgrade Prevents out of order upgrade steps. commit 28b0675591e782169128f75429b7bab2a22307fa Author: Daniel Gasienica Date: Thu Feb 22 12:29:52 2018 -0500 Reject invalid attachments commit 41f4f457dae9416dae66dc2fa2079483d1f127a9 Author: Daniel Gasienica Date: Thu Feb 22 12:29:36 2018 -0500 Fix upgrade pipeline order commit 3935629e91c49b8d96c1e02bd37b1b31d1180720 Author: Daniel Gasienica Date: Thu Feb 22 12:28:25 2018 -0500 Avoid `_.isPlainObject` Attachments are deserialized from a protocol buffer and can have a non-plain-object constructor. commit 39f6e7f622ff4885e2ccafa354e0edb5864c55d8 Author: Daniel Gasienica Date: Thu Feb 22 12:19:07 2018 -0500 Define basic attachment validity commit adcf7e3243cd90866cc35990c558ff7829019037 Author: Daniel Gasienica Date: Thu Feb 22 12:18:54 2018 -0500 Add tests for attachment upgrade pipeline commit 82fc4644d7e654eea9f348518b086497be2b0cb4 Author: Daniel Gasienica Date: Wed Feb 21 12:20:24 2018 -0500 Favor `async` / `await` over `then` commit 8fe49e3c40e78ced0b8f2eb0b678f4bae842855d Author: Daniel Gasienica Date: Wed Feb 21 12:19:59 2018 -0500 Add `eslint-more` plugin This will enable us to disallow `then` in favor of `async` / `await`. commit 020beefb25f508ae96cf3fc099599fbbca98802b Author: Daniel Gasienica Date: Wed Feb 21 11:31:49 2018 -0500 Remove unnecessary `async` modifiers commit 177090c5f5ad9836f0ca0a5c2f298779519e3692 Author: Daniel Gasienica Date: Wed Feb 21 11:30:55 2018 -0500 Document `operator-linebreak` ESLint rule commit 25622b7c59291cb672ae057c47e7327a564cca40 Author: Daniel Gasienica Date: Wed Feb 21 11:14:15 2018 -0500 Prefix internal function with `_` commit 6aa3cf5098df71e9b710064739ec49d74f81b7bf Author: Daniel Gasienica Date: Fri Feb 16 19:00:07 2018 -0500 Replace all Unicode order override occurrences commit fd6e23b0a519bce3c12c5b9ac676bcd198034fed Author: Daniel Gasienica Date: Fri Feb 16 17:48:41 2018 -0500 Whitelist `testcheck` `check` and `gen` globals commit 400bae9fac5078821813bc0ca17a5d7a72900161 Author: Daniel Gasienica Date: Fri Feb 16 17:46:57 2018 -0500 :art: Fix lint errors commit da53d3960aa7aa36b7cc1fcff414c9e929c0d9fc Author: Daniel Gasienica Date: Fri Feb 16 17:42:42 2018 -0500 Add tests for `Attachment.withSchemaVersion` commit ec203444239d9e3c443ba88cab7ef4672151072d Author: Daniel Gasienica Date: Fri Feb 16 17:42:17 2018 -0500 Add test for `Attachment.upgradeSchema` commit 4540d5bdf7a4279f49d2e4c6ee03f47b93df46bf Author: Daniel Gasienica Date: Fri Feb 16 17:05:29 2018 -0500 Rename `setSchemaVersion` --> `withSchemaVersion` Put the schema version first for better readability. commit e379cf919feda31d1fa96d406c30fd38e159a11d Author: Daniel Gasienica Date: Fri Feb 16 17:03:22 2018 -0500 Add filename sanitization to upgrade pipeline commit 1e344a0d15926fc3e17be20cd90bfa882b65f337 Author: Daniel Gasienica Date: Fri Feb 16 17:01:55 2018 -0500 Test that we preserve non-suspicious filenames commit a2452bfc98f93f82bed48b438757af2e66a6af82 Author: Daniel Gasienica Date: Fri Feb 16 17:00:56 2018 -0500 Add `testcheck` dependency Allows for generative property-based testing similar to Haskell’s QuickCheck. See: https://medium.com/javascript-inside/f91432247c27 commit ceb5bfd2484a77689fdb8e9edd18d4a7b093a486 Author: Daniel Gasienica Date: Fri Feb 16 16:15:33 2018 -0500 Replace Unicode order override characters Prevents users from being tricked into clicking a file named `testexe.fig` that appears as `testexe.gif` due to a Unicode order override character. See: - http://unicode.org/reports/tr36/#Bidirectional_Text_Spoofing - https://krebsonsecurity.com/2011/09/right-to-left-override-aids-email-attacks/ commit bc605afb1c6af3a5ebc31a4c1523ff170eb96ffe Author: Daniel Gasienica Date: Fri Feb 16 16:12:29 2018 -0500 Remove `CURRENT_PROCESS_VERSION` Reintroduce this whenever we need it. We currently only deal with schema version numbers within this module. --- .eslintrc.js | 8 + app/logging.js | 3 + js/modules/types/attachment.js | 141 ++++++++++++--- js/views/file_input_view.js | 9 + main.js | 3 + package.json | 3 + test/modules/.eslintrc | 6 + test/modules/types/attachment_test.js | 246 ++++++++++++++++++++++++++ test/server/app/logging_test.js | 3 + yarn.lock | 14 ++ 10 files changed, 409 insertions(+), 27 deletions(-) create mode 100644 test/modules/.eslintrc create mode 100644 test/modules/types/attachment_test.js diff --git a/.eslintrc.js b/.eslintrc.js index 55d953c9a0..124289e01e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,6 +11,10 @@ module.exports = { 'airbnb-base', ], + plugins: [ + 'more', + ], + rules: { 'comma-dangle': ['error', { arrays: 'always-multiline', @@ -29,6 +33,9 @@ module.exports = { ignoreUrls: true, }], + // encourage consistent use of `async` / `await` instead of `then` + 'more/no-then': 'error', + // it helps readability to put public API at top, 'no-use-before-define': 'off', @@ -38,6 +45,7 @@ module.exports = { // though we have a logger, we still remap console to log to disk 'no-console': 'off', + // consistently place operators at end of line except ternaries 'operator-linebreak': 'error', } }; diff --git a/app/logging.js b/app/logging.js index 52bf2cc04f..1fa10e0ea8 100644 --- a/app/logging.js +++ b/app/logging.js @@ -1,3 +1,6 @@ +// NOTE: Temporarily allow `then` until we convert the entire file to `async` / `await`: +/* eslint-disable more/no-then */ + const path = require('path'); const fs = require('fs'); diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 048f4f368c..11c313b865 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -1,18 +1,25 @@ +const isFunction = require('lodash/isFunction'); +const isNumber = require('lodash/isNumber'); +const isString = require('lodash/isString'); +const isUndefined = require('lodash/isUndefined'); + const MIME = require('./mime'); const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util'); const { autoOrientImage } = require('../auto_orient_image'); -// Increment this everytime we change how attachments are upgraded. This allows us to -// retroactively upgrade existing attachments. As we add more upgrade steps, we could -// design a pipeline that does this incrementally, e.g. from version 0 (unknown) -> 1, -// 1 --> 2, etc., similar to how we do database migrations: -const CURRENT_PROCESS_VERSION = 1; +// Increment this version number every time we change how attachments are upgraded. This +// will allow us to retroactively upgrade existing attachments. As we add more upgrade +// steps, we could design a pipeline that does this incrementally, e.g. from +// version 0 / unknown -> 1, 1 --> 2, etc., similar to how we do database migrations: +exports.CURRENT_SCHEMA_VERSION = 2; // Schema version history // // Version 1 // - Auto-orient JPEG attachments using EXIF `Orientation` data // - Add `schemaVersion` property +// Version 2 +// - Sanitize Unicode order override characters // // Incoming message attachment fields // { @@ -37,34 +44,81 @@ const CURRENT_PROCESS_VERSION = 1; // schemaVersion: integer // } +// Returns true if `rawAttachment` is a valid attachment based on our (limited) +// criteria. Over time, we can expand this definition to become more narrow: +exports.isValid = (rawAttachment) => { + // NOTE: We cannot use `_.isPlainObject` because `rawAttachment` is + // deserialized by protobuf: + if (!rawAttachment) { + return false; + } + + return isString(rawAttachment.contentType) && + isString(rawAttachment.fileName); +}; + // Middleware // type UpgradeStep = Attachment -> Promise Attachment -// UpgradeStep -> SchemaVersion -> UpgradeStep -const setSchemaVersion = (next, schemaVersion) => async (attachment) => { - const isAlreadyUpgraded = attachment.schemaVersion >= schemaVersion; - if (isAlreadyUpgraded) { - return attachment; +// SchemaVersion -> UpgradeStep -> UpgradeStep +exports.withSchemaVersion = (schemaVersion, upgrade) => { + if (!isNumber(schemaVersion)) { + throw new TypeError('`schemaVersion` must be a number'); + } + if (!isFunction(upgrade)) { + throw new TypeError('`upgrade` must be a function'); } - let upgradedAttachment; - try { - upgradedAttachment = await next(attachment); - } catch (error) { - console.error('Attachment.setSchemaVersion: error:', error); - upgradedAttachment = null; - } + return async (attachment) => { + if (!exports.isValid(attachment)) { + console.log('Attachment.withSchemaVersion: Invalid input attachment:', attachment); + return attachment; + } - const hasSuccessfullyUpgraded = upgradedAttachment !== null; - if (!hasSuccessfullyUpgraded) { - return attachment; - } + const isAlreadyUpgraded = attachment.schemaVersion >= schemaVersion; + if (isAlreadyUpgraded) { + return attachment; + } - return Object.assign( - {}, - upgradedAttachment, - { schemaVersion } - ); + const expectedVersion = schemaVersion - 1; + const isUnversioned = isUndefined(attachment.schemaVersion); + const hasExpectedVersion = isUnversioned || + attachment.schemaVersion === expectedVersion; + if (!hasExpectedVersion) { + console.log( + 'WARNING: Attachment.withSchemaVersion: Unexpected version:' + + ` Expected attachment to have version ${expectedVersion},` + + ` but got ${attachment.schemaVersion}.`, + attachment + ); + return attachment; + } + + let upgradedAttachment; + try { + upgradedAttachment = await upgrade(attachment); + } catch (error) { + console.log( + 'Attachment.withSchemaVersion: error:', + error && error.stack ? error.stack : error + ); + return attachment; + } + + if (!exports.isValid(upgradedAttachment)) { + console.log( + 'Attachment.withSchemaVersion: Invalid upgraded attachment:', + upgradedAttachment + ); + return attachment; + } + + return Object.assign( + {}, + upgradedAttachment, + { schemaVersion } + ); + }; }; // Upgrade steps @@ -93,6 +147,39 @@ const autoOrientJPEG = async (attachment) => { return newAttachment; }; +const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D'; +const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E'; +const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD'; +const INVALID_CHARACTERS_PATTERN = new RegExp( + `[${UNICODE_LEFT_TO_RIGHT_OVERRIDE}${UNICODE_RIGHT_TO_LEFT_OVERRIDE}]`, + 'g' +); +// NOTE: Expose synchronous version to do property-based testing using `testcheck`, +// which currently doesn’t support async testing: +// https://github.com/leebyron/testcheck-js/issues/45 +exports._replaceUnicodeOrderOverridesSync = (attachment) => { + if (!isString(attachment.fileName)) { + return attachment; + } + + const normalizedFilename = attachment.fileName.replace( + INVALID_CHARACTERS_PATTERN, + UNICODE_REPLACEMENT_CHARACTER + ); + const newAttachment = Object.assign({}, attachment, { + fileName: normalizedFilename, + }); + + return newAttachment; +}; + +exports.replaceUnicodeOrderOverrides = async attachment => + exports._replaceUnicodeOrderOverridesSync(attachment); + // Public API +const toVersion1 = exports.withSchemaVersion(1, autoOrientJPEG); +const toVersion2 = exports.withSchemaVersion(2, exports.replaceUnicodeOrderOverrides); + // UpgradeStep -exports.upgradeSchema = setSchemaVersion(autoOrientJPEG, CURRENT_PROCESS_VERSION); +exports.upgradeSchema = async attachment => + toVersion2(await toVersion1(attachment)); diff --git a/js/views/file_input_view.js b/js/views/file_input_view.js index 943771af14..9e5eec9218 100644 --- a/js/views/file_input_view.js +++ b/js/views/file_input_view.js @@ -143,6 +143,9 @@ break; } + // NOTE: Temporarily allow `then` until we convert the entire file + // to `async` / `await`: + // eslint-disable-next-line more/no-then window.autoOrientImage(file) .then(dataURL => this.addThumb(dataURL)); break; @@ -150,6 +153,9 @@ this.addThumb('images/file.svg'); break; } + // NOTE: Temporarily allow `then` until we convert the entire file + // to `async` / `await`: + // eslint-disable-next-line more/no-then this.autoScale(file).then(function(blob) { var limitKb = 1000000; var blobType = file.type === 'image/gif' ? 'gif' : type; @@ -214,6 +220,9 @@ return newAttachment; }; + // NOTE: Temporarily allow `then` until we convert the entire file + // to `async` / `await`: + // eslint-disable-next-line more/no-then return this.autoScale(file) .then(this.readFile) .then(setFlags(attachmentFlags)); diff --git a/main.js b/main.js index e74a3e8c92..597c8eac78 100644 --- a/main.js +++ b/main.js @@ -377,6 +377,8 @@ function showAbout() { // Some APIs can only be used after this event occurs. let ready = false; app.on('ready', () => { + // NOTE: Temporarily allow `then` until we convert the entire file to `async` / `await`: + /* eslint-disable more/no-then */ let loggingSetupError; logging.initialize().catch((error) => { loggingSetupError = error; @@ -416,6 +418,7 @@ app.on('ready', () => { const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu); }); + /* eslint-enable more/no-then */ }); app.on('before-quit', () => { diff --git a/package.json b/package.json index 0ae6567a79..d68ae4889f 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "rimraf": "^2.6.2", "semver": "^5.4.1", "spellchecker": "^3.4.4", + "testcheck": "^1.0.0-rc.2", "websocket": "^1.0.25" }, "devDependencies": { @@ -80,6 +81,7 @@ "eslint": "^4.14.0", "eslint-config-airbnb-base": "^12.1.0", "eslint-plugin-import": "^2.8.0", + "eslint-plugin-more": "^0.3.1", "extract-zip": "^1.6.6", "grunt": "^1.0.1", "grunt-cli": "^1.2.0", @@ -92,6 +94,7 @@ "grunt-jscs": "^3.0.1", "grunt-sass": "^2.0.0", "mocha": "^4.1.0", + "mocha-testcheck": "^1.0.0-rc.0", "node-sass-import-once": "^1.2.0", "nyc": "^11.4.1", "spectron": "^3.7.2", diff --git a/test/modules/.eslintrc b/test/modules/.eslintrc new file mode 100644 index 0000000000..4cb311c548 --- /dev/null +++ b/test/modules/.eslintrc @@ -0,0 +1,6 @@ +{ + "globals": { + "check": true, + "gen": true + } +} diff --git a/test/modules/types/attachment_test.js b/test/modules/types/attachment_test.js new file mode 100644 index 0000000000..4de9d85269 --- /dev/null +++ b/test/modules/types/attachment_test.js @@ -0,0 +1,246 @@ +require('mocha-testcheck').install(); + +const { assert } = require('chai'); + +const Attachment = require('../../../js/modules/types/attachment'); + +describe('Attachment', () => { + describe('upgradeSchema', () => { + it('should upgrade an unversioned attachment to the latest version', async () => { + const input = { + contentType: 'application/json', + data: null, + fileName: 'test\u202Dfig.exe', + size: 1111, + }; + const expected = { + contentType: 'application/json', + data: null, + fileName: 'test\uFFFDfig.exe', + size: 1111, + schemaVersion: Attachment.CURRENT_SCHEMA_VERSION, + }; + + const actual = await Attachment.upgradeSchema(input); + assert.deepEqual(actual, expected); + }); + + context('with multiple upgrade steps', () => { + it('should return last valid attachment when any upgrade step fails', async () => { + const input = { + contentType: 'application/json', + data: null, + fileName: 'test\u202Dfig.exe', + size: 1111, + }; + const expected = { + contentType: 'application/json', + data: null, + fileName: 'test\u202Dfig.exe', + size: 1111, + schemaVersion: 1, + hasUpgradedToVersion1: true, + }; + + const v1 = async attachment => + Object.assign({}, attachment, { hasUpgradedToVersion1: true }); + const v2 = async () => { + throw new Error('boom'); + }; + const v3 = async attachment => + Object.assign({}, attachment, { hasUpgradedToVersion3: true }); + + const toVersion1 = Attachment.withSchemaVersion(1, v1); + const toVersion2 = Attachment.withSchemaVersion(2, v2); + const toVersion3 = Attachment.withSchemaVersion(3, v3); + + const upgradeSchema = async attachment => + toVersion3(await toVersion2(await toVersion1(attachment))); + + const actual = await upgradeSchema(input); + assert.deepEqual(actual, expected); + }); + + it('should skip out-of-order upgrade steps', async () => { + const input = { + contentType: 'application/json', + data: null, + fileName: 'test\u202Dfig.exe', + size: 1111, + }; + const expected = { + contentType: 'application/json', + data: null, + fileName: 'test\u202Dfig.exe', + size: 1111, + schemaVersion: 2, + hasUpgradedToVersion1: true, + hasUpgradedToVersion2: true, + }; + + const v1 = async attachment => + Object.assign({}, attachment, { hasUpgradedToVersion1: true }); + const v2 = async attachment => + Object.assign({}, attachment, { hasUpgradedToVersion2: true }); + const v3 = async attachment => + Object.assign({}, attachment, { hasUpgradedToVersion3: true }); + + const toVersion1 = Attachment.withSchemaVersion(1, v1); + const toVersion2 = Attachment.withSchemaVersion(2, v2); + const toVersion3 = Attachment.withSchemaVersion(3, v3); + + // NOTE: We upgrade to 3 before 2, i.e. the pipeline should abort: + const upgradeSchema = async attachment => + toVersion2(await toVersion3(await toVersion1(attachment))); + + const actual = await upgradeSchema(input); + assert.deepEqual(actual, expected); + }); + }); + }); + + describe('withSchemaVersion', () => { + it('should require a version number', () => { + const toVersionX = () => {}; + assert.throws( + () => Attachment.withSchemaVersion(toVersionX, 2), + '`schemaVersion` must be a number' + ); + }); + + it('should require an upgrade function', () => { + assert.throws( + () => Attachment.withSchemaVersion(2, 3), + '`upgrade` must be a function' + ); + }); + + it('should skip upgrading if attachment has already been upgraded', async () => { + const upgrade = async attachment => + Object.assign({}, attachment, { foo: true }); + const upgradeWithVersion = Attachment.withSchemaVersion(3, upgrade); + + const input = { + contentType: 'image/gif', + data: null, + fileName: 'foo.gif', + size: 1111, + schemaVersion: 4, + }; + const actual = await upgradeWithVersion(input); + assert.deepEqual(actual, input); + }); + + it('should return original attachment if upgrade function throws', async () => { + const upgrade = async () => { + throw new Error('boom!'); + }; + const upgradeWithVersion = Attachment.withSchemaVersion(3, upgrade); + + const input = { + contentType: 'image/gif', + data: null, + fileName: 'foo.gif', + size: 1111, + }; + const actual = await upgradeWithVersion(input); + assert.deepEqual(actual, input); + }); + + it('should return original attachment if upgrade function returns null', async () => { + const upgrade = async () => null; + const upgradeWithVersion = Attachment.withSchemaVersion(3, upgrade); + + const input = { + contentType: 'image/gif', + data: null, + fileName: 'foo.gif', + size: 1111, + }; + const actual = await upgradeWithVersion(input); + assert.deepEqual(actual, input); + }); + }); + + describe('replaceUnicodeOrderOverrides', () => { + it('should sanitize left-to-right order override character', async () => { + const input = { + contentType: 'image/jpeg', + data: null, + fileName: 'test\u202Dfig.exe', + size: 1111, + schemaVersion: 1, + }; + const expected = { + contentType: 'image/jpeg', + data: null, + fileName: 'test\uFFFDfig.exe', + size: 1111, + schemaVersion: 1, + }; + + const actual = await Attachment.replaceUnicodeOrderOverrides(input); + assert.deepEqual(actual, expected); + }); + + it('should sanitize right-to-left order override character', async () => { + const input = { + contentType: 'image/jpeg', + data: null, + fileName: 'test\u202Efig.exe', + size: 1111, + schemaVersion: 1, + }; + const expected = { + contentType: 'image/jpeg', + data: null, + fileName: 'test\uFFFDfig.exe', + size: 1111, + schemaVersion: 1, + }; + + const actual = await Attachment.replaceUnicodeOrderOverrides(input); + assert.deepEqual(actual, expected); + }); + + it('should sanitize multiple override characters', async () => { + const input = { + contentType: 'image/jpeg', + data: null, + fileName: 'test\u202e\u202dlol\u202efig.exe', + size: 1111, + schemaVersion: 1, + }; + const expected = { + contentType: 'image/jpeg', + data: null, + fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe', + size: 1111, + schemaVersion: 1, + }; + + const actual = await Attachment.replaceUnicodeOrderOverrides(input); + assert.deepEqual(actual, expected); + }); + + const hasNoUnicodeOrderOverrides = value => + !value.includes('\u202D') && !value.includes('\u202E'); + + check.it( + 'should ignore non-order-override characters', + gen.string.suchThat(hasNoUnicodeOrderOverrides), + (fileName) => { + const input = { + contentType: 'image/jpeg', + data: null, + fileName, + size: 1111, + schemaVersion: 1, + }; + + const actual = Attachment._replaceUnicodeOrderOverridesSync(input); + assert.deepEqual(actual, input); + } + ); + }); +}); diff --git a/test/server/app/logging_test.js b/test/server/app/logging_test.js index 865232bfc1..04f73e1d14 100644 --- a/test/server/app/logging_test.js +++ b/test/server/app/logging_test.js @@ -1,3 +1,6 @@ +// NOTE: Temporarily allow `then` until we convert the entire file to `async` / `await`: +/* eslint-disable more/no-then */ + const fs = require('fs'); const path = require('path'); diff --git a/yarn.lock b/yarn.lock index b4d5e145e2..9aaa198d07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1661,6 +1661,10 @@ eslint-plugin-import@^2.8.0: minimatch "^3.0.3" read-pkg-up "^2.0.0" +eslint-plugin-more@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-more/-/eslint-plugin-more-0.3.1.tgz#ff688fb3fa8f153c8bfd5d70c15a68dc222a1b31" + eslint-restricted-globals@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/eslint-restricted-globals/-/eslint-restricted-globals-0.1.1.tgz#35f0d5cbc64c2e3ed62e93b4b1a7af05ba7ed4d7" @@ -3468,6 +3472,12 @@ mksnapshot@^0.3.0: fs-extra "0.26.7" request "^2.79.0" +mocha-testcheck@^1.0.0-rc.0: + version "1.0.0-rc.0" + resolved "https://registry.yarnpkg.com/mocha-testcheck/-/mocha-testcheck-1.0.0-rc.0.tgz#05e50203043be1537aef2a87dd96ccd447702773" + dependencies: + testcheck "^1.0.0-rc" + mocha@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/mocha/-/mocha-4.1.0.tgz#7d86cfbcf35cb829e2754c32e17355ec05338794" @@ -4982,6 +4992,10 @@ test-exclude@^4.1.1: read-pkg-up "^1.0.1" require-main-filename "^1.0.1" +testcheck@^1.0.0-rc, testcheck@^1.0.0-rc.2: + version "1.0.0-rc.2" + resolved "https://registry.yarnpkg.com/testcheck/-/testcheck-1.0.0-rc.2.tgz#11356a25b84575efe0b0857451e85b5fa74ee4e4" + text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" From 426dab85a268b0e8fc970f8ad9f7e6344536b2c0 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 22 Feb 2018 10:40:32 -0800 Subject: [PATCH 16/19] New design for import/install, 'light' import (#2053) - A new design for the import flow. It features: - Icons at the top of every screen - Gray background, blue buttons, thinner text - Simpler copy - A new design for the install flow. It features: - Immediate entry into the QR code screen - Animated dots to show that we're loading the QR code from the server - Fewer screens: 1) QR 2) device name 3) sync-in-progress - When not set up, the app opens directly into the install screen, which has been streamlined. The `--import` command-line argument will cause the app to open directly into the import flow. - Support for two different flavors of builds - the normal build will open into the standard registration flow, and the import flavor will be exactly the same except during setup it will open directly into the import flow. - A new design for the (dev-only) standalone registration view - When these install sequences are active, the OS File menu has entries to allow you to switch the method of setup you'd like to use. These go away as soon as the first step is taken in any of these flows. - The device name (chosen on initial setup) is now shown in the settings panel - At the end of a light import, we hand off to the normal device link screen, starting at the QR code. On a full import, we remove the sensitive encryption information in the export to prevent conflicts on multiple imports. - `Whisper.Backup.exportToDirectory()` takes an options object so you can tell it to do a light export. - `Whisper.Backup.importFromDirectory()` takes an options object so you can force it to load only the light components found on disk. It also returns an object so you can tell whether a given import was a full import or light import. - On start of import, we build a list of all the ids present in the messages, conversations, and groups stores in IndexedDB. This can take some time if a lot of data is in the database already, but it makes the subsequent deduplicated import very fast. - Disappearing messages are now excluded when exporting - Remove some TODOs in the tests --- .travis.yml | 2 +- _locales/en/messages.json | 189 ++++------- app/menu.js | 61 +++- appveyor.yml | 2 +- background.html | 239 ++++++++------ config/default.json | 5 +- images/alert-outline.svg | 1 + images/android.svg | 1 + images/apple.svg | 1 + images/check-circle-outline.svg | 1 + images/folder-outline.svg | 1 + images/import.svg | 1 + images/lead-pencil.svg | 1 + images/sync.svg | 1 + js/background.js | 31 +- js/backup.js | 283 ++++++++++++++--- js/views/app_view.js | 58 ++-- js/views/import_view.js | 136 +++++--- js/views/install_choice_view.js | 31 -- js/views/install_view.js | 206 ++++++------ js/views/settings_view.js | 3 + js/views/standalone_registration_view.js | 13 +- main.js | 74 ++++- package.json | 3 +- preload.js | 20 ++ prepare_build.js => prepare_beta_build.js | 2 +- prepare_import_build.js | 60 ++++ stylesheets/_global.scss | 277 +++++++++++++++- stylesheets/_settings.scss | 4 + stylesheets/options.scss | 329 -------------------- test/views/last_seen_indicator_view_test.js | 19 +- test/views/scroll_down_button_view_test.js | 8 +- 32 files changed, 1228 insertions(+), 835 deletions(-) create mode 100644 images/alert-outline.svg create mode 100644 images/android.svg create mode 100644 images/apple.svg create mode 100644 images/check-circle-outline.svg create mode 100644 images/folder-outline.svg create mode 100644 images/import.svg create mode 100644 images/lead-pencil.svg create mode 100644 images/sync.svg delete mode 100644 js/views/install_choice_view.js rename prepare_build.js => prepare_beta_build.js (96%) create mode 100644 prepare_import_build.js diff --git a/.travis.yml b/.travis.yml index d2cb9a7aac..2915dcc3bb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,7 @@ install: - yarn install script: - yarn run generate - - yarn prepare-build + - yarn prepare-beta-build - yarn eslint - yarn test-server - yarn lint diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b6ed1662b2..814fff7be5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -19,79 +19,65 @@ "message": "&Help", "description": "The label that is used for the Help menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt- combination." }, + "menuSetupWithImport": { + "message": "Set up with import", + "description": "When the application is not yet set up, menu option to start up the import sequence" + }, + "menuSetupAsNewDevice": { + "message": "Set up as new device", + "description": "When the application is not yet set up, menu option to start up the set up as fresh device" + }, + "menuSetupAsStandalone": { + "message": "Set up as standalone device", + "description": "Only available on development modes, menu option to open up the standalone device setup sequence" + }, "loading": { "message": "Loading...", "description": "Message shown on the loading screen before we've loaded any messages" }, - "exportInstructions": { - "message": "The first step is to choose a directory to store this application's exported data. It will contain your message history and sensitive cryptographic data, so be sure to save it somewhere private.", - "description": "Description of the export process" - }, "chooseDirectory": { - "message": "Choose directory", - "description": "Button to allow the user to export all data from app as part of migration process" + "message": "Choose folder", + "description": "Button to allow the user to find a folder on disk" }, - "exportButton": { - "message": "Export", - "desription": "Button shown on the choose directory dialog which starts the export process" + "loadDataHeader": { + "message": "Load your data", + "description": "Header shown on the first screen in the data import process" }, - "exportChooserTitle": { - "message": "Choose target directory for data", - "description": "Title of the popup window used to select data storage location" - }, - "exportAgain": { - "message": "Export again", - "description": "If user has already exported once, this button allows user to do it again if needed" - }, - "exportError": { - "message": "Unfortunately, something went wrong during the export. First, double-check your target empty directory for write access and enough space. Then, please submit a debug log so we can help you get migrated!", - "description": "Helper text if the user went forward on migrating the app, but ran into an error" - }, - "exporting": { - "message": "Please wait while we export your data. It may take several minutes. You can still use Signal on your phone and other devices during this time.", - "description": "Message shown on the migration screen while we export data" - }, - "exportComplete": { - "message": "Your data has been exported to:

$location$

You'll be able to import this data as you set up
the new Signal Desktop.", - "description": "Message shown on the migration screen when we are done exporting data", - "placeholders": { - "location": { - "content": "$1", - "example": "/Users/someone/somewhere" - } - } - }, - "installNewSignal": { - "message": "Install new Signal Desktop", - "description": "When export is complete, a button shows which sends user to Signal Desktop install instructions" - }, - "importButton": { - "message": "Import", - "desription": "Button shown on the choose directory dialog which starts the import process" + "loadDataDescription": { + "message": "You've just gone through the export process, and your contacts and messages are waiting patiently on your computer. Select the folder that contains your saved Signal data.", + "description": "Introduction to the process of importing messages and contacts from disk" }, "importChooserTitle": { "message": "Choose directory with exported data", "description": "Title of the popup window used to select data previously exported" }, + "importErrorHeader": { + "message": "Something went wrong!", + "description": "Header of the error screen after a failed import" + }, + "importingHeader": { + "message": "Loading contacts and messages", + "description": "Header of screen shown as data is import" + }, "importError": { - "message": "Unfortunately, something went wrong during the import.

First, double-check your target directory. It should start with 'Signal Export.'

Next, try with a new export of your data from the Chrome App.

If that still fails, please submit a debug log so we can help you get migrated!", + "message": "Make sure you have chosen the correct directory that contains your saved Signal data. Its name should begin with 'Signal Export.' You can also save a new copy of your data from the Chrome App.

If these steps don't work for you, please submit a debug log so that we can help you get migrated!

", "description": "Message shown if the import went wrong." }, - "tryAgain": { - "message": "Try again", + "importAgain": { + "message": "Choose folder and try again", "description": "Button shown if the user runs into an error during import, allowing them to start over" }, - "importInstructions": { - "message": "The first step is to tell us where you previously exported your Signal data. It will be a directory whose name starts with 'Signal Export.'

NOTE: You must only import a set of exported data once. Import makes a copy of the exported client, and duplicate clients interfere with each other.", - "description": "Description of the export process" + "importCompleteHeader": { + "message": "Success!", + "description": "Header shown on the screen at the end of a successful import process" }, - "importing": { - "message": "Please wait while we import your data...", - "description": "Shown as we are loading the user's data from disk" + "importCompleteStartButton": { + "message": "Start using Signal Desktop", + "description": "Button shown at end of successful import process, nothing left but a restart" }, - "importComplete": { - "message": "We've successfully loaded your data. The next step is to restart the application!", - "description": "Shown when the import is complete." + "importCompleteLinkButton": { + "message": "Link this device to your phone", + "description": "Button shown at end of successful 'light' import process, so the standard linking process still needs to happen" }, "selectedLocation": { "message": "your selected location", @@ -526,87 +512,46 @@ "message": "Privacy is possible. Signal makes it easy.", "description": "Tagline displayed under 'installWelcome' string on the install page" }, - "installNew": { - "message": "Set up as new install", - "description": "One of two choices presented on the screen shown on first launch" + "linkYourPhone": { + "message": "Link your phone to Signal Desktop", + "description": "Shown on the front page when the application first starst, above the QR code" }, - "installImport": { - "message": "Set up with exported data", - "description": "One of two choices presented on the screen shown on first launch" + "signalSettings": { + "message": "Signal Settings", + "description": "Used in the guidance to help people find the 'link new device' area of their Signal mobile app" }, - "installGetStartedButton": { - "message": "Get started" + "linkedDevices": { + "message": "Linked Devices", + "description": "Used in the guidance to help people find the 'link new device' area of their Signal mobile app" }, - "installSignalLink": { - "message": "First, install Signal on your mobile phone. We'll link your devices and keep your messages in sync.", - "description": "Prompt the user to install Signal on their phone before linking", - "placeholders": { - "a_params": { - "content": "$1", - "example": "href='http://example.com'" - } - } + "plusButton": { + "message": "'+' Button", + "description": "The button used in Signal Android to add a new linked device" }, - "installSignalLinks": { - "message": "First, install Signal on your Android or iPhone.
We'll link your devices and keep your messages in sync.", - "description": "Prompt the user to install Signal on their phone before linking", - "placeholders": { - "play_store": { - "content": "$1", - "example": "href='http://example.com'" - }, - "app_store": { - "content": "$2", - "example": "href='http://example.com'" - } - } + "linkNewDevice": { + "message": "Link New Device", + "description": "The menu option shown in Signal iOS to add a new linked device" }, - "installGotIt": { - "message": "Got it", - "description": "Button for the user to confirm that they have Signal installed." + "deviceName": { + "message": "Device name", + "description": "The label in settings panel shown for the user-provided name for this desktop instance" }, - "installIHaveSignalButton": { - "message": "I have Signal for Android", - "description": "Button for the user to confirm that they have Signal for Android" + "chooseDeviceName": { + "message": "Choose this device's name", + "description": "The header shown on the 'choose device name' screen in the device linking process" }, - "installFollowUs": { - "message": "Follow us for updates about multi-device support for iOS.", - "placeholders": { - "a_params": { - "content": "$1", - "example": "href='http://example.com'" - } - } + "finishLinkingPhone": { + "message": "Finish linking phone", + "description": "The text on the button to finish the linking process, after choosing the device name" }, - "installAndroidInstructions": { - "message": "Open Signal on your phone and navigate to Settings > Linked devices. Tap the button to add a new device, then scan the code above." - }, - "installConnecting": { - "message": "Connecting...", - "description": "Displayed when waiting for the QR Code" + "initialSync": { + "message": "Syncing contacts and groups", + "description": "Shown during initial link while contacts and groups are being pulled from mobile device" }, "installConnectionFailed": { "message": "Failed to connect to server.", "description": "Displayed when we can't connect to the server." }, - "installGeneratingKeys": { - "message": "Generating Keys" - }, - "installSyncingGroupsAndContacts": { - "message": "Syncing groups and contacts" - }, - "installComputerName": { - "message": "This computer's name will be", - "description": "Text displayed before the input where the user can enter the name for this device." - }, - "installLinkingWithNumber": { - "message": "Linking with", - "description": "Text displayed before the phone number that the user is in the process of linking with" - }, - "installFinalButton": { - "message": "Looking good", - "description": "The final button for the install process, after the user has entered a name for their device" - }, "installTooManyDevices": { "message": "Sorry, you have too many devices linked already. Try removing some." }, diff --git a/app/menu.js b/app/menu.js index f12c6a35bc..ce3111258f 100644 --- a/app/menu.js +++ b/app/menu.js @@ -6,6 +6,9 @@ function createTemplate(options, messages) { openNewBugForm, openSupportPage, openForums, + setupWithImport, + setupAsNewDevice, + setupAsStandalone, } = options; const template = [{ @@ -123,6 +126,27 @@ function createTemplate(options, messages) { ], }]; + if (options.includeSetup) { + const fileMenu = template[0]; + + // These are in reverse order, since we're prepending them one at a time + if (options.development) { + fileMenu.submenu.unshift({ + label: messages.menuSetupAsStandalone.message, + click: setupAsStandalone, + }); + } + + fileMenu.submenu.unshift({ + label: messages.menuSetupAsNewDevice.message, + click: setupAsNewDevice, + }); + fileMenu.submenu.unshift({ + label: messages.menuSetupWithImport.message, + click: setupWithImport, + }); + } + if (process.platform === 'darwin') { return updateForMac(template, messages, options); } @@ -134,14 +158,46 @@ function updateForMac(template, messages, options) { const { showWindow, showAbout, + setupWithImport, + setupAsNewDevice, + setupAsStandalone, } = options; // Remove About item and separator from Help menu, since it's on the first menu template[4].submenu.pop(); template[4].submenu.pop(); - // Replace File menu + // Remove File menu template.shift(); + + if (options.includeSetup) { + // Add a File menu just for these setup options. Because we're using unshift(), we add + // the file menu first, though it ends up to the right of the Signal Desktop menu. + const fileMenu = { + label: messages.mainMenuFile.message, + submenu: [ + { + label: messages.menuSetupWithImport.message, + click: setupWithImport, + }, + { + label: messages.menuSetupAsNewDevice.message, + click: setupAsNewDevice, + }, + ], + }; + + if (options.development) { + fileMenu.submenu.push({ + label: messages.menuSetupAsStandalone.message, + click: setupAsStandalone, + }); + } + + template.unshift(fileMenu); + } + + // Add the OSX-specific Signal Desktop menu at the far left template.unshift({ submenu: [ { @@ -170,7 +226,8 @@ function updateForMac(template, messages, options) { }); // Add to Edit menu - template[1].submenu.push( + const editIndex = options.includeSetup ? 2 : 1; + template[editIndex].submenu.push( { type: 'separator', }, diff --git a/appveyor.yml b/appveyor.yml index 8f0ffee4ea..f7a95d3a96 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -19,7 +19,7 @@ build_script: - node build\grunt.js - type package.json | findstr /v certificateSubjectName > temp.json - move temp.json package.json - - yarn prepare-build + - yarn prepare-beta-build - node_modules\.bin\build --em.environment=%SIGNAL_ENV% --publish=never test_script: diff --git a/background.html b/background.html index 193e4a19b3..10233e918a 100644 --- a/background.html +++ b/background.html @@ -571,6 +571,9 @@

{{ settings }}

+
+ {{ deviceNameLabel }}: {{ deviceName }} +

{{ theme }}

@@ -652,151 +655,198 @@ {{/action }} - - + @@ -857,7 +907,6 @@ - diff --git a/config/default.json b/config/default.json index 442f5509ea..ef464eda44 100644 --- a/config/default.json +++ b/config/default.json @@ -4,5 +4,8 @@ "disableAutoUpdate": false, "openDevTools": false, "buildExpiration": 0, - "certificateAuthorities": ["-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n"] + "certificateAuthorities": [ + "-----BEGIN CERTIFICATE-----\nMIID7zCCAtegAwIBAgIJAIm6LatK5PNiMA0GCSqGSIb3DQEBBQUAMIGNMQswCQYD\nVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5j\naXNjbzEdMBsGA1UECgwUT3BlbiBXaGlzcGVyIFN5c3RlbXMxHTAbBgNVBAsMFE9w\nZW4gV2hpc3BlciBTeXN0ZW1zMRMwEQYDVQQDDApUZXh0U2VjdXJlMB4XDTEzMDMy\nNTIyMTgzNVoXDTIzMDMyMzIyMTgzNVowgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI\nDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMR0wGwYDVQQKDBRP\ncGVuIFdoaXNwZXIgU3lzdGVtczEdMBsGA1UECwwUT3BlbiBXaGlzcGVyIFN5c3Rl\nbXMxEzARBgNVBAMMClRleHRTZWN1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw\nggEKAoIBAQDBSWBpOCBDF0i4q2d4jAXkSXUGpbeWugVPQCjaL6qD9QDOxeW1afvf\nPo863i6Crq1KDxHpB36EwzVcjwLkFTIMeo7t9s1FQolAt3mErV2U0vie6Ves+yj6\ngrSfxwIDAcdsKmI0a1SQCZlr3Q1tcHAkAKFRxYNawADyps5B+Zmqcgf653TXS5/0\nIPPQLocLn8GWLwOYNnYfBvILKDMItmZTtEbucdigxEA9mfIvvHADEbteLtVgwBm9\nR5vVvtwrD6CCxI3pgH7EH7kMP0Od93wLisvn1yhHY7FuYlrkYqdkMvWUrKoASVw4\njb69vaeJCUdU+HCoXOSP1PQcL6WenNCHAgMBAAGjUDBOMB0GA1UdDgQWBBQBixjx\nP/s5GURuhYa+lGUypzI8kDAfBgNVHSMEGDAWgBQBixjxP/s5GURuhYa+lGUypzI8\nkDAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQB+Hr4hC56m0LvJAu1R\nK6NuPDbTMEN7/jMojFHxH4P3XPFfupjR+bkDq0pPOU6JjIxnrD1XD/EVmTTaTVY5\niOheyv7UzJOefb2pLOc9qsuvI4fnaESh9bhzln+LXxtCrRPGhkxA1IMIo3J/s2WF\n/KVYZyciu6b4ubJ91XPAuBNZwImug7/srWvbpk0hq6A6z140WTVSKtJG7EP41kJe\n/oF4usY5J7LPkxK3LWzMJnb5EIJDmRvyH8pyRwWg6Qm6qiGFaI4nL8QU4La1x2en\n4DGXRaLMPRwjELNgQPodR38zoCMuA8gHZfZYYoZ7D7Q1wNUiVHcxuFrEeBaYJbLE\nrwLV\n-----END CERTIFICATE-----\n" + ], + "import": false } diff --git a/images/alert-outline.svg b/images/alert-outline.svg new file mode 100644 index 0000000000..cb7627f88a --- /dev/null +++ b/images/alert-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/android.svg b/images/android.svg new file mode 100644 index 0000000000..3edcb81a87 --- /dev/null +++ b/images/android.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/apple.svg b/images/apple.svg new file mode 100644 index 0000000000..c67e912050 --- /dev/null +++ b/images/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/check-circle-outline.svg b/images/check-circle-outline.svg new file mode 100644 index 0000000000..61a9db740a --- /dev/null +++ b/images/check-circle-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/folder-outline.svg b/images/folder-outline.svg new file mode 100644 index 0000000000..2a7ad7b8ea --- /dev/null +++ b/images/folder-outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/import.svg b/images/import.svg new file mode 100644 index 0000000000..2cc83b8399 --- /dev/null +++ b/images/import.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/lead-pencil.svg b/images/lead-pencil.svg new file mode 100644 index 0000000000..0d661d6eb2 --- /dev/null +++ b/images/lead-pencil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/sync.svg b/images/sync.svg new file mode 100644 index 0000000000..e0ed6c2248 --- /dev/null +++ b/images/sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/background.js b/js/background.js index 893ccc940f..42d74b8df1 100644 --- a/js/background.js +++ b/js/background.js @@ -107,6 +107,27 @@ } }); + Whisper.events.on('setupWithImport', function() { + var appView = window.owsDesktopApp.appView; + if (appView) { + appView.openImporter(); + } + }); + + Whisper.events.on('setupAsNewDevice', function() { + var appView = window.owsDesktopApp.appView; + if (appView) { + appView.openInstaller(); + } + }); + + Whisper.events.on('setupAsStandalone', function() { + var appView = window.owsDesktopApp.appView; + if (appView) { + appView.openStandalone(); + } + }); + function start() { var currentVersion = window.config.version; var lastVersion = storage.get('version'); @@ -140,8 +161,10 @@ appView.openInbox({ initialLoadComplete: initialLoadComplete }); + } else if (window.config.importMode) { + appView.openImporter(); } else { - appView.openInstallChoice(); + appView.openInstaller(); } Whisper.events.on('showDebugLog', function() { @@ -158,12 +181,6 @@ appView.openInbox(); } }); - Whisper.events.on('contactsync:begin', function() { - if (appView.installView && appView.installView.showSync) { - appView.installView.showSync(); - } - }); - Whisper.Notifications.on('click', function(conversation) { showWindow(); if (conversation) { diff --git a/js/backup.js b/js/backup.js index 98e760d8ed..2ccf8ed3a3 100644 --- a/js/backup.js +++ b/js/backup.js @@ -75,9 +75,9 @@ }; } - function exportNonMessages(idb_db, parent) { + function exportNonMessages(idb_db, parent, options) { return createFileAndWriter(parent, 'db.json').then(function(writer) { - return exportToJsonFile(idb_db, writer); + return exportToJsonFile(idb_db, writer, options); }); } @@ -85,10 +85,27 @@ * Export all data from an IndexedDB database * @param {IDBDatabase} idb_db */ - function exportToJsonFile(idb_db, fileWriter) { + function exportToJsonFile(idb_db, fileWriter, options) { + options = options || {}; + _.defaults(options, {excludeClientConfig: false}); + return new Promise(function(resolve, reject) { var storeNames = idb_db.objectStoreNames; storeNames = _.without(storeNames, 'messages'); + + if (options.excludeClientConfig) { + console.log('exportToJsonFile: excluding client config from export'); + storeNames = _.without( + storeNames, + 'items', + 'signedPreKeys', + 'preKeys', + 'identityKeys', + 'sessions', + 'unprocessed' // since we won't be able to decrypt them anyway + ); + } + var exportedStoreNames = []; if (storeNames.length === 0) { throw new Error('No stores to export'); @@ -160,9 +177,10 @@ }); } - function importNonMessages(idb_db, parent) { - return readFileAsText(parent, 'db.json').then(function(string) { - return importFromJsonString(idb_db, string); + function importNonMessages(idb_db, parent, options) { + var file = 'db.json'; + return readFileAsText(parent, file).then(function(string) { + return importFromJsonString(idb_db, string, path.join(parent, file), options); }); } @@ -176,6 +194,16 @@ reject(error || new Error(prefix)); } + function eliminateClientConfigInBackup(data, path) { + var cleaned = _.pick(data, 'conversations', 'groups'); + console.log('Writing configuration-free backup file back to disk'); + try { + fs.writeFileSync(path, JSON.stringify(cleaned)); + } catch (error) { + console.log('Error writing cleaned-up backup to disk: ', error.stack); + } + } + /** * Import data from JSON into an IndexedDB database. This does not delete any existing data * from the database, so keys could clash @@ -183,19 +211,50 @@ * @param {IDBDatabase} idb_db * @param {string} jsonString - data to import, one key per object store */ - function importFromJsonString(idb_db, jsonString) { + function importFromJsonString(idb_db, jsonString, path, options) { + options = options || {}; + _.defaults(options, { + forceLightImport: false, + conversationLookup: {}, + groupLookup: {}, + }); + + var conversationLookup = options.conversationLookup; + var groupLookup = options.groupLookup; + var result = { + fullImport: true, + }; + return new Promise(function(resolve, reject) { var importObject = JSON.parse(jsonString); delete importObject.debug; - var storeNames = _.keys(importObject); + if (!importObject.sessions || options.forceLightImport) { + result.fullImport = false; + + delete importObject.items; + delete importObject.signedPreKeys; + delete importObject.preKeys; + delete importObject.identityKeys; + delete importObject.sessions; + delete importObject.unprocessed; + + console.log('This is a light import; contacts, groups and messages only'); + } + + // We mutate the on-disk backup to prevent the user from importing client + // configuration more than once - that causes lots of encryption errors. + // This of course preserves the true data: conversations and groups. + eliminateClientConfigInBackup(importObject, path); + + var storeNames = _.keys(importObject); console.log('Importing to these stores:', storeNames.join(', ')); var finished = false; var finish = function(via) { console.log('non-messages import done via', via); if (finished) { - resolve(); + resolve(result); } finished = true; }; @@ -219,20 +278,46 @@ } var count = 0; + var skipCount = 0; + + var finishStore = function() { + // added all objects for this store + delete importObject[storeName]; + console.log( + 'Done importing to store', + storeName, + 'Total count:', + count, + 'Skipped:', + skipCount + ); + if (_.keys(importObject).length === 0) { + // added all object stores + console.log('DB import complete'); + finish('puts scheduled'); + } + }; + _.each(importObject[storeName], function(toAdd) { toAdd = unstringify(toAdd); + + var haveConversationAlready = + storeName === 'conversations' + && conversationLookup[getConversationKey(toAdd)]; + var haveGroupAlready = + storeName === 'groups' && groupLookup[getGroupKey(toAdd)]; + + if (haveConversationAlready || haveGroupAlready) { + skipCount++; + count++; + return; + } + var request = transaction.objectStore(storeName).put(toAdd, toAdd.id); request.onsuccess = function(event) { count++; if (count == importObject[storeName].length) { - // added all objects for this store - delete importObject[storeName]; - console.log('Done importing to store', storeName); - if (_.keys(importObject).length === 0) { - // added all object stores - console.log('DB import complete'); - finish('puts scheduled'); - } + finishStore(); } }; request.onerror = function(e) { @@ -243,6 +328,12 @@ ); }; }); + + // We have to check here, because we may have skipped every item, resulting + // in no onsuccess callback at all. + if (count === importObject[storeName].length) { + finishStore(); + } }); }); } @@ -432,14 +523,20 @@ request.onsuccess = function(event) { var cursor = event.target.result; if (cursor) { - if (count !== 0) { - stream.write(','); - } - var message = cursor.value; var messageId = message.received_at; var attachments = message.attachments; + // skip message if it is disappearing, no matter the amount of time left + if (message.expireTimer) { + cursor.continue(); + return; + } + + if (count !== 0) { + stream.write(','); + } + message.attachments = _.map(attachments, function(attachment) { return _.omit(attachment, ['data']); }); @@ -598,6 +695,10 @@ })); } + function saveMessage(idb_db, message) { + return saveAllMessages(idb_db, [message]); + } + function saveAllMessages(idb_db, messages) { if (!messages.length) { return Promise.resolve(); @@ -658,43 +759,64 @@ // message, save it, and only then do we move on to the next message. Thus, every // message with attachments needs to be removed from our overall message save with the // filter() call. - function importConversation(idb_db, dir) { + function importConversation(idb_db, dir, options) { + options = options || {}; + _.defaults(options, {messageLookup: {}}); + + var messageLookup = options.messageLookup; + var conversationId = 'unknown'; + var total = 0; + var skipped = 0; + return readFileAsText(dir, 'messages.json').then(function(contents) { var promiseChain = Promise.resolve(); var json = JSON.parse(contents); - var conversationId; if (json.messages && json.messages.length) { - conversationId = json.messages[0].conversationId; + conversationId = '[REDACTED]' + (json.messages[0].conversationId || '').slice(-3); } + total = json.messages.length; var messages = _.filter(json.messages, function(message) { message = unstringify(message); + if (messageLookup[getMessageKey(message)]) { + skipped++; + return false; + } + if (message.attachments && message.attachments.length) { var process = function() { return loadAttachments(dir, message).then(function() { - return saveAllMessages(idb_db, [message]); + return saveMessage(idb_db, message); }); }; promiseChain = promiseChain.then(process); - return null; + return false; } - return message; + return true; }); - return saveAllMessages(idb_db, messages) + var promise = Promise.resolve(); + if (messages.length > 0) { + promise = saveAllMessages(idb_db, messages); + } + + return promise .then(function() { return promiseChain; }) .then(function() { console.log( 'Finished importing conversation', - // Don't know if group or private conversation, so we blindly redact - conversationId ? '[REDACTED]' + conversationId.slice(-3) : 'with no messages' + conversationId, + 'Total:', + total, + 'Skipped:', + skipped ); }); @@ -703,7 +825,7 @@ }); } - function importConversations(idb_db, dir) { + function importConversations(idb_db, dir, options) { return getDirContents(dir).then(function(contents) { var promiseChain = Promise.resolve(); @@ -713,7 +835,7 @@ } var process = function() { - return importConversation(idb_db, conversationDir); + return importConversation(idb_db, conversationDir, options); }; promiseChain = promiseChain.then(process); @@ -723,6 +845,73 @@ }); } + function getMessageKey(message) { + var ourNumber = textsecure.storage.user.getNumber(); + var source = message.source || ourNumber; + if (source === ourNumber) { + return source + ' ' + message.timestamp; + } + + var sourceDevice = message.sourceDevice || 1; + return source + '.' + sourceDevice + ' ' + message.timestamp; + } + function loadMessagesLookup(idb_db) { + return assembleLookup(idb_db, 'messages', getMessageKey); + } + + function getConversationKey(conversation) { + return conversation.id; + } + function loadConversationLookup(idb_db) { + return assembleLookup(idb_db, 'conversations', getConversationKey); + } + + function getGroupKey(group) { + return group.id; + } + function loadGroupsLookup(idb_db) { + return assembleLookup(idb_db, 'groups', getGroupKey); + } + + function assembleLookup(idb_db, storeName, keyFunction) { + var lookup = Object.create(null); + + return new Promise(function(resolve, reject) { + var transaction = idb_db.transaction(storeName, 'readwrite'); + transaction.onerror = function(e) { + handleDOMException( + 'assembleLookup(' + storeName + ') transaction error', + transaction.error, + reject + ); + }; + transaction.oncomplete = function() { + // not really very useful - fires at unexpected times + }; + + var promiseChain = Promise.resolve(); + var store = transaction.objectStore(storeName); + var request = store.openCursor(); + request.onerror = function(e) { + handleDOMException( + 'assembleLookup(' + storeName + ') request error', + request.error, + reject + ); + }; + request.onsuccess = function(event) { + var cursor = event.target.result; + if (cursor && cursor.value) { + lookup[keyFunction(cursor.value)] = true; + cursor.continue(); + } else { + console.log('Done creating ' + storeName + ' lookup'); + return resolve(lookup); + } + }; + }); + } + function clearAllStores(idb_db) { return new Promise(function(resolve, reject) { console.log('Clearing all indexeddb stores'); @@ -791,7 +980,7 @@ }; return getDirectory(options); }, - backupToDirectory: function(directory) { + exportToDirectory: function(directory, options) { var dir; var idb; return openDatabase().then(function(idb_db) { @@ -800,7 +989,7 @@ return createDirectory(directory, name); }).then(function(created) { dir = created; - return exportNonMessages(idb, dir); + return exportNonMessages(idb, dir, options); }).then(function() { return exportConversations(idb, dir); }).then(function() { @@ -823,18 +1012,30 @@ }; return getDirectory(options); }, - importFromDirectory: function(directory) { - var idb; + importFromDirectory: function(directory, options) { + options = options || {}; + + var idb, nonMessageResult; return openDatabase().then(function(idb_db) { idb = idb_db; - return importNonMessages(idb_db, directory); + + return Promise.all([ + loadMessagesLookup(idb_db), + loadConversationLookup(idb_db), + loadGroupsLookup(idb_db), + ]); + }).then(function(lookups) { + options.messageLookup = lookups[0]; + options.conversationLookup = lookups[1]; + options.groupLookup = lookups[2]; }).then(function() { - return importConversations(idb, directory); + return importNonMessages(idb, directory, options); + }).then(function(result) { + nonMessageResult = result; + return importConversations(idb, directory, options); }).then(function() { - return directory; - }).then(function(path) { console.log('done restoring from backup!'); - return path; + return nonMessageResult; }, function(error) { console.log( 'the import went wrong:', diff --git a/js/views/app_view.js b/js/views/app_view.js index 6d34853d95..56527a49c9 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -7,12 +7,12 @@ initialize: function(options) { this.inboxView = null; this.installView = null; + this.applyTheme(); this.applyHideMenu(); }, events: { - 'click .openInstaller': 'openInstaller', - 'click .openStandalone': 'openStandalone', + 'click .openInstaller': 'openInstaller', // NetworkStatusView has this button 'openInbox': 'openInbox', 'change-theme': 'applyTheme', 'change-hide-menu': 'applyHideMenu', @@ -45,39 +45,29 @@ this.debugLogView = null; } }, - openInstallChoice: function() { - this.closeInstallChoice(); - var installChoice = this.installChoice = new Whisper.InstallChoiceView(); - - this.listenTo(installChoice, 'install-new', this.openInstaller.bind(this)); - this.listenTo(installChoice, 'install-import', this.openImporter.bind(this)); - - this.openView(this.installChoice); - }, - closeInstallChoice: function() { - if (this.installChoice) { - this.installChoice.remove(); - this.installChoice = null; - } - }, openImporter: function() { - this.closeImporter(); - this.closeInstallChoice(); + window.addSetupMenuItems(); + this.resetViews(); var importView = this.importView = new Whisper.ImportView(); - this.listenTo(importView, 'cancel', this.openInstallChoice.bind(this)); + this.listenTo(importView, 'light-import', this.finishLightImport.bind(this)); this.openView(this.importView); }, + finishLightImport: function() { + var options = { + startStep: Whisper.InstallView.Steps.SCAN_QR_CODE, + }; + this.openInstaller(options); + }, closeImporter: function() { if (this.importView) { this.importView.remove(); this.importView = null; } }, - openInstaller: function() { - this.closeInstaller(); - this.closeInstallChoice(); - var installView = this.installView = new Whisper.InstallView(); - this.listenTo(installView, 'cancel', this.openInstallChoice.bind(this)); + openInstaller: function(options) { + window.addSetupMenuItems(); + this.resetViews(); + var installView = this.installView = new Whisper.InstallView(options); this.openView(this.installView); }, closeInstaller: function() { @@ -88,11 +78,23 @@ }, openStandalone: function() { if (window.config.environment !== 'production') { - this.closeInstaller(); - this.installView = new Whisper.StandaloneRegistrationView(); - this.openView(this.installView); + window.addSetupMenuItems(); + this.resetViews(); + this.standaloneView = new Whisper.StandaloneRegistrationView(); + this.openView(this.standaloneView); } }, + closeStandalone: function() { + if (this.standaloneView) { + this.standaloneView.remove(); + this.standaloneView = null; + } + }, + resetViews: function() { + this.closeInstaller(); + this.closeImporter(); + this.closeStandalone(); + }, openInbox: function(options) { options = options || {}; // The inbox can be created before the 'empty' event fires or afterwards. If diff --git a/js/views/import_view.js b/js/views/import_view.js index 6aa1e5c046..92752a945b 100644 --- a/js/views/import_view.js +++ b/js/views/import_view.js @@ -7,7 +7,8 @@ var State = { IMPORTING: 1, - COMPLETE: 2 + COMPLETE: 2, + LIGHT_COMPLETE: 3, }; var IMPORT_STARTED = 'importStarted'; @@ -39,12 +40,13 @@ }; Whisper.ImportView = Whisper.View.extend({ - templateName: 'app-migration-screen', - className: 'app-loading-screen', + templateName: 'import-flow-template', + className: 'full-screen-flow', events: { - 'click .import': 'onImport', + 'click .choose': 'onImport', 'click .restart': 'onRestart', 'click .cancel': 'onCancel', + 'click .register': 'onRegister', }, initialize: function() { if (Whisper.Import.isIncomplete()) { @@ -55,41 +57,42 @@ this.pending = Promise.resolve(); }, render_attributes: function() { - var message; - var importButton; - var hideProgress = true; - var restartButton; - var cancelButton; - if (this.error) { return { - message: i18n('importError'), - hideProgress: true, - importButton: i18n('tryAgain'), + isError: true, + errorHeader: i18n('importErrorHeader'), + errorMessage: i18n('importError'), + chooseButton: i18n('importAgain'), }; } - switch (this.state) { - case State.COMPLETE: - message = i18n('importComplete'); - restartButton = i18n('restartSignal'); - break; - case State.IMPORTING: - message = i18n('importing'); - hideProgress = false; - break; - default: - message = i18n('importInstructions'); - importButton = i18n('chooseDirectory'); - cancelButton = i18n('cancel'); + var restartButton = i18n('importCompleteStartButton'); + var registerButton = i18n('importCompleteLinkButton'); + var step = 'step2'; + + if (this.state === State.IMPORTING) { + step = 'step3'; + } else if (this.state === State.COMPLETE) { + registerButton = null; + step = 'step4'; + } else if (this.state === State.LIGHT_COMPLETE) { + restartButton = null; + step = 'step4'; } return { - hideProgress: hideProgress, - message: message, - importButton: importButton, + isStep2: step === 'step2', + chooseHeader: i18n('loadDataHeader'), + choose: i18n('loadDataDescription'), + chooseButton: i18n('chooseDirectory'), + + isStep3: step === 'step3', + importingHeader: i18n('importingHeader'), + + isStep4: step === 'step4', + completeHeader: i18n('importCompleteHeader'), restartButton: restartButton, - cancelButton: cancelButton, + registerButton: registerButton, }; }, onRestart: function() { @@ -110,9 +113,16 @@ } }); }, - doImport: function(directory) { - this.error = null; + onRegister: function() { + // AppView listens for this, and opens up InstallView to the QR code step to + // finish setting this device up. + this.trigger('light-import'); + }, + doImport: function(directory) { + window.removeSetupMenuItems(); + + this.error = null; this.state = State.IMPORTING; this.render(); @@ -125,25 +135,17 @@ Whisper.Import.start(), Whisper.Backup.importFromDirectory(directory) ]); - }).then(function() { - // Catching in-memory cache up with what's in indexeddb now... - // NOTE: this fires storage.onready, listened to across the app. We'll restart - // to complete the install to start up cleanly with everything now in the DB. - return storage.fetch(); - }).then(function() { - return Promise.all([ - // Clearing any migration-related state inherited from the Chrome App - storage.remove('migrationState'), - storage.remove('migrationEnabled'), - storage.remove('migrationEverCompleted'), - storage.remove('migrationStorageLocation'), + }).then(function(results) { + var importResult = results[1]; - Whisper.Import.saveLocation(directory), - Whisper.Import.complete() - ]); - }).then(function() { - this.state = State.COMPLETE; - this.render(); + // A full import changes so much we need a restart of the app + if (importResult.fullImport) { + return this.finishFullImport(directory); + } + + // A light import just brings in contacts, groups, and messages. And we need a + // normal link to finish the process. + return this.finishLightImport(directory); }.bind(this)).catch(function(error) { console.log('Error importing:', error && error.stack ? error.stack : error); @@ -153,6 +155,40 @@ return Whisper.Backup.clearDatabase(); }.bind(this)); + }, + finishLightImport: function(directory) { + ConversationController.reset(); + + return ConversationController.load().then(function() { + return Promise.all([ + Whisper.Import.saveLocation(directory), + Whisper.Import.complete(), + ]); + }).then(function() { + this.state = State.LIGHT_COMPLETE; + this.render(); + }.bind(this)); + }, + finishFullImport: function(directory) { + // Catching in-memory cache up with what's in indexeddb now... + // NOTE: this fires storage.onready, listened to across the app. We'll restart + // to complete the install to start up cleanly with everything now in the DB. + return storage.fetch() + .then(function() { + return Promise.all([ + // Clearing any migration-related state inherited from the Chrome App + storage.remove('migrationState'), + storage.remove('migrationEnabled'), + storage.remove('migrationEverCompleted'), + storage.remove('migrationStorageLocation'), + + Whisper.Import.saveLocation(directory), + Whisper.Import.complete() + ]); + }).then(function() { + this.state = State.COMPLETE; + this.render(); + }.bind(this)); } }); })(); diff --git a/js/views/install_choice_view.js b/js/views/install_choice_view.js deleted file mode 100644 index 49cca2dd70..0000000000 --- a/js/views/install_choice_view.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * vim: ts=4:sw=4:expandtab - */ -(function () { - 'use strict'; - window.Whisper = window.Whisper || {}; - - Whisper.InstallChoiceView = Whisper.View.extend({ - templateName: 'install-choice', - className: 'install install-choice', - events: { - 'click .new': 'onClickNew', - 'click .import': 'onClickImport' - }, - initialize: function() { - this.render(); - }, - render_attributes: { - installWelcome: i18n('installWelcome'), - installTagline: i18n('installTagline'), - installNew: i18n('installNew'), - installImport: i18n('installImport') - }, - onClickNew: function() { - this.trigger('install-new'); - }, - onClickImport: function() { - this.trigger('install-import'); - } - }); -})(); diff --git a/js/views/install_view.js b/js/views/install_view.js index c5abeea539..d619cf386b 100644 --- a/js/views/install_view.js +++ b/js/views/install_view.js @@ -14,145 +14,159 @@ NETWORK_ERROR: 'NetworkError', }; + var DEVICE_NAME_SELECTOR = 'input.device-name'; + var CONNECTION_ERROR = -1; + var TOO_MANY_DEVICES = 411; + Whisper.InstallView = Whisper.View.extend({ - templateName: 'install_flow_template', - className: 'main install', - render_attributes: function() { - var twitterHref = 'https://twitter.com/signalapp'; - var signalHref = 'https://signal.org/install'; - return { - installWelcome: i18n('installWelcome'), - installTagline: i18n('installTagline'), - installGetStartedButton: i18n('installGetStartedButton'), - installSignalLink: this.i18n_with_links('installSignalLink', signalHref), - installIHaveSignalButton: i18n('installGotIt'), - installFollowUs: this.i18n_with_links('installFollowUs', twitterHref), - installAndroidInstructions: i18n('installAndroidInstructions'), - installLinkingWithNumber: i18n('installLinkingWithNumber'), - installComputerName: i18n('installComputerName'), - installFinalButton: i18n('installFinalButton'), - installTooManyDevices: i18n('installTooManyDevices'), - installConnectionFailed: i18n('installConnectionFailed'), - ok: i18n('ok'), - tryAgain: i18n('tryAgain'), - development: window.config.environment === 'development' - }; + templateName: 'link-flow-template', + className: 'main full-screen-flow', + events: { + 'click .try-again': 'connect', + // handler for finish button is in confirmNumber() }, initialize: function(options) { - this.counter = 0; + options = options || {}; - this.render(); - - var deviceName = textsecure.storage.user.getDeviceName(); - if (!deviceName) { - deviceName = window.config.hostname; - } - - this.$('#device-name').val(deviceName); - this.selectStep(Steps.INSTALL_SIGNAL); + this.selectStep(Steps.SCAN_QR_CODE); this.connect(); this.on('disconnected', this.reconnect); - if (Whisper.Registration.everDone()) { - this.selectStep(Steps.SCAN_QR_CODE); - this.hideDots(); + if (Whisper.Registration.everDone() || options.startStep) { + this.selectStep(options.startStep || Steps.SCAN_QR_CODE); } }, + render_attributes: function() { + var errorMessage; + + if (this.error) { + if (this.error.name === 'HTTPError' + && this.error.code == TOO_MANY_DEVICES) { + + errorMessage = i18n('installTooManyDevices'); + } + else if (this.error.name === 'HTTPError' + && this.error.code == CONNECTION_ERROR) { + + errorMessage = i18n('installConnectionFailed'); + } + else if (this.error.message === 'websocket closed') { + // AccountManager.registerSecondDevice uses this specific + // 'websocket closed' error message + errorMessage = i18n('installConnectionFailed'); + } + + return { + isError: true, + errorHeader: 'Something went wrong!', + errorMessage, + errorButton: 'Try again', + }; + } + + return { + isStep3: this.step === Steps.SCAN_QR_CODE, + linkYourPhone: i18n('linkYourPhone'), + signalSettings: i18n('signalSettings'), + linkedDevices: i18n('linkedDevices'), + androidFinalStep: i18n('plusButton'), + appleFinalStep: i18n('linkNewDevice'), + + isStep4: this.step === Steps.ENTER_NAME, + chooseName: i18n('chooseDeviceName'), + finishLinkingPhoneButton: i18n('finishLinkingPhone'), + + isStep5: this.step === Steps.PROGRESS_BAR, + syncing: i18n('initialSync'), + }; + }, + selectStep: function(step) { + this.step = step; + this.render(); + }, connect: function() { + this.error = null; + this.selectStep(Steps.SCAN_QR_CODE); this.clearQR(); + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + var accountManager = getAccountManager(); + accountManager.registerSecondDevice( this.setProvisioningUrl.bind(this), - this.confirmNumber.bind(this), - this.incrementCounter.bind(this) + this.confirmNumber.bind(this) ).catch(this.handleDisconnect.bind(this)); }, handleDisconnect: function(e) { - if (this.canceled) { - return; - } console.log('provisioning failed', e.stack); + this.error = e; + this.render(); + if (e.message === 'websocket closed') { - this.showConnectionError(); this.trigger('disconnected'); - } else if (e.name === 'HTTPError' && e.code == -1) { - this.selectStep(Steps.NETWORK_ERROR); - } else if (e.name === 'HTTPError' && e.code == 411) { - this.showTooManyDevices(); - } else { + } else if (e.name !== 'HTTPError' + || (e.code !== CONNECTION_ERROR && e.code !== TOO_MANY_DEVICES)) { + throw e; } }, reconnect: function() { - setTimeout(this.connect.bind(this), 10000); - }, - events: function() { - return { - 'click .error-dialog .ok': 'connect', - 'click .step1': 'onCancel', - 'click .step2': this.selectStep.bind(this, Steps.INSTALL_SIGNAL), - 'click .step3': this.selectStep.bind(this, Steps.SCAN_QR_CODE) - }; - }, - onCancel: function() { - this.canceled = true; - this.trigger('cancel'); + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.timeout = setTimeout(this.connect.bind(this), 10000); }, clearQR: function() { - this.$('#qr').text(i18n("installConnecting")); + this.$('#qr img').remove(); + this.$('#qr canvas').remove(); + this.$('#qr .container').show(); + this.$('#qr').removeClass('ready'); }, setProvisioningUrl: function(url) { - this.$('#qr').html(''); - new QRCode(this.$('#qr')[0]).makeCode(url); + if ($('#qr').length === 0) { + console.log('Did not find #qr element in the DOM!'); + return; + } + + this.$('#qr .container').hide(); + this.qr = new QRCode(this.$('#qr')[0]).makeCode(url); + this.$('#qr').removeAttr('title'); + this.$('#qr').addClass('ready'); + }, + setDeviceNameDefault: function() { + var deviceName = textsecure.storage.user.getDeviceName(); + + this.$(DEVICE_NAME_SELECTOR).val(deviceName || window.config.hostname); + this.$(DEVICE_NAME_SELECTOR).focus(); }, confirmNumber: function(number) { - var parsed = libphonenumber.parse(number); - var stepId = '#step' + Steps.ENTER_NAME; - this.$(stepId + ' .number').text(libphonenumber.format( - parsed, - libphonenumber.PhoneNumberFormat.INTERNATIONAL - )); + window.removeSetupMenuItems(); this.selectStep(Steps.ENTER_NAME); - this.$('#device-name').focus(); + this.setDeviceNameDefault(); + return new Promise(function(resolve, reject) { - this.$(stepId + ' .cancel').click(function(e) { - reject(); - }); - this.$(stepId).submit(function(e) { + this.$('.finish').click(function(e) { e.stopPropagation(); e.preventDefault(); - var name = this.$('#device-name').val(); + + var name = this.$(DEVICE_NAME_SELECTOR).val(); name = name.replace(/\0/g,''); // strip unicode null if (name.trim().length === 0) { - this.$('#device-name').focus(); + this.$(DEVICE_NAME_SELECTOR).focus(); return; } - this.$('.progress-dialog .status').text(i18n('installGeneratingKeys')); + this.selectStep(Steps.PROGRESS_BAR); resolve(name); }.bind(this)); }.bind(this)); }, - incrementCounter: function() { - this.$('.progress-dialog .bar').css('width', (++this.counter * 100 / 100) + '%'); - }, - selectStep: function(step) { - this.$('.step').hide(); - this.$('#step' + step).show(); - }, - showSync: function() { - this.$('.progress-dialog .status').text(i18n('installSyncingGroupsAndContacts')); - this.$('.progress-dialog .bar').addClass('progress-bar-striped active'); - }, - showTooManyDevices: function() { - this.selectStep(Steps.TOO_MANY_DEVICES); - }, - showConnectionError: function() { - this.$('#qr').text(i18n("installConnectionFailed")); - }, - hideDots: function() { - this.$('#step' + Steps.SCAN_QR_CODE + ' .nav .dot').hide(); - } }); + + Whisper.InstallView.Steps = Steps; })(); diff --git a/js/views/settings_view.js b/js/views/settings_view.js index 65374dce0c..517d6537b3 100644 --- a/js/views/settings_view.js +++ b/js/views/settings_view.js @@ -55,6 +55,7 @@ className: 'settings modal expand', templateName: 'settings', initialize: function() { + this.deviceName = textsecure.storage.user.getDeviceName(); this.render(); new RadioButtonGroupView({ el: this.$('.notification-settings'), @@ -88,6 +89,8 @@ }, render_attributes: function() { return { + deviceNameLabel: i18n('deviceName'), + deviceName: this.deviceName, theme: i18n('theme'), notifications: i18n('notifications'), notificationSettingsDialog: i18n('notificationSettingsDialog'), diff --git a/js/views/standalone_registration_view.js b/js/views/standalone_registration_view.js index c27bd5e975..bbc0745472 100644 --- a/js/views/standalone_registration_view.js +++ b/js/views/standalone_registration_view.js @@ -7,7 +7,7 @@ Whisper.StandaloneRegistrationView = Whisper.View.extend({ templateName: 'standalone', - className: 'install main', + className: 'full-screen-flow', initialize: function() { this.accountManager = getAccountManager(); @@ -21,16 +21,15 @@ this.$('#error').hide(); }, events: { - 'submit #form': 'submit', 'validation input.number': 'onValidation', - 'change #code': 'onChangeCode', 'click #request-voice': 'requestVoice', 'click #request-sms': 'requestSMSVerification', + 'change #code': 'onChangeCode', + 'click #verifyCode': 'verifyCode', }, - submit: function(e) { - e.preventDefault(); + verifyCode: function(e) { var number = this.phoneView.validateNumber(); - var verificationCode = $('#code').val().replace(/\D+/g, ""); + var verificationCode = $('#code').val().replace(/\D+/g, ''); this.accountManager.registerSingleDevice(number, verificationCode).then(function() { this.$el.trigger('openInbox'); @@ -64,6 +63,7 @@ } }, requestVoice: function() { + window.removeSetupMenuItems(); this.$('#error').hide(); var number = this.phoneView.validateNumber(); if (number) { @@ -74,6 +74,7 @@ } }, requestSMSVerification: function() { + window.removeSetupMenuItems(); $('#error').hide(); var number = this.phoneView.validateNumber(); if (number) { diff --git a/main.js b/main.js index 597c8eac78..1044ae8595 100644 --- a/main.js +++ b/main.js @@ -37,11 +37,17 @@ function getMainWindow() { // Tray icon and related objects let tray = null; -const startInTray = process.argv.find(arg => arg === '--start-in-tray'); -const usingTrayIcon = startInTray || process.argv.find(arg => arg === '--use-tray-icon'); +const startInTray = process.argv.some(arg => arg === '--start-in-tray'); +const usingTrayIcon = startInTray || process.argv.some(arg => arg === '--use-tray-icon'); + const config = require('./app/config'); +const importMode = process.argv.some(arg => arg === '--import') || config.get('import'); + + +const development = config.environment === 'development'; + // Very important to put before the single instance check, since it is based on the // userData directory. const userConfig = require('./app/user_config'); @@ -119,6 +125,7 @@ function prepareURL(pathSegments) { appInstance: process.env.NODE_APP_INSTANCE, polyfillNotifications: polyfillNotifications ? true : undefined, // for stringify() proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy, + importMode: importMode ? true : undefined, // for stringify() }, }); } @@ -334,6 +341,24 @@ function openForums() { shell.openExternal('https://whispersystems.discoursehosting.net/'); } +function setupWithImport() { + if (mainWindow) { + mainWindow.webContents.send('set-up-with-import'); + } +} + +function setupAsNewDevice() { + if (mainWindow) { + mainWindow.webContents.send('set-up-as-new-device'); + } +} + +function setupAsStandalone() { + if (mainWindow) { + mainWindow.webContents.send('set-up-as-standalone'); + } +} + let aboutWindow; function showAbout() { @@ -404,23 +429,31 @@ app.on('ready', () => { tray = createTrayIcon(getMainWindow, locale.messages); } - const options = { - showDebugLog, - showWindow, - showAbout, - openReleaseNotes, - openNewBugForm, - openSupportPage, - openForums, - }; - const template = createTemplate(options, locale.messages); - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); + setupMenu(); }); /* eslint-enable more/no-then */ }); +function setupMenu(options) { + const menuOptions = Object.assign({}, options, { + development, + showDebugLog, + showWindow, + showAbout, + openReleaseNotes, + openNewBugForm, + openSupportPage, + openForums, + setupWithImport, + setupAsNewDevice, + setupAsStandalone, + }); + const template = createTemplate(menuOptions, locale.messages); + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); +} + + app.on('before-quit', () => { windowState.markShouldQuit(); }); @@ -454,6 +487,17 @@ ipc.on('set-badge-count', (event, count) => { app.setBadgeCount(count); }); +ipc.on('remove-setup-menu-items', () => { + setupMenu(); +}); + +ipc.on('add-setup-menu-items', () => { + setupMenu({ + includeSetup: true, + }); +}); + + ipc.on('draw-attention', () => { if (process.platform === 'darwin') { app.dock.bounce(); diff --git a/package.json b/package.json index d68ae4889f..c4278d909a 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "build": "build --em.environment=$SIGNAL_ENV", "dist": "npm run generate && npm run build", "pack": "npm run generate && npm run build -- --dir", - "prepare-build": "node prepare_build.js", + "prepare-beta-build": "node prepare_beta_build.js", + "prepare-import-build": "node prepare_import_build.js", "pack-prod": "SIGNAL_ENV=production npm run pack", "dist-prod": "SIGNAL_ENV=production npm run dist", "dist-prod-all": "SIGNAL_ENV=production npm run dist -- -mwl", diff --git a/preload.js b/preload.js index ed616e9133..93a67c9ce4 100644 --- a/preload.js +++ b/preload.js @@ -42,6 +42,26 @@ Whisper.events.trigger('showDebugLog'); }); + ipc.on('set-up-with-import', function() { + Whisper.events.trigger('setupWithImport'); + }); + + ipc.on('set-up-as-new-device', function() { + Whisper.events.trigger('setupAsNewDevice'); + }); + + ipc.on('set-up-as-standalone', function() { + Whisper.events.trigger('setupAsStandalone'); + }); + + window.addSetupMenuItems = function() { + ipc.send('add-setup-menu-items'); + } + + window.removeSetupMenuItems = function() { + ipc.send('remove-setup-menu-items'); + } + // We pull these dependencies in now, from here, because they have Node.js dependencies require('./js/logging'); diff --git a/prepare_build.js b/prepare_beta_build.js similarity index 96% rename from prepare_build.js rename to prepare_beta_build.js index 47acc5d577..203358543d 100644 --- a/prepare_build.js +++ b/prepare_beta_build.js @@ -17,7 +17,7 @@ if (!beta.test(version)) { process.exit(); } -console.log('prepare_build: updating package.json for beta build'); +console.log('prepare_beta_build: updating package.json'); // ------- diff --git a/prepare_import_build.js b/prepare_import_build.js new file mode 100644 index 0000000000..b0bcdc2d0f --- /dev/null +++ b/prepare_import_build.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const _ = require('lodash'); + +const packageJson = require('./package.json'); +const defaultConfig = require('./config/default.json'); + +function checkValue(object, objectPath, expected) { + const actual = _.get(object, objectPath); + if (actual !== expected) { + throw new Error(`${objectPath} was ${actual}; expected ${expected}`); + } +} + +// You might be wondering why this file is necessary. We have some very specific +// requirements around our import-flavor builds. They need to look exactly the same as +// normal builds, but they must immediately open into import mode. So they need a +// slight config tweak, and then a change to the .app/.exe name (note: we do NOT want to +// change where data is stored or anything, since that would make these builds +// incompatible with the mainline builds) So we just change the artifact name. +// +// Another key thing to know about these builds is that we should not upload the +// latest.yml (windows) and latest-mac.yml (mac) that go along with the executables. +// This would interrupt the normal install flow for users installing from +// signal.org/download. So any release script will need to upload these files manually +// instead of relying on electron-builder, which will upload everything. + +// ------- + +console.log('prepare_import_build: updating config/default.json'); + +const IMPORT_PATH = 'import'; +const IMPORT_START_VALUE = false; +const IMPORT_END_VALUE = true; + +checkValue(defaultConfig, IMPORT_PATH, IMPORT_START_VALUE); + +_.set(defaultConfig, IMPORT_PATH, IMPORT_END_VALUE); + +// ------- + +console.log('prepare_import_build: updating package.json'); + +const MAC_ASSET_PATH = 'build.mac.artifactName'; +const MAC_ASSET_START_VALUE = '${name}-mac-${version}.${ext}'; +const MAC_ASSET_END_VALUE = '${name}-mac-${version}-import.${ext}'; + +const WIN_ASSET_PATH = 'build.win.artifactName'; +const WIN_ASSET_START_VALUE = '${name}-win-${version}.${ext}'; +const WIN_ASSET_END_VALUE = '${name}-win-${version}-import.${ext}'; + +checkValue(packageJson, MAC_ASSET_PATH, MAC_ASSET_START_VALUE); +checkValue(packageJson, WIN_ASSET_PATH, WIN_ASSET_START_VALUE); + +_.set(packageJson, MAC_ASSET_PATH, MAC_ASSET_END_VALUE); +_.set(packageJson, WIN_ASSET_PATH, WIN_ASSET_END_VALUE); + +// --- + +fs.writeFileSync('./config/default.json', JSON.stringify(defaultConfig, null, ' ')); +fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, ' ')); diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index e53d5d75f4..296f73e8d8 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -218,7 +218,7 @@ button.hamburger { } .dropoff { - outline: solid 1px #2090ea; + outline: solid 1px $blue; } $avatar-size: 44px; @@ -609,6 +609,281 @@ input[type=text], input[type=search], textarea { } } +.full-screen-flow { + z-index: 1000; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + font-family: roboto-light; + + color: black; + a { + color: $blue; + } + background: linear-gradient( + to bottom, + rgb(238,238,238) 0%, // (1 - 0.41) * 255 + 0.41 * 213 + rgb(243,243,243) 12%, // (1 - 0.19) * 255 + 0.19 * 191 + rgb(255,255,255) 27%, + rgb(255,255,255) 60%, + rgb(249,249,249) 85%, // (1 - 0.19) * 255 + 0.19 * 222 + rgb(213,213,213) 100% // (1 - 0.27) * 255 + 0.27 * 98 + ); + display: flex; + align-items: center; + text-align: center; + + font-size: 10pt; + input { + margin-top: 1em; + font-size: 12pt; + font-family: roboto-light; + border: 2px solid $blue; + padding: 0.5em; + text-align: center; + width: 20em; + } + + @media (min-height: 750px) and (min-width: 700px) { + font-size: 14pt; + + input { + font-size: 16pt; + } + } + + #qr { + display: inline-block; + + &.ready { + border: 5px solid $blue; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + } + + img { + height: 20em; + border: 5px solid white; + } + + @media (max-height: 475px) { + img { + width: 8em; + height: 8em; + } + } + + .dot { + width: 14px; + height: 14px; + border: 3px solid $blue; + border-radius: 50%; + float: left; + margin: 0 6px; + transform: scale(0); + + animation: loading 1500ms ease infinite 0ms; + &:nth-child(2) { + animation: loading 1500ms ease infinite 333ms; + } + &:nth-child(3) { + animation: loading 1500ms ease infinite 666ms; + } + } + + canvas { + display: none; + } + } + + .os-icon { + height: 3em; + width: 3em; + vertical-align: text-bottom; + display: inline-block; + margin: 0.5em; + + &.apple { + @include color-svg('../images/apple.svg', black); + } + &.android { + @include color-svg('../images/android.svg', black); + } + } + + .header { + font-weight: normal; + margin-bottom: 1.5em; + + font-size: 20pt; + + @media (min-height: 750px) and (min-width: 700px) { + font-size: 28pt; + } + } + + .body-text { + max-width: 22em; + text-align: left; + margin-left: auto; + margin-right: auto; + } + .body-text-wide { + max-width: 30em; + text-align: left; + margin-left: auto; + margin-right: auto; + } + + .step { + height: 100%; + width: 100%; + padding: 70px 0 50px; + } + .step-body { + margin-left: auto; + margin-right: auto; + max-width: 35em; + } + + .inner { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100%; + } + + .banner-image { + margin: 1em; + display: none; + + @media (min-height: 550px) { + display: inline-block; + height: 10em; + width: 10em; + } + } + + .banner-icon { + display: none; + margin: 1em; + + // 640px by 338px is the smallest the window can go + @media (min-height: 550px) { + display: inline-block; + height: 10em; + width: 10em; + } + + // generic + &.check-circle-outline { + @include color-svg('../images/check-circle-outline.svg', #DEDEDE); + } + &.alert-outline { + @include color-svg('../images/alert-outline.svg', #DEDEDE); + } + + // import and export + &.folder-outline { + @include color-svg('../images/folder-outline.svg', #DEDEDE); + } + &.import { + @include color-svg('../images/import.svg', #DEDEDE); + } + &.export { + @include color-svg('../images/export.svg', #DEDEDE); + } + + // registration process + &.lead-pencil { + @include color-svg('../images/lead-pencil.svg', #DEDEDE); + } + &.sync { + @include color-svg('../images/sync.svg', #DEDEDE); + } + } + + .button { + cursor: pointer; + display: inline-block; + border: none; + min-width: 300px; + padding: 0.75em; + margin-top: 1em; + color: white; + background: $blue; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + + font-size: 12pt; + + @media (min-height: 750px) and (min-width: 700px) { + font-size: 20pt; + } + } + a.link { + display: block; + cursor: pointer; + text-decoration: underline; + margin: 0.5em; + color: #2090ea; + } + + .progress { + text-align: center; + padding: 1em; + width: 80%; + margin: auto; + + .bar-container { + height: 1em; + margin: 1em; + background-color: $grey_l; + } + .bar { + width: 100%; + height: 100%; + background-color: $blue_l; + transition: width 0.25s; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + } + } + + .nav { + width: 100%; + bottom: 50px; + margin-top: auto; + padding-bottom: 2em; + padding-left: 20px; + padding-right: 20px; + + .instructions { + text-align: left; + margin-left: auto; + margin-right: auto; + margin-bottom: 2em; + margin-top: 2em; + max-width: 30em; + } + .instructions:after { + clear: both; + } + .android { + float: left; + } + .apple { + float: right; + } + .label { + float: left; + } + .body { + float: left; + } + } +} + //yellow border fix .inbox:focus { outline: none; diff --git a/stylesheets/_settings.scss b/stylesheets/_settings.scss index 394da84e79..652d8c45b1 100644 --- a/stylesheets/_settings.scss +++ b/stylesheets/_settings.scss @@ -11,6 +11,10 @@ hr { margin: 10px 0; } + .device-name-settings { + text-align: center; + margin-bottom: 1em; + } .syncSettings { button { float: right; diff --git a/stylesheets/options.scss b/stylesheets/options.scss index b21a761529..76a3d4b83b 100644 --- a/stylesheets/options.scss +++ b/stylesheets/options.scss @@ -6,335 +6,6 @@ background: url("../images/flags.png"); } -.install { - height: 100%; - background: #2090ea; - color: white; - text-align: center; - font-size: 16px; - overflow: auto; - - input, button, select, textarea { - font-family: inherit; - font-size: inherit; - line-height: inherit; - } - - .main { - padding: 70px 0 50px; - } - .hidden { - display: none; - } - .step { - height: 100%; - } - .inner { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - height: 100%; - - .step-body { - margin-top: auto; - width: 100%; - max-width: 600px; - } - } - - #signal-computer, - #signal-phone { - max-width: 50%; - max-height: 250px; - } - - p { - max-width: 35em; - margin: 1em auto; - padding: 0 1em; - line-height: 1.5em; - font-size: 1.2em; - font-weight: bold; - } - - a { - cursor: pointer; - &, &:visited, &:hover { - text-decoration: none; - } - } - - .button { - display: inline-block; - text-transform: uppercase; - border: none; - font-weight: bold; - min-width: 300px; - padding: 0.5em; - margin: 0.5em 0; - background: white; - color: $blue; - } - - .nav { - width: 100%; - bottom: 50px; - margin-top: auto; - padding: 20px; - - .dot-container { - margin-top: 3em; - } - - .dot { - display: inline-block; - cursor: pointer; - margin: 10px; - width: 20px; - height: 20px; - border-radius: 10px; - background: white; - border: solid 5px $blue; - - &.selected { - background: $blue_l; - } - } - } - - &.install-choice .nav { - top: 20px; - margin-bottom: auto; - } - - .link { - &:hover, &:focus { - background: rgba(255,255,255,0.3); - outline: none; - } - &, &:visited, &:hover { - padding: 0 3px; - color: white; - font-weight: bold; - border-bottom: dashed 2px white; - text-decoration: none; - } - } - - .container { - min-width: 650px; - } - - h1 { - font-size: 30pt; - font-weight: normal; - padding-bottom: 10px; - } - - h3.step { - margin-top: 0; - font-weight: bold; - } - - .help { - border-top: 2px solid $grey_l; - padding: 1.5em 0.1em; - } - - .install { - display: inline-block; - margin-top: 90px; - } - - #qr { - display: inline-block; - min-height: 266px; - img { - border: 5px solid white; - } - - canvas { - display: none; - } - } - - #device-name { - border: none; - border-bottom: 1px solid white; - padding: 8px; - background: transparent; - color: white; - font-weight: bold; - text-align: center; - &::selection, a::selection { - color: $grey_d; - background: white; - } - - &::-moz-selection, a::-moz-selection { - color: $grey_d; - background: white; - } - - &:focus { - outline: none; - } - - &:hover, &:focus { - background: rgba(255,255,255,0.1); - } - - } - - #verifyCode, - #code, - #number { - box-sizing: border-box; - width: 100%; - display: block; - margin-bottom: 0.5em; - text-align: center; - } - - #request-voice, - #request-sms { - box-sizing: border-box; - } - #request-sms { - width: 57%; - float: right; - } - #request-voice { - width: 40%; - float: left; - } - - .number-container { - position: relative; - margin-bottom: 0.5em; - } - .number-container .intl-tel-input, - .number-container .number { - width: 100%; - } - .number-container::after { - visibility: hidden; - content: ' '; - display: inline-block; - border-radius: 1.5em; - width: 1.5em; - height: 1.5em; - line-height: 1.5em; - color: #ffffff; - position: absolute; - top: 0; - left: 100%; - margin: 3px 8px; - text-align: center; - } - .number-container.valid::after { - visibility: visible; - content: '✓'; - background-color: #0f9d58; - color: #ffffff; - } - .number-container.invalid::after { - visibility: visible; - content: '!'; - background-color: #f44336; - color: #ffffff; - } - - #error { - color: white; - font-weight: bold; - padding: 0.5em; - text-align: center; - } - #error { background-color: #f44336; } - #error:before { - content: '\26a0'; - padding-right: 0.5em; - } - .narrow { - margin: auto; - box-sizing: border-box; - width: 275px; - max-width: 100%; - } - - ul.country-list { - min-width: 197px !important; - } - - .confirmation-dialog, .progress-dialog { - padding: 1em; - text-align: left; - } - .number { text-align: center; } - .confirmation-dialog { - button { - float: right; - margin-left: 10px; - } - } - .progress-dialog { - text-align: center; - padding: 1em; - width: 100%; - max-width: 600px; - margin: auto; - - .status { padding: 1em; } - - .bar-container { - height: 1em; - background-color: $grey_l; - border: solid 1px white; - } - .bar { - width: 0; - height: 100%; - background-color: $blue_l; - transition: width 0.25s; - - &.active { - } - } - } - - .modal-container { - display: none; - position: absolute; - width: 100%; - height: 100%; - background: rgba(0,0,0,0.1); - top: 0; - padding-top: 10em; - text-align: center; - - .modal-main { - display: inline-block; - width: 80%; - max-width: 500px; - border: solid 2px $blue; - background: white; - margin: 10% auto; - box-shadow: 0 0 5px 3px rgba(darken($blue, 30%), 0.2); - - h4 { - background-color: $blue; - color: white; - padding: 1em; - margin: 0; - text-align: left; - } - - } - } -} - .intl-tel-input .country-list { text-align: left; } diff --git a/test/views/last_seen_indicator_view_test.js b/test/views/last_seen_indicator_view_test.js index 179715a737..667ceb7169 100644 --- a/test/views/last_seen_indicator_view_test.js +++ b/test/views/last_seen_indicator_view_test.js @@ -2,18 +2,33 @@ * vim: ts=4:sw=4:expandtab */ describe('LastSeenIndicatorView', function() { - // TODO: in electron branch, where we have access to real i18n, test rendered HTML - it('renders provided count', function() { var view = new Whisper.LastSeenIndicatorView({count: 10}); assert.equal(view.count, 10); + + view.render(); + assert.match(view.$el.html(), /10 Unread Messages/); + }); + + it('renders count of 1', function() { + var view = new Whisper.LastSeenIndicatorView({count: 1}); + assert.equal(view.count, 1); + + view.render(); + assert.match(view.$el.html(), /1 Unread Message/); }); it('increments count', function() { var view = new Whisper.LastSeenIndicatorView({count: 4}); + assert.equal(view.count, 4); + view.render(); + assert.match(view.$el.html(), /4 Unread Messages/); + view.increment(3); assert.equal(view.count, 7); + view.render(); + assert.match(view.$el.html(), /7 Unread Messages/); }); }); diff --git a/test/views/scroll_down_button_view_test.js b/test/views/scroll_down_button_view_test.js index e830885ede..eb8787ad09 100644 --- a/test/views/scroll_down_button_view_test.js +++ b/test/views/scroll_down_button_view_test.js @@ -2,13 +2,11 @@ * vim: ts=4:sw=4:expandtab */ describe('ScrollDownButtonView', function() { - // TODO: in electron branch, where we have access to real i18n, uncomment assertions against real strings - it('renders with count = 0', function() { var view = new Whisper.ScrollDownButtonView(); view.render(); assert.equal(view.count, 0); - // assert.match(view.$el.html(), /Scroll to bottom/); + assert.match(view.$el.html(), /Scroll to bottom/); }); it('renders with count = 1', function() { @@ -16,7 +14,7 @@ describe('ScrollDownButtonView', function() { view.render(); assert.equal(view.count, 1); assert.match(view.$el.html(), /new-messages/); - // assert.match(view.$el.html(), /New message below/); + assert.match(view.$el.html(), /New message below/); }); it('renders with count = 2', function() { @@ -25,7 +23,7 @@ describe('ScrollDownButtonView', function() { assert.equal(view.count, 2); assert.match(view.$el.html(), /new-messages/); - // assert.match(view.$el.html(), /New messages below/); + assert.match(view.$el.html(), /New messages below/); }); it('increments count and re-renders', function() { From 42c24dd098c8a279ff2c5466298a19b86abbbf5f Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 22 Feb 2018 11:14:26 -0800 Subject: [PATCH 17/19] Enable conversation open when app started offline (#2064) Turns out textsecure.messaging is only set up on first connection to the server. When we start up offline, we never do that. And it prevents the user from opening every conversation. There's a whole lot more to do to bulletproof ourselves against a missing textsecure.messaging (and then beyond that, queuing outgoing messages so they don't get dropped completely when offline). Hence, it's a band-aid. --- js/models/conversations.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/js/models/conversations.js b/js/models/conversations.js index 45bc96c941..6ed49da82f 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -911,6 +911,11 @@ }, getProfile: function(id) { + if (!textsecure.messaging) { + var message = 'Conversation.getProfile: textsecure.messaging not available'; + return Promise.reject(new Error(message)); + } + return textsecure.messaging.getProfile(id).then(function(profile) { var identityKey = dcodeIO.ByteBuffer.wrap(profile.identityKey, 'base64').toArrayBuffer(); From ae75390acc32d51d4d3fd5ecf68f9fccd6117703 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 22 Feb 2018 11:40:25 -0800 Subject: [PATCH 18/19] v1.5.0-beta.1 New design for import and install flows (#2053) Support for 'light' imports, which bring just messages, contacts and groups (#2053) Fix: If app started offline, conversations would not open (#2064) Fix: Attached images would sometimes show up rotated improperly (#2040) Fix: Uncaught Exception: TypeError; 'getSize' (#2061) Fix: File paths with special characters could be shown in misleading way Dev: - Move ESLint environment configuration into `.eslintrc` (#2051) - Sync Protocol Buffers with `libsignal-service-java` (#2046) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c4278d909a..440617cf80 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "Signal", "description": "Private messaging from your desktop", "repository": "https://github.com/signalapp/Signal-Desktop.git", - "version": "1.4.0-beta.1", + "version": "1.5.0-beta.1", "license": "GPL-3.0", "author": { "name": "Open Whisper Systems", From 2a384cef7e29d6726cffc85d0ddda5fb85849e4a Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 26 Feb 2018 15:45:28 -0800 Subject: [PATCH 19/19] Two fixes for tricky import/register scenarios (#2072) * Clear data on finish of new install, unless re-link/light import * Don't show setup options in file menu in middle of light import * Naming changes to address feedback --- js/views/app_view.js | 13 +++++++++++-- js/views/install_view.js | 29 +++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/js/views/app_view.js b/js/views/app_view.js index 56527a49c9..662e7539e4 100644 --- a/js/views/app_view.js +++ b/js/views/app_view.js @@ -54,7 +54,7 @@ }, finishLightImport: function() { var options = { - startStep: Whisper.InstallView.Steps.SCAN_QR_CODE, + hasExistingData: true }; this.openInstaller(options); }, @@ -65,7 +65,16 @@ } }, openInstaller: function(options) { - window.addSetupMenuItems(); + options = options || {}; + + // If we're in the middle of import, we don't want to show the menu options + // allowing the user to switch to other ways to set up the app. If they + // switched back and forth in the middle of a light import, they'd lose all + // that imported data. + if (!options.hasExistingData) { + window.addSetupMenuItems(); + } + this.resetViews(); var installView = this.installView = new Whisper.InstallView(options); this.openView(this.installView); diff --git a/js/views/install_view.js b/js/views/install_view.js index d619cf386b..330d0a5d39 100644 --- a/js/views/install_view.js +++ b/js/views/install_view.js @@ -32,9 +32,8 @@ this.connect(); this.on('disconnected', this.reconnect); - if (Whisper.Registration.everDone() || options.startStep) { - this.selectStep(options.startStep || Steps.SCAN_QR_CODE); - } + // Keep data around if it's a re-link, or the middle of a light import + this.shouldRetainData = Whisper.Registration.everDone() || options.hasExistingData; }, render_attributes: function() { var errorMessage; @@ -162,11 +161,29 @@ } this.selectStep(Steps.PROGRESS_BAR); - resolve(name); + + var finish = function() { + resolve(name); + }; + + // Delete all data from database unless we're in the middle + // of a re-link, or we are finishing a light import. Without this, + // app restarts at certain times can cause weird things to happen, + // like data from a previous incomplete light import showing up + // after a new install. + if (this.shouldRetainData) { + return finish(); + } + + Whisper.Backup.clearDatabase().then(finish, function(error) { + console.log( + 'confirmNumber: error clearing database', + error && error.stack ? error.stack : error + ); + finish(); + }); }.bind(this)); }.bind(this)); }, }); - - Whisper.InstallView.Steps = Steps; })();