Import: choice on first startup, workflow, ported to Node.js fs API

FREEBIE
This commit is contained in:
Scott Nonnenberg 2017-08-07 17:24:59 -07:00
parent 9c8fe1a9d8
commit ba347744ff
No known key found for this signature in database
GPG key ID: A4931C09644C654B
12 changed files with 616 additions and 217 deletions

View file

@ -53,7 +53,35 @@
};
storage.fetch();
// We need this 'first' check because we don't want to start the app up any other time
// than the first time. And storage.fetch() will cause onready() to fire.
var first = true;
storage.onready(function() {
if (!first) {
return;
}
first = false;
start();
});
window.getSyncRequest = function() {
return new textsecure.SyncRequest(textsecure.messaging, messageReceiver);
};
Whisper.events.on('shutdown', function() {
if (messageReceiver) {
messageReceiver.close().then(function() {
messageReceiver = null;
Whisper.events.trigger('shutdown-complete');
});
} else {
Whisper.events.trigger('shutdown-complete');
}
});
function start() {
ConversationController.load();
window.dispatchEvent(new Event('storage_ready'));
@ -61,7 +89,7 @@
console.log("listening for registration events");
Whisper.events.on('registration_done', function() {
console.log("handling registration event");
init(true);
connect(true);
});
var appView = window.owsDesktopApp.appView = new Whisper.AppView({el: $('body')});
@ -70,13 +98,16 @@
Whisper.RotateSignedPreKeyListener.init(Whisper.events);
Whisper.ExpiringMessagesListener.init(Whisper.events);
if (Whisper.Registration.everDone()) {
init();
if (Whisper.Import.isIncomplete()) {
console.log('Import was interrupted, showing import error screen');
appView.openImporter();
} else if (Whisper.Registration.everDone()) {
connect();
appView.openInbox({
initialLoadComplete: initialLoadComplete
});
} else {
appView.openInstaller();
appView.openInstallChoice();
}
Whisper.events.on('showDebugLog', function() {
@ -109,7 +140,7 @@
});
}
});
});
}
window.getSyncRequest = function() {
return new textsecure.SyncRequest(textsecure.messaging, messageReceiver);
@ -126,11 +157,12 @@
}
});
function init(firstRun) {
window.removeEventListener('online', init);
function connect(firstRun) {
window.removeEventListener('online', connect);
if (!Whisper.Registration.isDone()) { return; }
if (Whisper.Migration.inProgress()) { return; }
if (Whisper.Import.isIncomplete()) { return; }
if (messageReceiver) { messageReceiver.close(); }
@ -398,13 +430,13 @@
// Failed to connect to server
if (navigator.onLine) {
console.log('retrying in 1 minute');
setTimeout(init, 60000);
setTimeout(connect, 60000);
Whisper.events.trigger('reconnectTimer');
} else {
console.log('offline');
if (messageReceiver) { messageReceiver.close(); }
window.addEventListener('online', init);
window.addEventListener('online', connect);
}
return;
}

View file

@ -2,10 +2,12 @@
'use strict';
window.Whisper = window.Whisper || {};
function stringToBlob(string) {
var buffer = dcodeIO.ByteBuffer.wrap(string).toArrayBuffer();
return new Blob([buffer]);
}
var electronRemote = require('electron').remote;
var dialog = electronRemote.dialog;
var BrowserWindow = electronRemote.BrowserWindow;
var fs = require('fs');
var path = require('path');
function stringify(object) {
for (var key in object) {
@ -41,21 +43,34 @@
return object;
}
function createOutputStream(fileWriter) {
function createOutputStream(writer) {
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 new Promise(function(resolve) {
if (writer.write(string)) {
return resolve();
}
// If write() returns true, we don't need to wait for the drain event
// https://nodejs.org/dist/latest-v7.x/docs/api/stream.html#stream_class_stream_writable
writer.once('drain', resolve);
// We don't register for the 'error' event here, only in close(). Otherwise,
// we'll get "Possible EventEmitter memory leak detected" warnings.
});
});
return wait;
},
close: function() {
return wait.then(function() {
return new Promise(function(resolve, reject) {
writer.once('finish', resolve);
writer.once('error', reject);
writer.end();
});
});
}
};
}
@ -85,7 +100,7 @@
stream.write('{');
_.each(storeNames, function(storeName) {
var transaction = idb_db.transaction(storeNames, "readwrite");
var transaction = idb_db.transaction(storeNames, 'readwrite');
transaction.onerror = function(error) {
console.log(
'exportToJsonFile: transaction error',
@ -129,7 +144,9 @@
stream.write(',');
} else {
console.log('Exported all stores');
stream.write('}').then(function() {
stream.write('}');
stream.close().then(function() {
console.log('Finished writing all stores to disk');
resolve();
});
@ -158,13 +175,19 @@
var importObject = JSON.parse(jsonString);
var storeNames = _.keys(importObject);
console.log('Importing to these stores:', storeNames);
console.log('Importing to these stores:', storeNames.join(', '));
var transaction = idb_db.transaction(storeNames, "readwrite");
var transaction = idb_db.transaction(storeNames, 'readwrite');
transaction.onerror = reject;
_.each(storeNames, function(storeName) {
console.log('Importing items for store', storeName);
if (!importObject[storeName].length) {
delete importObject[storeName];
return;
}
var count = 0;
_.each(importObject[storeName], function(toAdd) {
toAdd = unstringify(toAdd);
@ -218,52 +241,56 @@
}
function createDirectory(parent, name) {
var sanitized = sanitizeFileName(name);
return new Promise(function(resolve, reject) {
parent.getDirectory(sanitized, {create: true, exclusive: true}, resolve, reject);
var sanitized = sanitizeFileName(name);
var targetDir = path.join(parent, sanitized);
fs.mkdir(targetDir, function(error) {
if (error) {
return reject(error);
}
return resolve(targetDir);
});
});
}
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);
return new Promise(function(resolve) {
var sanitized = sanitizeFileName(name);
var targetPath = path.join(parent, sanitized);
var options = {
flags: 'wx'
};
return resolve(fs.createWriteStream(targetPath, options));
});
}
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);
var targetPath = path.join(parent, name);
fs.readFile(targetPath, 'utf8', function(error, string) {
if (error) {
return reject(error);
}
return resolve(string);
});
});
}
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);
var targetPath = path.join(parent, name);
// omitting the encoding to get a buffer back
fs.readFile(targetPath, function(error, buffer) {
if (error) {
return reject(error);
}
// Buffer instances are also Uint8Array instances
// https://nodejs.org/docs/latest/api/buffer.html#buffer_buffers_and_typedarray
return resolve(buffer.buffer);
});
});
}
@ -299,15 +326,14 @@
}
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);
var name = getAttachmentFileName(attachment);
var sanitized = sanitizeFileName(name);
var attachmentDir = path.join(parent, message.received_at.toString());
return readFileAsArrayBuffer(attachmentDir, sanitized).then(function(contents) {
attachment.data = contents;
return resolve();
}, reject);
});
}
@ -316,7 +342,8 @@
var filename = getAttachmentFileName(attachment);
return createFileAndWriter(dir, filename).then(function(writer) {
var stream = createOutputStream(writer);
return stream.write(attachment.data);
stream.write(new Buffer(attachment.data));
return stream.close();
});
}
@ -344,7 +371,7 @@
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");
var transaction = idb_db.transaction('messages', 'readwrite');
transaction.onerror = function(e) {
console.log(
'exportConversation transaction error for conversation',
@ -406,10 +433,11 @@
count += 1;
cursor.continue();
} else {
var promise = stream.write(']}');
promiseChain = promiseChain.then(promise);
stream.write(']}');
return promiseChain.then(function() {
var promise = stream.close();
return promiseChain.then(promise).then(function() {
console.log('done exporting conversation', name);
return resolve();
}, function(error) {
@ -457,7 +485,7 @@
function exportConversations(idb_db, parentDir) {
return new Promise(function(resolve, reject) {
var transaction = idb_db.transaction('conversations', "readwrite");
var transaction = idb_db.transaction('conversations', 'readwrite');
transaction.onerror = function(e) {
console.log(
'exportConversations: transaction error:',
@ -503,44 +531,40 @@
});
}
function getDirectory() {
function getDirectory(options) {
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'));
}
var browserWindow = BrowserWindow.getFocusedWindow();
var dialogOptions = {
title: options.title,
properties: ['openDirectory'],
buttonLabel: options.buttonLabel
};
w.chrome.fileSystem.chooseEntry({type: 'openDirectory'}, function(entry) {
if (!entry) {
dialog.showOpenDialog(browserWindow, dialogOptions, function(directory) {
if (!directory || !directory[0]) {
var error = new Error('Error choosing directory');
error.name = 'ChooseError';
return reject(error);
}
return resolve(entry);
return resolve(directory[0]);
});
});
}
function getDirContents(dir) {
return new Promise(function(resolve, reject) {
var reader = dir.createReader();
var contents = [];
fs.readdir(dir, function(err, files) {
if (err) {
return reject(err);
}
var getContents = function() {
reader.readEntries(function(results) {
if (results.length) {
contents = contents.concat(results);
getContents();
} else {
return resolve(contents);
}
}, function(error) {
return reject(error);
files = _.map(files, function(file) {
return path.join(dir, file);
});
};
getContents();
resolve(files);
});
});
}
@ -556,10 +580,10 @@
}
return new Promise(function(resolve, reject) {
var transaction = idb_db.transaction('messages', "readwrite");
var transaction = idb_db.transaction('messages', 'readwrite');
transaction.onerror = function(e) {
console.log(
'importConversations transaction error:',
'saveAllMessages transaction error:',
e && e.stack ? e.stack : e
);
return reject(e);
@ -614,7 +638,7 @@
return saveAllMessages(idb_db, messages);
});
}, function() {
console.log('Warning: could not access messages.json in directory: ' + dir.fullPath);
console.log('Warning: could not access messages.json in directory: ' + dir);
});
}
@ -623,7 +647,7 @@
var promiseChain = Promise.resolve();
_.forEach(contents, function(conversationDir) {
if (!conversationDir.isDirectory) {
if (!fs.statSync(conversationDir).isDirectory()) {
return;
}
@ -638,10 +662,45 @@
});
}
function getDisplayPath(entry) {
return new Promise(function(resolve) {
chrome.fileSystem.getDisplayPath(entry, function(path) {
return resolve(path);
function clearAllStores(idb_db) {
return new Promise(function(resolve, reject) {
console.log('Clearing all indexeddb stores');
var storeNames = idb_db.objectStoreNames;
var transaction = idb_db.transaction(storeNames, 'readwrite');
transaction.oncomplete = function() {
// unused
};
transaction.onerror = function(error) {
console.log(
'saveAllMessages transaction error:',
error && error.stack ? error.stack : error
);
return reject(error);
};
var count = 0;
_.forEach(storeNames, function(storeName) {
var store = transaction.objectStore(storeName);
var request = store.clear();
request.onsuccess = function() {
count += 1;
console.log('Done clearing store', storeName);
if (count >= storeNames.length) {
console.log('Done clearing all indexeddb stores');
return resolve();
}
};
request.onerror = function(error) {
console.log(
'clearAllStores transaction error:',
error && error.stack ? error.stack : error
);
return reject(error);
};
});
});
}
@ -651,21 +710,30 @@
}
Whisper.Backup = {
clearDatabase: function() {
return openDatabase().then(function(idb_db) {
return clearAllStores(idb_db);
});
},
backupToDirectory: function() {
return getDirectory().then(function(directoryEntry) {
var options = {
title: i18n('exportChooserTitle'),
buttonLabel: i18n('exportButton'),
};
return getDirectory(options).then(function(directory) {
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 createDirectory(directory, name);
}).then(function(created) {
dir = created;
return exportNonMessages(idb, dir);
}).then(function() {
return exportConversations(idb, dir);
}).then(function() {
return getDisplayPath(dir);
return dir;
});
}).then(function(path) {
console.log('done backing up!');
@ -679,15 +747,19 @@
});
},
importFromDirectory: function() {
return getDirectory().then(function(directoryEntry) {
var options = {
title: i18n('importChooserTitle'),
buttonLabel: i18n('importButton'),
};
return getDirectory(options).then(function(directory) {
var idb;
return openDatabase().then(function(idb_db) {
idb = idb_db;
return importNonMessages(idb_db, directoryEntry);
return importNonMessages(idb_db, directory);
}).then(function() {
return importConversations(idb, directoryEntry);
return importConversations(idb, directory);
}).then(function() {
return displayPath(directoryEntry);
return directory;
});
}).then(function(path) {
console.log('done restoring from backup!');

View file

@ -29,15 +29,47 @@
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();
var importView = this.importView = new Whisper.ImportView();
this.listenTo(importView, 'cancel', this.openInstallChoice.bind(this));
this.openView(this.importView);
},
closeImporter: function() {
if (this.importView) {
this.importView.remove();
this.importView = null;
}
},
openInstaller: function() {
this.closeInstaller();
this.installView = new Whisper.InstallView();
if (Whisper.Registration.everDone()) {
this.installView.selectStep(3);
this.installView.hideDots();
}
this.closeInstallChoice();
var installView = this.installView = new Whisper.InstallView();
this.listenTo(installView, 'cancel', this.openInstallChoice.bind(this));
this.openView(this.installView);
},
closeInstaller: function() {
if (this.installView) {
this.installView.remove();
this.installView = null;
}
},
openStandalone: function() {
if (window.config.environment !== 'production') {
this.closeInstaller();
@ -45,12 +77,6 @@
this.openView(this.installView);
}
},
closeInstaller: function() {
if (this.installView) {
this.installView.remove();
this.installView = null;
}
},
openInbox: function(options) {
options = options || {};
_.defaults(options, {initialLoadComplete: false});

153
js/views/import_view.js Normal file
View file

@ -0,0 +1,153 @@
/*
* vim: ts=4:sw=4:expandtab
*/
(function () {
'use strict';
window.Whisper = window.Whisper || {};
var State = {
IMPORTING: 1,
COMPLETE: 2
};
var IMPORT_STARTED = 'importStarted';
var IMPORT_COMPLETE = 'importComplete';
var IMPORT_LOCATION = 'importLocation';
Whisper.Import = {
isStarted: function() {
return Boolean(storage.get(IMPORT_STARTED));
},
isComplete: function() {
return Boolean(storage.get(IMPORT_COMPLETE));
},
isIncomplete: function() {
return this.isStarted() && !this.isComplete();
},
start: function() {
storage.put(IMPORT_STARTED, true);
},
complete: function() {
storage.put(IMPORT_COMPLETE, true);
},
saveLocation: function(location) {
storage.put(IMPORT_LOCATION, location);
},
reset: function() {
return Whisper.Backup.clearDatabase();
}
};
Whisper.ImportView = Whisper.View.extend({
templateName: 'app-migration-screen',
className: 'app-loading-screen',
events: {
'click .import': 'onImport',
'click .restart': 'onRestart',
'click .cancel': 'onCancel',
},
initialize: function() {
if (Whisper.Import.isIncomplete()) {
this.error = true;
}
this.render();
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'),
};
}
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');
}
return {
hideProgress: hideProgress,
message: message,
importButton: importButton,
restartButton: restartButton,
cancelButton: cancelButton,
};
},
onRestart: function() {
return window.restart();
},
onCancel: function() {
this.trigger('cancel');
},
onImport: function() {
this.error = null;
this.state = State.IMPORTING;
this.render();
var importLocation;
// Wait for prior database interaction to complete
this.pending = this.pending.then(function() {
// For resilience to interruptions, clear database both before import and after
return Whisper.Backup.clearDatabase();
}).then(function() {
Whisper.Import.start();
return Whisper.Backup.importFromDirectory();
}).then(function(directory) {
importLocation = 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() {
// Clearing any migration-related state inherited from the Chome App
storage.remove('migrationState');
storage.remove('migrationEnabled');
storage.remove('migrationEverCompleted');
storage.remove('migrationStorageLocation');
if (importLocation) {
Whisper.Import.saveLocation(importLocation);
}
Whisper.Import.complete();
this.state = State.COMPLETE;
this.render();
}.bind(this)).catch(function(error) {
if (error.name !== 'ChooseError') {
this.error = error.message;
console.log('Error importing:', error && error.stack ? error.stack : error);
}
this.state = null;
this.render();
if (this.error) {
return Whisper.Backup.clearDatabase();
}
}.bind(this));
}
});
})();

View file

@ -0,0 +1,31 @@
/*
* 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');
}
});
})();

View file

@ -7,8 +7,7 @@
Whisper.InstallView = Whisper.View.extend({
templateName: 'install_flow_template',
id: 'install',
className: 'main',
className: 'main install',
render_attributes: function() {
var twitterHref = 'https://twitter.com/whispersystems';
var signalHref = 'https://signal.org/install';
@ -48,6 +47,11 @@
this.$('#step1').show();
this.connect();
this.on('disconnected', this.reconnect);
if (Whisper.Registration.everDone()) {
this.installView.selectStep(3);
this.installView.hideDots();
}
},
connect: function() {
this.clearQR();

View file

@ -7,8 +7,7 @@
Whisper.StandaloneRegistrationView = Whisper.View.extend({
templateName: 'standalone',
id: 'install',
className: 'main',
className: 'install main',
initialize: function() {
this.accountManager = getAccountManager();