diff --git a/.travis.yml b/.travis.yml index d2cb9a7aa..2915dcc3b 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 b6ed1662b..814fff7be 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 f12c6a35b..ce3111258 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 8f0ffee4e..f7a95d3a9 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 193e4a19b..10233e918 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 442f5509e..ef464eda4 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 000000000..cb7627f88 --- /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 000000000..3edcb81a8 --- /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 000000000..c67e91205 --- /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 000000000..61a9db740 --- /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 000000000..2a7ad7b8e --- /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 000000000..2cc83b839 --- /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 000000000..0d661d6eb --- /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 000000000..e0ed6c224 --- /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 893ccc940..42d74b8df 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 98e760d8e..2ccf8ed3a 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 6d34853d9..56527a49c 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 6aa1e5c04..92752a945 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 49cca2dd7..000000000 --- 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 c5abeea53..d619cf386 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 65374dce0..517d6537b 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 c27bd5e97..bbc074547 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 597c8eac7..1044ae859 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 d68ae4889..c4278d909 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 ed616e913..93a67c9ce 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 47acc5d57..203358543 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 000000000..b0bcdc2d0 --- /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 e53d5d75f..296f73e8d 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 394da84e7..652d8c45b 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 b21a76152..76a3d4b83 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 179715a73..667ceb716 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 e830885ed..eb8787ad0 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() {