diff --git a/js/background.js b/js/background.js index f15797f42..99920f195 100644 --- a/js/background.js +++ b/js/background.js @@ -272,6 +272,10 @@ const clearDataView = new window.Whisper.ClearDataView().render(); $('body').append(clearDataView.el); }, + + shutdown: async () => { + await window.Signal.Data.shutdown(); + }, }; const currentVersion = window.getVersion(); @@ -297,10 +301,18 @@ await mandatoryMessageUpgrade({ upgradeMessageSchema }); await migrateAllToSQLCipher({ writeNewAttachmentData, Views }); await removeDatabase(); - await window.Signal.Data.removeIndexedDBFiles(); + try { + await window.Signal.Data.removeIndexedDBFiles(); + } catch (error) { + window.log.error( + 'Failed to remove IndexedDB files:', + error && error.stack ? error.stack : error + ); + } window.installStorage(window.newStorage); await window.storage.fetch(); + await storage.put('indexeddb-delete-needed', true); } Views.Initialization.setMessage(window.i18n('optimizingApplication')); diff --git a/js/models/messages.js b/js/models/messages.js index a12e78cb0..e82b02d75 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -899,7 +899,13 @@ return this.OUR_NUMBER; }, getContact() { - return ConversationController.getOrCreate(this.getSource(), 'private'); + const source = this.getSource(); + + if (!source) { + return null; + } + + return ConversationController.getOrCreate(source, 'private'); }, isOutgoing() { return this.get('type') === 'outgoing'; diff --git a/js/modules/data.js b/js/modules/data.js index 69c46f107..5d4bfac86 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -32,6 +32,9 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const _jobs = Object.create(null); const _DEBUG = false; let _jobCounter = 0; +let _shuttingDown = false; +let _shutdownCallback = null; +let _shutdownPromise = null; const channels = {}; @@ -39,6 +42,7 @@ module.exports = { _jobs, _cleanData, + shutdown, close, removeDB, removeIndexedDBFiles, @@ -190,7 +194,45 @@ function _cleanData(data) { return data; } +async function _shutdown() { + if (_shutdownPromise) { + return _shutdownPromise; + } + + _shuttingDown = true; + + const jobKeys = Object.keys(_jobs); + window.log.info( + `data.shutdown: starting process. ${jobKeys.length} jobs outstanding` + ); + + // No outstanding jobs, return immediately + if (jobKeys.length === 0) { + return null; + } + + // Outstanding jobs; we need to wait until the last one is done + _shutdownPromise = new Promise((resolve, reject) => { + _shutdownCallback = error => { + window.log.info('data.shutdown: process complete'); + if (error) { + return reject(error); + } + + return resolve(); + }; + }); + + return _shutdownPromise; +} + function _makeJob(fnName) { + if (_shuttingDown && fnName !== 'close') { + throw new Error( + `Rejecting SQL channel job (${fnName}); application is shutting down` + ); + } + _jobCounter += 1; const id = _jobCounter; @@ -237,8 +279,16 @@ function _updateJob(id, data) { function _removeJob(id) { if (_DEBUG) { _jobs[id].complete = true; - } else { - delete _jobs[id]; + return; + } + + delete _jobs[id]; + + if (_shutdownCallback) { + const keys = Object.keys(_jobs); + if (keys.length === 0) { + _shutdownCallback(); + } } } @@ -328,6 +378,14 @@ function keysFromArrayBuffer(keys, data) { // Top-level calls +async function shutdown() { + // Stop accepting new SQL jobs, flush outstanding queue + await _shutdown(); + + // Close database + await close(); +} + // Note: will need to restart the app after calling this, to set up afresh async function close() { await channels.close(); diff --git a/js/modules/web_api.js b/js/modules/web_api.js index 5b7b670e5..7ec79743d 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -167,6 +167,7 @@ function _createSocket(url, { certificateAuthority, proxyUrl, signature }) { return new WebSocket(url, null, null, headers, requestOptions); } +const FIVE_MINUTES = 1000 * 60 * 5; const agents = { unauth: null, auth: null, @@ -182,16 +183,21 @@ function _promiseAjax(providedUrl, options) { typeof options.timeout !== 'undefined' ? options.timeout : 10000; const { proxyUrl } = options; - const agentType = options.unathenticated ? 'unauth' : 'auth'; + const agentType = options.unauthenticated ? 'unauth' : 'auth'; - if (!agents[agentType]) { - if (proxyUrl) { - agents[agentType] = new ProxyAgent(proxyUrl); - } else { - agents[agentType] = new Agent(); + const { timestamp } = agents[agentType] || {}; + if (!timestamp || timestamp + FIVE_MINUTES < Date.now()) { + if (timestamp) { + log.info(`Cycling agent for type ${agentType}`); } + agents[agentType] = { + agent: proxyUrl + ? new ProxyAgent(proxyUrl) + : new Agent({ keepAlive: true }), + timestamp: Date.now(), + }; } - const agent = agents[agentType]; + const { agent } = agents[agentType]; const fetchOptions = { method: options.type, @@ -443,7 +449,7 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) { message = 'The server rejected our query, please file a bug report.'; } - e.message = message; + e.message = `${message} (original: ${e.message})`; throw e; }); } diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index cd046a444..202467d25 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -318,18 +318,20 @@ ); return Promise.resolve().then(() => { - textsecure.storage.remove('identityKey'); - textsecure.storage.remove('signaling_key'); - textsecure.storage.remove('password'); - textsecure.storage.remove('registrationId'); - textsecure.storage.remove('number_id'); - textsecure.storage.remove('device_name'); - textsecure.storage.remove('userAgent'); - textsecure.storage.remove('read-receipts-setting'); - + await Promise.all([ + textsecure.storage.remove('identityKey'), + textsecure.storage.remove('signaling_key'), + textsecure.storage.remove('password'), + textsecure.storage.remove('registrationId'), + textsecure.storage.remove('number_id'), + textsecure.storage.remove('device_name'), + textsecure.storage.remove('userAgent'), + textsecure.storage.remove('read-receipts-setting'), + ]); + // update our own identity key, which may have changed // if we're relinking after a reinstall on the master device - textsecure.storage.protocol.saveIdentityWithAttributes(pubKeyString, { + await textsecure.storage.protocol.saveIdentityWithAttributes(pubKeyString, { id: pubKeyString, publicKey: identityKeyPair.pubKey, firstUse: true, @@ -338,20 +340,20 @@ nonblockingApproval: true, }); - textsecure.storage.put('identityKey', identityKeyPair); - textsecure.storage.put('signaling_key', signalingKey); - textsecure.storage.put('password', password); - textsecure.storage.put('registrationId', registrationId); + await textsecure.storage.put('identityKey', identityKeyPair); + await textsecure.storage.put('signaling_key', signalingKey); + await textsecure.storage.put('password', password); + await textsecure.storage.put('registrationId', registrationId); if (userAgent) { - textsecure.storage.put('userAgent', userAgent); - } - if (readReceipts) { - textsecure.storage.put('read-receipt-setting', true); - } else { - textsecure.storage.put('read-receipt-setting', false); + await textsecure.storage.put('userAgent', userAgent); } - textsecure.storage.user.setNumberAndDeviceId(pubKeyString, 1); + await textsecure.storage.put( + 'read-receipt-setting', + Boolean(readReceipts) + ); + + await textsecure.storage.user.setNumberAndDeviceId(pubKeyString, 1); }); }, clearSessionsAndPreKeys() { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index fb37979a0..7a9952057 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -304,6 +304,9 @@ MessageReceiver.prototype.extend({ } envelope.id = envelope.serverGuid || window.getGuid(); + envelope.serverTimestamp = envelope.serverTimestamp + ? envelope.serverTimestamp.toNumber() + : null; return this.addToCache(envelope, plaintext).then( async () => { @@ -421,6 +424,11 @@ MessageReceiver.prototype.extend({ ); } const envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext); + envelope.id = envelope.serverGuid || item.id; + envelope.source = envelope.source || item.source; + envelope.sourceDevice = envelope.sourceDevice || item.sourceDevice; + envelope.serverTimestamp = + envelope.serverTimestamp || item.serverTimestamp; const { decrypted } = item; if (decrypted) { @@ -533,15 +541,19 @@ MessageReceiver.prototype.extend({ } if (item.get('version') === 2) { - item.set( - 'decrypted', - await MessageReceiver.arrayBufferToStringBase64(plaintext) - ); + item.set({ + source: envelope.source, + sourceDevice: envelope.sourceDevice, + serverTimestamp: envelope.serverTimestamp, + decrypted: await MessageReceiver.arrayBufferToStringBase64(plaintext), + }); } else { - item.set( - 'decrypted', - await MessageReceiver.arrayBufferToString(plaintext) - ); + item.set({ + source: envelope.source, + sourceDevice: envelope.sourceDevice, + serverTimestamp: envelope.serverTimestamp, + decrypted: await MessageReceiver.arrayBufferToString(plaintext), + }); } return textsecure.storage.unprocessed.save(item.attributes); @@ -719,12 +731,7 @@ MessageReceiver.prototype.extend({ .decrypt( window.Signal.Metadata.createCertificateValidator(serverTrustRoot), ciphertext.toArrayBuffer(), - Math.min( - envelope.serverTimestamp - ? envelope.serverTimestamp.toNumber() - : Date.now(), - Date.now() - ), + Math.min(envelope.serverTimestamp || Date.now(), Date.now()), me ) .then( @@ -765,7 +772,7 @@ MessageReceiver.prototype.extend({ throw error; } - return this.removeFromCache().then(() => { + return this.removeFromCache(envelope).then(() => { throw error; }); } diff --git a/main.js b/main.js index 220b36055..6eccb7b9a 100644 --- a/main.js +++ b/main.js @@ -330,27 +330,43 @@ function createWindow() { captureClicks(mainWindow); // Emitted when the window is about to be closed. - mainWindow.on('close', e => { + // Note: We do most of our shutdown logic here because all windows are closed by + // Electron before the app quits. + mainWindow.on('close', async e => { + console.log('close event', { + readyForShutdown: mainWindow ? mainWindow.readyForShutdown : null, + shouldQuit: windowState.shouldQuit(), + }); // If the application is terminating, just do the default if ( - windowState.shouldQuit() || config.environment === 'test' || - config.environment === 'test-lib' + config.environment === 'test-lib' || + (mainWindow.readyForShutdown && windowState.shouldQuit()) ) { return; } + // Prevent the shutdown + e.preventDefault(); + mainWindow.hide(); + // On Mac, or on other platforms when the tray icon is in use, the window // should be only hidden, not closed, when the user clicks the close button - if (usingTrayIcon || process.platform === 'darwin') { - e.preventDefault(); - mainWindow.hide(); - + if ( + !windowState.shouldQuit() && + (usingTrayIcon || process.platform === 'darwin') + ) { // toggle the visibility of the show/hide tray icon menu entries if (tray) { tray.updateContextMenu(); } + + return; } + + await requestShutdown(); + mainWindow.readyForShutdown = true; + app.quit(); }); // Emitted when the window is closed. @@ -641,6 +657,20 @@ app.on('ready', async () => { await sql.initialize({ configDir: userDataPath, key }); await sqlChannels.initialize(); + try { + const IDB_KEY = 'indexeddb-delete-needed'; + const item = await sql.getItemById(IDB_KEY); + if (item && item.value) { + await sql.removeIndexedDBFiles(); + await sql.removeItemById(IDB_KEY); + } + } catch (error) { + console.log( + '(ready event handler) error deleting IndexedDB:', + error && error.stack ? error.stack : error + ); + } + async function cleanupOrphanedAttachments() { const allAttachments = await attachments.getAllAttachments(userDataPath); const orphanedAttachments = await sql.removeKnownAttachments( @@ -692,7 +722,51 @@ function setupMenu(options) { Menu.setApplicationMenu(menu); } +async function requestShutdown() { + if (!mainWindow || !mainWindow.webContents) { + return; + } + + console.log('requestShutdown: Requesting close of mainWindow...'); + const request = new Promise((resolve, reject) => { + ipc.once('now-ready-for-shutdown', (_event, error) => { + console.log('requestShutdown: Response received'); + + if (error) { + return reject(error); + } + + return resolve(); + }); + mainWindow.webContents.send('get-ready-for-shutdown'); + + // We'll wait two minutes, then force the app to go down. This can happen if someone + // exits the app before we've set everything up in preload() (so the browser isn't + // yet listening for these events), or if there are a whole lot of stacked-up tasks. + // Note: two minutes is also our timeout for SQL tasks in data.js in the browser. + setTimeout(() => { + console.log( + 'requestShutdown: Response never received; forcing shutdown.' + ); + resolve(); + }, 2 * 60 * 1000); + }); + + try { + await request; + } catch (error) { + console.log( + 'requestShutdown error:', + error && error.stack ? error.stack : error + ); + } +} + app.on('before-quit', () => { + console.log('before-quit event', { + readyForShutdown: mainWindow ? mainWindow.readyForShutdown : null, + shouldQuit: windowState.shouldQuit(), + }); windowState.markShouldQuit(); }); diff --git a/preload.js b/preload.js index abf376e3e..a5ac6586a 100644 --- a/preload.js +++ b/preload.js @@ -151,6 +151,25 @@ ipc.on('delete-all-data', () => { } }); +ipc.on('get-ready-for-shutdown', async () => { + const { shutdown } = window.Events; + if (!shutdown) { + window.log.error('preload shutdown handler: shutdown method not found'); + ipc.send('now-ready-for-shutdown'); + return; + } + + try { + await shutdown(); + ipc.send('now-ready-for-shutdown'); + } catch (error) { + ipc.send( + 'now-ready-for-shutdown', + error && error.stack ? error.stack : error + ); + } +}); + function installGetter(name, functionName) { ipc.on(`get-${name}`, async () => { const getFn = window.Events[functionName]; @@ -159,7 +178,10 @@ function installGetter(name, functionName) { try { ipc.send(`get-success-${name}`, null, await getFn()); } catch (error) { - ipc.send(`get-success-${name}`, error); + ipc.send( + `get-success-${name}`, + error && error.stack ? error.stack : error + ); } } }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 174d78fae..5c41b5018 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -227,7 +227,7 @@ "rule": "jQuery-load(", "path": "js/background.js", "line": " await ConversationController.load();", - "lineNumber": 405, + "lineNumber": 417, "reasonCategory": "falseMatch", "updated": "2018-10-02T21:00:44.007Z" }, @@ -235,7 +235,7 @@ "rule": "jQuery-$(", "path": "js/background.js", "line": " el: $('body'),", - "lineNumber": 468, + "lineNumber": 480, "reasonCategory": "usageTrusted", "updated": "2018-10-16T23:47:48.006Z", "reasonDetail": "Protected from arbitrary input" @@ -244,7 +244,7 @@ "rule": "jQuery-wrap(", "path": "js/background.js", "line": " wrap(", - "lineNumber": 727, + "lineNumber": 739, "reasonCategory": "falseMatch", "updated": "2018-10-18T22:23:00.485Z" }, @@ -252,7 +252,7 @@ "rule": "jQuery-wrap(", "path": "js/background.js", "line": " await wrap(", - "lineNumber": 1228, + "lineNumber": 1240, "reasonCategory": "falseMatch", "updated": "2018-10-26T22:43:23.229Z" }, @@ -311,7 +311,7 @@ "rule": "jQuery-wrap(", "path": "js/models/messages.js", "line": " return wrap(", - "lineNumber": 994, + "lineNumber": 1000, "reasonCategory": "falseMatch", "updated": "2018-10-05T23:12:28.961Z" }, @@ -2315,7 +2315,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", - "lineNumber": 774, + "lineNumber": 781, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, @@ -2323,7 +2323,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/message_receiver.js", "line": " const buffer = dcodeIO.ByteBuffer.wrap(ciphertext);", - "lineNumber": 799, + "lineNumber": 806, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" },