Full export, migration banner, and full migration workflow - behind flag (#1342)

* Add support for backup and restore

This first pass works for all stores except messages, pending some scaling
improvements.

// FREEBIE

* Import of messages and attachments

Properly sanitize filenames. Logging information that will help with
debugging but won't threaten privacy (no contact or group names),
where the on-disk directories have this information to make things
human-readable

FREEBIE

* First fully operational single-action export and import!

FREEBIE

* Add migration export flow

A banner alert leads to a blocking ui for the migration. We close the socket and
wait for incoming messages to drain before starting the export.

FREEBIE

* A number of updates for the export flow

1. We don't immediately pop the directory selection dialog box, instead
  showing an explicit 'choose directory' button after explaining what is
  about to happen
2. We show a 'submit debug log' button on most steps of the process
3. We handle export errors and encourage the user to double-check their
  filesystem then submit their log
4. We are resilient to restarts during the process
5. We handle the user cancelling out of the directory selection dialog
  differently from other errors.
6. The export process is now serialized: non-messages, then messages.
7. After successful export, show where the data is on disk

FREEBUE

* Put migration behind a flag

FREEBIE

* Shut down websocket before proceeding with export

FREEBIE

* Add MigrationView to test/index.html to fix test

FREEBIE

* Remove 'Submit Debug Log' button when the export process is complete

FREEBIE

* Create a 'Signal Export' directory below user-chosen dir

This cleans things up a bit so we don't litter the user's target
directory with lots of stuff.

FREEBIE

* Clarify MessageReceiver.drain() method comments

FREEBIE

* A couple updates for clarity - event names, else handling

Also the removal of wait(), which wasn't used anywhere.

FREEBIE

* A number of wording updates for the export flow

FREEBIE

* Export complete: put dir on its own line, make text selectable

FREEBIE
This commit is contained in:
Scott Nonnenberg 2017-08-28 13:06:10 -07:00 committed by GitHub
parent 76a69f7511
commit c0cd733139
13 changed files with 1010 additions and 8 deletions

View file

@ -3,6 +3,56 @@
"message": "Loading...",
"description": "Message shown on the loading screen before we've loaded any messages"
},
"migrationWarning": {
"message": "The Signal Desktop Chrome app has been deprecated. Would you like to migrate to the new Signal Desktop now?",
"description": "Warning notification that this version of the app has been deprecated and the user must migrate"
},
"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"
},
"migrate": {
"message": "Migrate",
"description": "Button label to begin migrating this client to Electron"
},
"export": {
"message": "Choose directory",
"description": "Button to allow the user to export all data from app as part of migration process"
},
"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"
},
"confirmMigration": {
"message": "Start migration process? You will not be able to send or receive Signal messages from this application while the migration is in progress.",
"description": "Confirmation dialogue when beginning migration"
},
"migrationDisconnecting": {
"message": "Disconnecting...",
"description": "Displayed while we wait for pending incoming messages to process"
},
"exporting": {
"message": "Please wait while we export your data. You can still use Signal on your phone and other devices during this time. You can also <a target='_blank' href='https://support.whispersystems.org/hc/en-us/articles/214507138-How-do-I-install-Signal-Desktop-'>install the new Signal Desktop</a>.",
"description": "Message shown on the migration screen while we export data"
},
"exportComplete": {
"message": "Your data has been exported to: <p><b>$location$</b></p> To complete the migration, <a target='_blank' href='https://support.whispersystems.org/hc/en-us/articles/214507138-How-do-I-install-Signal-Desktop-'>install the new Signal Desktop</a> and import this data.",
"description": "Message shown on the migration screen when we are done exporting data",
"placeholders": {
"location": {
"content": "$1",
"example": "/Users/someone/somewhere"
}
}
},
"selectedLocation": {
"message": "your selected location",
"description": "Message shown as the export location if we didn't capture the target directory"
},
"upgradingDatabase": {
"message": "Upgrading database. This may take some time...",
"description": "Message shown on the loading screen when we're changing database structure on first run of a new version"

View file

@ -2,6 +2,27 @@
<html>
<head>
<meta charset='utf-8'>
<script type='text/x-tmpl-mustache' id='app-migration-screen'>
<div class='content'>
<img src='/images/icon_128.png'>
{{ ^hideProgress }}
<div class='container'>
<span class='dot'></span>
<span class='dot'></span>
<span class='dot'></span>
</div>
{{ /hideProgress }}
<div class='message'>{{& message }}</div>
<div>
{{ #exportButton }}
<button class='export grey'>{{ exportButton }}</button>
{{ /exportButton }}
{{ #debugLogButton }}
<button class='debug-log grey'>{{ debugLogButton }}</button>
{{ /debugLogButton }}
</div>
</div>
</script>
<script type='text/x-tmpl-mustache' id='app-loading-screen'>
<div class='content'>
<img src='/images/icon_128.png'>
@ -81,6 +102,12 @@
</a>
{{ expiredWarning }}
</script>
<script type='text/x-tmpl-mustache' id='migration_alert'>
<button class='migrate'>{{ migrate }}</button>
<div class='message'>
{{ migrationWarning }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='banner'>
<div class='body'>
<span class='icon warning'></span>
@ -727,10 +754,12 @@
<script type='text/javascript' src='js/views/install_view.js'></script>
<script type='text/javascript' src='js/views/banner_view.js'></script>
<script type='text/javascript' src='js/views/identity_key_send_error_view.js'></script>
<script type='text/javascript' src='js/views/migration_view.js'></script>
<script type='text/javascript' src='js/wall_clock_listener.js'></script>
<script type='text/javascript' src='js/rotate_signed_prekey_listener.js'></script>
<script type='text/javascript' src='js/keychange_listener.js'></script>
<script type='text/javascript' src='js/backup.js'></script>
<script type='text/javascript' src='js/background.js'></script>
</head>
<body>

View file

@ -100,9 +100,21 @@
return new textsecure.SyncRequest(textsecure.messaging, messageReceiver);
};
Whisper.events.on('start-shutdown', function() {
if (messageReceiver) {
messageReceiver.close().then(function() {
messageReceiver = null;
Whisper.events.trigger('shutdown-complete');
});
} else {
Whisper.events.trigger('shutdown-complete');
}
});
function init(firstRun) {
window.removeEventListener('online', init);
if (!Whisper.Registration.isDone()) { return; }
if (Whisper.Migration.inProgress()) { return; }
if (messageReceiver) { messageReceiver.close(); }

665
js/backup.js Normal file
View file

@ -0,0 +1,665 @@
;(function () {
'use strict';
window.Whisper = window.Whisper || {};
function stringToBlob(string) {
var buffer = dcodeIO.ByteBuffer.wrap(string).toArrayBuffer();
return new Blob([buffer]);
}
function stringify(object) {
for (var key in object) {
var val = object[key];
if (val instanceof ArrayBuffer) {
object[key] = {
type: 'ArrayBuffer',
encoding: 'base64',
data: dcodeIO.ByteBuffer.wrap(val).toString('base64')
};
} else if (val instanceof Object) {
object[key] = stringify(val);
}
}
return object;
}
function unstringify(object) {
if (!(object instanceof Object)) {
throw new Error('unstringify expects an object');
}
for (var key in object) {
var val = object[key];
if (val &&
val.type === 'ArrayBuffer' &&
val.encoding === 'base64' &&
typeof val.data === 'string' ) {
object[key] = dcodeIO.ByteBuffer.wrap(val.data, 'base64').toArrayBuffer();
} else if (val instanceof Object) {
object[key] = unstringify(object[key]);
}
}
return object;
}
function createOutputStream(fileWriter) {
var wait = Promise.resolve();
var count = 0;
return {
write: function(string) {
var i = count++;
wait = wait.then(function() {
return new Promise(function(resolve, reject) {
fileWriter.onwriteend = resolve;
fileWriter.onerror = reject;
fileWriter.onabort = reject;
fileWriter.write(stringToBlob(string));
});
});
return wait;
}
};
}
function exportNonMessages(idb_db, parent) {
return createFileAndWriter(parent, 'db.json').then(function(writer) {
return exportToJsonFile(idb_db, writer);
});
}
/**
* Export all data from an IndexedDB database
* @param {IDBDatabase} idb_db
*/
function exportToJsonFile(idb_db, fileWriter) {
return new Promise(function(resolve, reject) {
var storeNames = idb_db.objectStoreNames;
storeNames = _.without(storeNames, 'messages');
var exportedStoreNames = [];
if (storeNames.length === 0) {
throw new Error('No stores to export');
}
console.log('Exporting from these stores:', storeNames.join(', '));
var stream = createOutputStream(fileWriter);
stream.write('{');
_.each(storeNames, function(storeName) {
var transaction = idb_db.transaction(storeNames, "readwrite");
transaction.onerror = function(error) {
console.log(
'exportToJsonFile: transaction error',
error && error.stack ? error.stack : error
);
reject(error);
};
transaction.oncomplete = function() {
console.log('transaction complete');
};
var store = transaction.objectStore(storeName);
var request = store.openCursor();
var count = 0;
request.onerror = function(e) {
console.log('Error attempting to export store', storeName);
reject(e);
};
request.onsuccess = function(event) {
if (count === 0) {
console.log('cursor opened');
stream.write('"' + storeName + '": [');
}
var cursor = event.target.result;
if (cursor) {
if (count > 0) {
stream.write(',');
}
var jsonString = JSON.stringify(stringify(cursor.value));
stream.write(jsonString);
cursor.continue();
count++;
} else {
// no more
stream.write(']');
console.log('Exported', count, 'items from store', storeName);
exportedStoreNames.push(storeName);
if (exportedStoreNames.length < storeNames.length) {
stream.write(',');
} else {
console.log('Exported all stores');
stream.write('}').then(function() {
console.log('Finished writing all stores to disk');
resolve();
});
}
}
};
});
});
}
function importNonMessages(idb_db, parent) {
return readFileAsText(parent, 'db.json').then(function(string) {
return importFromJsonString(idb_db, string);
});
}
/**
* Import data from JSON into an IndexedDB database. This does not delete any existing data
* from the database, so keys could clash
*
* @param {IDBDatabase} idb_db
* @param {string} jsonString - data to import, one key per object store
*/
function importFromJsonString(idb_db, jsonString) {
return new Promise(function(resolve, reject) {
var importObject = JSON.parse(jsonString);
var storeNames = _.keys(importObject);
console.log('Importing to these stores:', storeNames);
var transaction = idb_db.transaction(storeNames, "readwrite");
transaction.onerror = reject;
_.each(storeNames, function(storeName) {
console.log('Importing items for store', storeName);
var count = 0;
_.each(importObject[storeName], function(toAdd) {
toAdd = unstringify(toAdd);
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');
resolve();
}
}
};
request.onerror = function(error) {
console.log(
'Error adding object to store',
storeName,
':',
toAdd
);
reject(error);
};
});
});
});
}
function openDatabase() {
var migrations = Whisper.Database.migrations;
var version = migrations[migrations.length - 1].version;
var DBOpenRequest = window.indexedDB.open('signal', version);
return new Promise(function(resolve, reject) {
// these two event handlers act on the IDBDatabase object,
// when the database is opened successfully, or not
DBOpenRequest.onerror = reject;
DBOpenRequest.onsuccess = function() {
resolve(DBOpenRequest.result);
};
// This event handles the event whereby a new version of
// the database needs to be created Either one has not
// been created before, or a new version number has been
// submitted via the window.indexedDB.open line above
DBOpenRequest.onupgradeneeded = reject;
});
}
function createDirectory(parent, name) {
var sanitized = sanitizeFileName(name);
return new Promise(function(resolve, reject) {
parent.getDirectory(sanitized, {create: true, exclusive: true}, resolve, reject);
});
}
function createFileAndWriter(parent, name) {
var sanitized = sanitizeFileName(name);
return new Promise(function(resolve, reject) {
parent.getFile(sanitized, {create: true, exclusive: true}, function(file) {
return file.createWriter(function(writer) {
resolve(writer);
}, reject);
}, reject);
});
}
function readFileAsText(parent, name) {
return new Promise(function(resolve, reject) {
parent.getFile(name, {create: false, exclusive: true}, function(fileEntry) {
fileEntry.file(function(file) {
var reader = new FileReader();
reader.onload = function(e) {
resolve(e.target.result);
};
reader.onerror = reject;
reader.onabort = reject;
reader.readAsText(file);
}, reject);
}, reject);
});
}
function readFileAsArrayBuffer(parent, name) {
return new Promise(function(resolve, reject) {
parent.getFile(name, {create: false, exclusive: true}, function(fileEntry) {
fileEntry.file(function(file) {
var reader = new FileReader();
reader.onload = function(e) {
resolve(e.target.result);
};
reader.onerror = reject;
reader.onabort = reject;
reader.readAsArrayBuffer(file);
}, reject);
}, reject);
});
}
function getAttachmentFileName(attachment) {
return attachment.fileName || (attachment.id + '.' + attachment.contentType.split('/')[1]);
}
function readAttachment(parent, message, attachment) {
var name = getAttachmentFileName(attachment);
var sanitized = sanitizeFileName(name);
var attachmentDir = message.received_at;
return new Promise(function(resolve, reject) {
parent.getDirectory(attachmentDir, {create: false, exclusive: true}, function(dir) {
return readFileAsArrayBuffer(dir, sanitized ).then(function(contents) {
attachment.data = contents;
return resolve();
}, reject);
}, reject);
});
}
function writeAttachment(dir, attachment) {
var filename = getAttachmentFileName(attachment);
return createFileAndWriter(dir, filename).then(function(writer) {
var stream = createOutputStream(writer);
return stream.write(attachment.data);
});
}
function writeAttachments(parentDir, name, messageId, attachments) {
return createDirectory(parentDir, messageId).then(function(dir) {
return Promise.all(_.map(attachments, function(attachment) {
return writeAttachment(dir, attachment);
}));
}).catch(function(error) {
console.log(
'writeAttachments: error exporting conversation',
name,
':',
error && error.stack ? error.stack : error
);
return Promise.reject(error);
});
}
function sanitizeFileName(filename) {
return filename.toString().replace(/[^a-z0-9.,+()'"#\- ]/gi, '_');
}
function exportConversation(idb_db, name, conversation, dir) {
console.log('exporting conversation', name);
return createFileAndWriter(dir, 'messages.json').then(function(writer) {
return new Promise(function(resolve, reject) {
var transaction = idb_db.transaction('messages', "readwrite");
transaction.onerror = function(e) {
console.log(
'exportConversation transaction error for conversation',
name,
':',
e && e.stack ? e.stack : e
);
return reject(e);
};
transaction.oncomplete = function() {
// this doesn't really mean anything - we may have attachment processing to do
};
var store = transaction.objectStore('messages');
var index = store.index('conversation');
var range = IDBKeyRange.bound([conversation.id, 0], [conversation.id, Number.MAX_VALUE]);
var promiseChain = Promise.resolve();
var count = 0;
var request = index.openCursor(range);
var stream = createOutputStream(writer);
stream.write('{"messages":[');
request.onerror = function(e) {
console.log(
'exportConversation: error pulling messages for conversation',
name,
':',
e && e.stack ? e.stack : e
);
return reject(e);
};
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;
message.attachments = _.map(attachments, function(attachment) {
return _.omit(attachment, ['data']);
});
var jsonString = JSON.stringify(stringify(message));
stream.write(jsonString);
if (attachments.length) {
var process = function() {
return writeAttachments(dir, name, messageId, attachments);
};
promiseChain = promiseChain.then(process);
}
count += 1;
cursor.continue();
} else {
var promise = stream.write(']}');
promiseChain = promiseChain.then(promise);
return promiseChain.then(function() {
console.log('done exporting conversation', name);
return resolve();
}, function(error) {
console.log(
'exportConversation: error exporting conversation',
name,
':',
error && error.stack ? error.stack : error
);
return reject(error);
});
}
};
});
});
}
function getConversationDirName(conversation) {
var name = conversation.active_at || 'never';
if (conversation.type === 'private') {
name += ' (' + (conversation.name || conversation.id) + ')';
} else {
name += ' (' + conversation.name + ')';
}
return name;
}
function getConversationLoggingName(conversation) {
var name = conversation.active_at || 'never';
name += ' (' + conversation.id + ')';
return name;
}
function exportConversations(idb_db, parentDir) {
return new Promise(function(resolve, reject) {
var transaction = idb_db.transaction('conversations', "readwrite");
transaction.onerror = function(e) {
console.log(
'exportConversations: transaction error:',
e && e.stack ? e.stack : e
);
return reject(e);
};
transaction.oncomplete = function() {
// not really very useful - fires at unexpected times
};
var promiseChain = Promise.resolve();
var store = transaction.objectStore('conversations');
var request = store.openCursor();
request.onerror = function(e) {
console.log(
'exportConversations: error pulling conversations:',
e && e.stack ? e.stack : e
);
return reject(e);
};
request.onsuccess = function(event) {
var cursor = event.target.result;
if (cursor && cursor.value) {
var conversation = cursor.value;
var dir = getConversationDirName(conversation);
var name = getConversationLoggingName(conversation);
var process = function() {
return createDirectory(parentDir, dir).then(function(dir) {
return exportConversation(idb_db, name, conversation, dir);
});
};
console.log('scheduling export for conversation', name);
promiseChain = promiseChain.then(process);
cursor.continue();
} else {
console.log('Done scheduling conversation exports');
return promiseChain.then(resolve, reject);
}
};
});
}
function getDirectory() {
return new Promise(function(resolve, reject) {
var w = extension.windows.getViews()[0];
if (!w || !w.chrome || !w.chrome.fileSystem) {
return reject(new Error('Ran into problem accessing Chrome filesystem API'));
}
w.chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(entry) {
if (!entry) {
var error = new Error('Error choosing directory');
error.name = 'ChooseError';
return reject(error);
}
return resolve(entry);
});
});
}
function getDirContents(dir) {
return new Promise(function(resolve, reject) {
var reader = dir.createReader();
var contents = [];
var getContents = function() {
reader.readEntries(function(results) {
if (results.length) {
contents = contents.concat(results);
getContents();
} else {
return resolve(contents);
}
}, function(error) {
return reject(error);
});
};
getContents();
});
}
function loadAttachments(dir, message) {
return Promise.all(_.map(message.attachments, function(attachment) {
return readAttachment(dir, message, attachment);
}));
}
function saveAllMessages(idb_db, messages) {
if (!messages.length) {
return Promise.resolve();
}
return new Promise(function(resolve, reject) {
var transaction = idb_db.transaction('messages', "readwrite");
transaction.onerror = function(e) {
console.log(
'importConversations transaction error:',
e && e.stack ? e.stack : e
);
return reject(e);
};
var store = transaction.objectStore('messages');
var conversationId = messages[0].conversationId;
var count = 0;
_.forEach(messages, function(message) {
var request = store.put(message, message.id);
request.onsuccess = function(event) {
count += 1;
if (count === messages.length) {
console.log(
'Done importing',
messages.length,
'messages for conversation',
conversationId
);
resolve();
}
};
request.onerror = function(event) {
console.log('Error adding object to store:', error);
reject();
};
});
});
}
function importConversation(idb_db, dir) {
return readFileAsText(dir, 'messages.json').then(function(contents) {
var promiseChain = Promise.resolve();
var json = JSON.parse(contents);
var messages = json.messages;
_.forEach(messages, function(message) {
message = unstringify(message);
if (message.attachments && message.attachments.length) {
var process = function() {
return loadAttachments(dir, message);
};
promiseChain = promiseChain.then(process);
}
});
return promiseChain.then(function() {
return saveAllMessages(idb_db, messages);
});
}, function() {
console.log('Warning: could not access messages.json in directory: ' + dir.fullPath);
});
}
function importConversations(idb_db, dir) {
return getDirContents(dir).then(function(contents) {
var promiseChain = Promise.resolve();
_.forEach(contents, function(conversationDir) {
if (!conversationDir.isDirectory) {
return;
}
var process = function() {
return importConversation(idb_db, conversationDir);
};
promiseChain = promiseChain.then(process);
});
return promiseChain;
});
}
function getDisplayPath(entry) {
return new Promise(function(resolve) {
chrome.fileSystem.getDisplayPath(entry, function(path) {
return resolve(path);
});
});
}
function getTimestamp() {
return moment().format('YYYY MMM Do [at] h.mm.ss a');
}
Whisper.Backup = {
backupToDirectory: function() {
return getDirectory().then(function(directoryEntry) {
var idb;
var dir;
return openDatabase().then(function(idb_db) {
idb = idb_db;
var name = 'Signal Export ' + getTimestamp();
return createDirectory(directoryEntry, name);
}).then(function(directory) {
dir = directory;
return exportNonMessages(idb, dir);
}).then(function() {
return exportConversations(idb, dir);
}).then(function() {
return getDisplayPath(dir);
});
}).then(function(path) {
console.log('done backing up!');
return path;
}, function(error) {
console.log(
'the backup went wrong:',
error && error.stack ? error.stack : error
);
return Promise.reject(error);
});
},
importFromDirectory: function() {
return getDirectory().then(function(directoryEntry) {
var idb;
return openDatabase().then(function(idb_db) {
idb = idb_db;
return importNonMessages(idb_db, directoryEntry);
}).then(function() {
return importConversations(idb, directoryEntry);
}).then(function() {
return displayPath(directoryEntry);
});
}).then(function(path) {
console.log('done restoring from backup!');
return path;
}, function(error) {
console.log(
'the import went wrong:',
error && error.stack ? error.stack : error
);
return Promise.reject(error);
});
}
};
}());

View file

@ -38292,7 +38292,7 @@ MessageReceiver.prototype.extend({
},
close: function() {
this.socket.close(3000, 'called close');
delete this.listeners;
return this.drain();
},
onopen: function() {
console.log('websocket open');
@ -38400,6 +38400,19 @@ MessageReceiver.prototype.extend({
// processing is complete by the time it runs.
Promise.all(incoming).then(queueDispatch, queueDispatch);
},
drain: function() {
var incoming = this.incoming;
this.incoming = [];
var queueDispatch = function() {
return this.addToQueue(function() {
console.log('drained');
});
}.bind(this);
// This promise will resolve when there are no more messages to be processed.
return Promise.all(incoming).then(queueDispatch, queueDispatch);
},
updateProgress: function(count) {
// count by 10s
if (count % 10 !== 0) {

View file

@ -149,6 +149,13 @@
var banner = new Whisper.ExpiredAlertBanner().render();
banner.$el.prependTo(this.$el);
this.$el.addClass('expired');
} else if (Whisper.Migration.inProgress()) {
this.appLoadingScreen.remove();
this.appLoadingScreen = null;
this.showMigrationScreen();
} else if (storage.get('migrationEnabled')) {
var migrationBanner = new Whisper.MigrationAlertBanner().render();
migrationBanner.$el.prependTo(this.$el);
}
},
render_attributes: {
@ -169,7 +176,16 @@
'select .gutter .conversation-list-item': 'openConversation',
'input input.search': 'filterContacts',
'click .restart-signal': 'reloadBackgroundPage',
'show .lightbox': 'showLightbox'
'show .lightbox': 'showLightbox',
'click .migrate': 'confirmMigration'
},
confirmMigration: function() {
this.confirm(i18n('confirmMigration'), i18n('migrate')).then(this.showMigrationScreen.bind(this));
},
showMigrationScreen: function() {
this.migrationScreen = new Whisper.MigrationView();
this.migrationScreen.render();
this.migrationScreen.$el.prependTo(this.el);
},
startConnectionListener: function() {
this.interval = setInterval(function() {
@ -287,4 +303,14 @@
}
});
Whisper.MigrationAlertBanner = Whisper.View.extend({
templateName: 'migration_alert',
className: 'expiredAlert clearfix',
render_attributes: function() {
return {
migrationWarning: i18n('migrationWarning'),
migrate: i18n('migrate'),
};
}
});
})();

180
js/views/migration_view.js Normal file
View file

@ -0,0 +1,180 @@
;(function () {
'use strict';
window.Whisper = window.Whisper || {};
var State = {
DISCONNECTING: 1,
EXPORTING: 2,
COMPLETE: 3
};
Whisper.Migration = {
isComplete: function() {
return storage.get('migrationState') === State.COMPLETE;
},
inProgress: function() {
return storage.get('migrationState') > 0 || this.everComplete();
},
markComplete: function(target) {
storage.put('migrationState', State.COMPLETE);
storage.put('migrationEverCompleted', true);
if (target) {
storage.put('migrationStorageLocation', target);
}
},
cancel: function() {
storage.remove('migrationState');
},
beginExport: function() {
storage.put('migrationState', State.EXPORTING);
return Whisper.Backup.backupToDirectory();
},
init: function() {
storage.put('migrationState', State.DISCONNECTING);
Whisper.events.trigger('start-shutdown');
},
everComplete: function() {
return Boolean(storage.get('migrationEverCompleted'));
},
getExportLocation: function() {
return storage.get('migrationStorageLocation');
}
};
Whisper.MigrationView = Whisper.View.extend({
templateName: 'app-migration-screen',
className: 'app-loading-screen',
events: {
'click .export': 'onClickExport',
'click .debug-log': 'onClickDebugLog'
},
initialize: function() {
if (!Whisper.Migration.inProgress()) {
return;
}
// We could be wedged in an 'in progress' state, the migration was started then the
// app restarted in the middle.
if (Whisper.Migration.everComplete()) {
// If the user has ever successfully exported before, we'll show the 'finished'
// screen with the 'Export again' button.
Whisper.Migration.markComplete();
} else if (!Whisper.Migration.isComplete()) {
// This takes the user back to the very beginning of the process.
Whisper.Migration.cancel();
}
},
render_attributes: function() {
var message;
var exportButton;
var hideProgress = Whisper.Migration.isComplete();
var debugLogButton = i18n('submitDebugLog');
if (this.error) {
return {
message: i18n('exportError'),
hideProgress: true,
exportButton: i18n('exportAgain'),
debugLogButton: i18n('submitDebugLog'),
};
}
switch (storage.get('migrationState')) {
case State.COMPLETE:
var location = Whisper.Migration.getExportLocation() || i18n('selectedLocation');
message = i18n('exportComplete', location);
exportButton = i18n('exportAgain');
debugLogButton = null;
break;
case State.EXPORTING:
message = i18n('exporting');
break;
case State.DISCONNECTING:
message = i18n('migrationDisconnecting');
break;
default:
hideProgress = true;
message = i18n('exportInstructions');
exportButton = i18n('export');
debugLogButton = null;
}
return {
hideProgress: hideProgress,
message: message,
exportButton: exportButton,
debugLogButton: debugLogButton,
};
},
onClickDebugLog: function() {
this.openDebugLog();
},
openDebugLog: function() {
this.closeDebugLog();
this.debugLogView = new Whisper.DebugLogView();
this.debugLogView.$el.appendTo(this.el);
},
closeDebugLog: function() {
if (this.debugLogView) {
this.debugLogView.remove();
this.debugLogView = null;
}
},
onClickExport: function() {
this.error = null;
if (!Whisper.Migration.everComplete()) {
return this.beginMigration();
}
// Different behavior for the user's second time through
Whisper.Migration.beginExport()
.then(this.completeMigration.bind(this))
.catch(function(error) {
if (error.name !== 'ChooseError') {
this.error = error.message;
}
// Even if we run into an error, we call this complete because the user has
// completed the process once before.
Whisper.Migration.markComplete();
this.render();
}.bind(this));
this.render();
},
beginMigration: function() {
// tells MessageReceiver to disconnect and drain its queue, will fire
// 'shutdown-complete' event when that is done.
Whisper.Migration.init();
Whisper.events.on('shutdown-complete', function() {
Whisper.Migration.beginExport()
.then(this.completeMigration.bind(this))
.catch(this.onError.bind(this));
// Rendering because we're now in the 'exporting' state
this.render();
}.bind(this));
// Rendering because we're now in the 'disconnected' state
this.render();
},
completeMigration: function(target) {
// This will prevent connection to the server on future app launches
Whisper.Migration.markComplete(target);
this.render();
},
onError: function(error) {
if (error.name === 'ChooseError') {
this.cancelMigration();
} else {
Whisper.Migration.cancel();
this.error = error.message;
this.render();
}
},
cancelMigration: function() {
Whisper.Migration.cancel();
this.render();
}
});
}());

View file

@ -47,10 +47,11 @@
this.$el.html(Mustache.render(template, attrs, partials));
return this;
},
confirm: function(message) {
confirm: function(message, okText) {
return new Promise(function(resolve, reject) {
var dialog = new Whisper.ConfirmationDialogView({
message: message,
okText: okText,
resolve: resolve,
reject: reject
});

View file

@ -41,7 +41,7 @@ MessageReceiver.prototype.extend({
},
close: function() {
this.socket.close(3000, 'called close');
delete this.listeners;
return this.drain();
},
onopen: function() {
console.log('websocket open');
@ -149,6 +149,19 @@ MessageReceiver.prototype.extend({
// processing is complete by the time it runs.
Promise.all(incoming).then(queueDispatch, queueDispatch);
},
drain: function() {
var incoming = this.incoming;
this.incoming = [];
var queueDispatch = function() {
return this.addToQueue(function() {
console.log('drained');
});
}.bind(this);
// This promise will resolve when there are no more messages to be processed.
return Promise.all(incoming).then(queueDispatch, queueDispatch);
},
updateProgress: function(count) {
// count by 10s
if (count % 10 !== 0) {

View file

@ -12,7 +12,7 @@
"permissions": [
"unlimitedStorage",
"notifications",
{"fileSystem": ["write"]},
{"fileSystem": ["write", "directory"]},
"alarms",
"fullscreen",
"audioCapture"

View file

@ -524,7 +524,6 @@ input[type=text], input[type=search], textarea {
.expiredAlert {
background: #F3F3A7;
padding: 10px;
line-height: 36px;
button {
float: right;
@ -537,6 +536,10 @@ input[type=text], input[type=search], textarea {
background: $blue;
margin-left: 20px;
}
.message {
padding: 10px 0;
}
}
.inbox {
@ -575,6 +578,10 @@ input[type=text], input[type=search], textarea {
width: 78px;
height: 22px;
}
.message {
-webkit-user-select: text;
max-width: 35em;
}
.dot {
width: 14px;

View file

@ -471,8 +471,7 @@ input[type=text]:active, input[type=text]:focus, input[type=search]:active, inpu
.expiredAlert {
background: #F3F3A7;
padding: 10px;
line-height: 36px; }
padding: 10px; }
.expiredAlert button {
float: right;
border: none;
@ -483,6 +482,8 @@ input[type=text]:active, input[type=text]:focus, input[type=search]:active, inpu
padding: 0 20px;
background: #2090ea;
margin-left: 20px; }
.expiredAlert .message {
padding: 10px 0; }
.inbox {
position: relative; }
@ -512,6 +513,9 @@ input[type=text]:active, input[type=text]:focus, input[type=search]:active, inpu
margin-right: auto;
width: 78px;
height: 22px; }
.app-loading-screen .message {
-webkit-user-select: text;
max-width: 35em; }
.app-loading-screen .dot {
width: 14px;
height: 14px;

View file

@ -626,6 +626,7 @@
<script type='text/javascript' src='../js/views/scroll_down_button_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/banner_view.js' data-cover></script>
<script type="text/javascript" src='../js/views/identity_key_send_error_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/migration_view.js'></script>
<script type="text/javascript" src="views/whisper_view_test.js"></script>
<script type="text/javascript" src="views/group_update_view_test.js"></script>
@ -638,6 +639,7 @@
<script type="text/javascript" src="views/last_seen_indicator_view_test.js"></script>
<script type='text/javascript' src='views/scroll_down_button_view_test.js'></script>
<script type="text/javascript" src="models/conversations_test.js"></script>
<script type="text/javascript" src="models/messages_test.js"></script>