diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 728b12c7b..c855a5eee 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -37,6 +37,7 @@ global.window = { storage: { get: key => storageMap.get(key), put: async (key, value) => storageMap.set(key, value), + remove: async key => storageMap.clear(key), }, }; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 8be82ec26..03879c8c2 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -13,6 +13,7 @@ import { uuidToBytes } from './util/uuidToBytes'; import { dropNull } from './util/dropNull'; import { HashType } from './types/Crypto'; import { getCountryCode } from './types/PhoneNumber'; +import { parseRemoteClientExpiration } from './util/parseRemoteClientExpiration'; export type ConfigKeyType = | 'desktop.calling.ringrtcAdmFull' @@ -135,13 +136,23 @@ export const _refreshRemoteConfig = async ( }; }, {}); - // If remote configuration fetch worked - we are not expired anymore. - if ( - !getValue('desktop.clientExpiration') && - window.storage.get('remoteBuildExpiration') != null - ) { - log.warn('Remote Config: clearing remote expiration on successful fetch'); + const remoteExpirationValue = getValue('desktop.clientExpiration'); + if (!remoteExpirationValue) { + // If remote configuration fetch worked - we are not expired anymore. + if (window.storage.get('remoteBuildExpiration') != null) { + log.warn('Remote Config: clearing remote expiration on successful fetch'); + } await window.storage.remove('remoteBuildExpiration'); + } else { + const remoteBuildExpirationTimestamp = parseRemoteClientExpiration( + remoteExpirationValue + ); + if (remoteBuildExpirationTimestamp) { + await window.storage.put( + 'remoteBuildExpiration', + remoteBuildExpirationTimestamp + ); + } } await window.storage.put('remoteConfig', config); diff --git a/ts/background.ts b/ts/background.ts index 0fa6d47d6..5c7e958d2 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -69,7 +69,7 @@ import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue'; import { ourProfileKeyService } from './services/ourProfileKey'; import { notificationService } from './services/notifications'; import { areWeASubscriberService } from './services/areWeASubscriber'; -import { onContactSync, setIsInitialSync } from './services/contactSync'; +import { onContactSync, setIsInitialContactSync } from './services/contactSync'; import { startTimeTravelDetector } from './util/startTimeTravelDetector'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; import { LatestQueue } from './util/LatestQueue'; @@ -206,6 +206,8 @@ import { import { postSaveUpdates } from './util/cleanup'; import { handleDataMessage } from './messages/handleDataMessage'; import { MessageModel } from './models/messages'; +import { waitForEvent } from './shims/events'; +import { sendSyncRequests } from './textsecure/syncRequests'; export function isOverHourIntoPast(timestamp: number): boolean { return isNumber(timestamp) && isOlderThan(timestamp, HOUR); @@ -402,7 +404,6 @@ export async function startApp(): Promise { }; let accountManager: AccountManager; - let isInRegistration = false; window.getAccountManager = () => { if (accountManager) { return accountManager; @@ -413,19 +414,23 @@ export async function startApp(): Promise { accountManager = new window.textsecure.AccountManager(server); accountManager.addEventListener('startRegistration', () => { - isInRegistration = true; pauseProcessing(); + // We should already be logged out, but this ensures that the next time we connect + // to the auth socket it is from newly-registered credentials + drop(server?.logout()); + authSocketConnectCount = 0; backupReady.reject(new Error('startRegistration')); backupReady = explodePromise(); + registrationCompleted = explodePromise(); }); - accountManager.addEventListener('registration', () => { - isInRegistration = false; + + accountManager.addEventListener('endRegistration', () => { window.Whisper.events.trigger('userChanged', false); + drop(window.storage.put('postRegistrationSyncsStatus', 'incomplete')); + registrationCompleted?.resolve(); drop(Registration.markDone()); - log.info('dispatching registration event'); - window.Whisper.events.trigger('registration_done'); }); return accountManager; }; @@ -1489,18 +1494,6 @@ export async function startApp(): Promise { await runAllSyncTasks(); - log.info('listening for registration events'); - window.Whisper.events.on('registration_done', () => { - log.info('handling registration event'); - - strictAssert(server !== undefined, 'WebAPI not ready'); - - drop(connect(true)); - - // Connect messageReceiver back to websocket - drop(afterStart()); - }); - cancelInitializationMessage(); render( window.Signal.State.Roots.createApp(window.reduxStore), @@ -1527,7 +1520,7 @@ export async function startApp(): Promise { ); if (isCoreDataValid && Registration.everDone()) { - drop(connect()); + idleDetector.start(); if (window.storage.get('backupDownloadPath')) { window.reduxActions.installer.showBackupImport(); } else { @@ -1583,25 +1576,17 @@ export async function startApp(): Promise { resolveOnAppView = undefined; } - drop(afterStart()); + setupNetworkChangeListeners(); } - async function afterStart() { - strictAssert(messageReceiver, 'messageReceiver must be initialized'); + function setupNetworkChangeListeners() { strictAssert(server, 'server must be initialized'); - log.info('afterStart(): emitting app-ready-for-processing'); - window.Whisper.events.trigger('app-ready-for-processing'); - const onOnline = () => { log.info('background: online'); - - // Do not attempt to connect while expired or in-the-middle of - // registration - if (!remotelyExpired && !isInRegistration) { - drop(connect()); - } + drop(afterAuthSocketConnect()); }; + window.Whisper.events.on('online', onOnline); const onOffline = () => { @@ -1611,7 +1596,7 @@ export async function startApp(): Promise { const hasAppEverBeenRegistered = Registration.everDone(); log.info('background: offline', { - connectCount, + authSocketConnectCount, hasInitialLoadCompleted, appView, hasAppEverBeenRegistered, @@ -1620,7 +1605,11 @@ export async function startApp(): Promise { drop(challengeHandler?.onOffline()); drop(AttachmentDownloadManager.stop()); drop(AttachmentBackupManager.stop()); - drop(messageReceiver?.drain()); + + if (messageReceiver) { + drop(messageReceiver.drain()); + server?.unregisterRequestHandler(messageReceiver); + } if (hasAppEverBeenRegistered) { const state = window.reduxStore.getState(); @@ -1653,14 +1642,171 @@ export async function startApp(): Promise { } else if (server.isOnline() === false) { onOffline(); } + } + let backupReady = explodePromise<{ wasBackupImported: boolean }>(); + let registrationCompleted: ExplodePromiseResultType | undefined; + let authSocketConnectCount = 0; + let afterAuthSocketConnectPromise: ExplodePromiseResultType | undefined; + let remotelyExpired = false; + + async function afterAuthSocketConnect() { + let contactSyncComplete: Promise | undefined; + let storageServiceSyncComplete: Promise | undefined; + let hasSentSyncRequests = false; + + const isFirstAuthSocketConnect = authSocketConnectCount === 0; + const logId = `afterAuthSocketConnect.${authSocketConnectCount}`; + + authSocketConnectCount += 1; + + if (remotelyExpired) { + log.info('afterAuthSocketConnect: remotely expired'); + return; + } + + strictAssert(server, 'server must be initialized'); + strictAssert(messageReceiver, 'messageReceiver must be initialized'); + + while (afterAuthSocketConnectPromise?.promise) { + log.info(`${logId}: waiting for previous run to finish`); + // eslint-disable-next-line no-await-in-loop + await afterAuthSocketConnectPromise.promise; + } + + afterAuthSocketConnectPromise = explodePromise(); + log.info(`${logId}: starting`); + + try { + // 1. Await any ongoing registration + if (registrationCompleted) { + log.info(`${logId}: awaiting completion of registration`); + await registrationCompleted?.promise; + } + + if (!window.textsecure.storage.user.getAci()) { + log.error(`${logId}: ACI not captured during registration, unlinking`); + return unlinkAndDisconnect(); + } + + if (!window.textsecure.storage.user.getPni()) { + log.error(`${logId}: PNI not captured during registration, unlinking`); + return unlinkAndDisconnect(); + } + + // 2. Fetch remote config, before we process the message queue + if (isFirstAuthSocketConnect) { + try { + await window.Signal.RemoteConfig.forceRefreshRemoteConfig( + server, + 'afterAuthSocketConnect/firstConnect' + ); + } catch (error) { + log.error( + `${logId}: Error refreshing remote config:`, + isNumber(error.code) + ? `code: ${error.code}` + : Errors.toLogFormat(error) + ); + } + } + + const postRegistrationSyncsComplete = + window.storage.get('postRegistrationSyncsStatus') !== 'incomplete'; + + // 3. Send any critical sync requests after registration + if (!postRegistrationSyncsComplete) { + log.info(`${logId}: postRegistrationSyncs not complete, sending sync`); + + setIsInitialContactSync(true); + const syncRequest = await sendSyncRequests(); + hasSentSyncRequests = true; + contactSyncComplete = syncRequest.contactSyncComplete; + } + + // 4. Download (or resume download) of link & sync backup + const { wasBackupImported } = await maybeDownloadAndImportBackup(); + log.info(logId, { + wasBackupImported, + }); + + // 5. Kickoff storage service sync + if (isFirstAuthSocketConnect || !postRegistrationSyncsComplete) { + log.info(`${logId}: triggering storage service sync`); + + storageServiceSyncComplete = waitForEvent( + 'storageService:syncComplete' + ); + drop(runStorageService({ reason: 'afterFirstAuthSocketConnect' })); + } + + // 6. Start processing messages from websocket + log.info(`${logId}: enabling message processing`); + server.registerRequestHandler(messageReceiver); + messageReceiver.startProcessingQueue(); + + // 7. Wait for critical post-registration syncs before showing inbox + if (!postRegistrationSyncsComplete) { + const syncsToAwaitBeforeShowingInbox = [contactSyncComplete]; + + // If backup was imported, we do not need to await the storage service sync + if (!wasBackupImported) { + syncsToAwaitBeforeShowingInbox.push(storageServiceSyncComplete); + } + + try { + await Promise.all(syncsToAwaitBeforeShowingInbox); + await window.storage.put('postRegistrationSyncsStatus', 'complete'); + log.info(`${logId}: postRegistrationSyncs complete`); + } catch (error) { + log.error( + `${logId}: Failed to run postRegistrationSyncs`, + Errors.toLogFormat(error) + ); + } + } + + // 8. Show inbox + const state = window.reduxStore.getState(); + if (state.app.appView === AppViewType.Installer) { + log.info(`${logId}: switching from installer to inbox`); + window.reduxActions.app.openInbox(); + } + + // 9. Start services requiring auth connection + afterEveryAuthConnect(); + + // 10. Handle once-on-boot tasks + if (isFirstAuthSocketConnect) { + afterEveryLinkedStartup(); + } + + // 10. Handle infrequent once-on-new-version tasks + if (newVersion) { + drop( + afterEveryLinkedStartupOnNewVersion({ + skipSyncRequests: hasSentSyncRequests, + }) + ); + } + } catch (e) { + log.error(`${logId}: error`, Errors.toLogFormat(e)); + } finally { + afterAuthSocketConnectPromise?.resolve(); + afterAuthSocketConnectPromise = undefined; + } + } + + async function maybeDownloadAndImportBackup(): Promise<{ + wasBackupImported: boolean; + }> { const backupDownloadPath = window.storage.get('backupDownloadPath'); if (backupDownloadPath) { tapToViewMessagesDeletionService.pause(); // Download backup before enabling request handler and storage service try { - await backupsService.downloadAndImport({ + const { wasBackupImported } = await backupsService.downloadAndImport({ onProgress: (backupStep, currentBytes, totalBytes) => { window.reduxActions.installer.updateBackupImportProgress({ backupStep, @@ -1670,36 +1816,121 @@ export async function startApp(): Promise { }, }); - log.info('afterStart: backup download attempt completed, resolving'); - backupReady.resolve(); + log.info('afterAppStart: backup download attempt completed, resolving'); + backupReady.resolve({ wasBackupImported }); } catch (error) { - log.error('afterStart: backup download failed, rejecting'); + log.error('afterAppStart: backup download failed, rejecting'); backupReady.reject(error); throw error; } finally { tapToViewMessagesDeletionService.resume(); } } else { - backupReady.resolve(); + backupReady.resolve({ wasBackupImported: false }); } - server.registerRequestHandler(messageReceiver); - drop(runStorageService({ reason: 'afterStart' })); - - // Opposite of `messageReceiver.stopProcessing` - messageReceiver.reset(); + return backupReady.promise; } - window.getSyncRequest = (timeoutMillis?: number) => { - strictAssert(messageReceiver, 'MessageReceiver not initialized'); + function afterEveryLinkedStartup() { + log.info('afterAuthSocketConnect/afterEveryLinkedStartup'); - const syncRequest = new window.textsecure.SyncRequest( - messageReceiver, - timeoutMillis - ); - syncRequest.start(); - return syncRequest; - }; + // Note: we always have to register our capabilities all at once, so we do this + // after connect on every startup + drop(registerCapabilities()); + drop(ensureAEP()); + drop(maybeQueueDeviceNameFetch()); + Stickers.downloadQueuedPacks(); + } + + async function afterEveryLinkedStartupOnNewVersion({ + skipSyncRequests = false, + }: { + skipSyncRequests: boolean; + }) { + log.info('afterAuthSocketConnect/afterEveryLinkedStartupOnNewVersion'); + + if (window.ConversationController.areWePrimaryDevice()) { + return; + } + + try { + if (!skipSyncRequests) { + drop(sendSyncRequests()); + } + + drop(StorageService.reprocessUnknownFields()); + + const manager = window.getAccountManager(); + await Promise.all([ + manager.maybeUpdateDeviceName(), + window.textsecure.storage.user.removeSignalingKey(), + ]); + } catch (e) { + log.error( + "Problem with 'afterLinkedStartupOnNewVersion' tasks: ", + Errors.toLogFormat(e) + ); + } + } + + async function ensureAEP() { + if ( + window.storage.get('accountEntropyPool') || + window.ConversationController.areWePrimaryDevice() + ) { + return; + } + + const lastSent = + window.storage.get('accountEntropyPoolLastRequestTime') ?? 0; + const now = Date.now(); + + // If we last attempted sync one day in the past, or if we time + // traveled. + if (isOlderThan(lastSent, DAY) || lastSent > now) { + log.warn('ensureAEP: AEP not captured, requesting sync'); + await singleProtoJobQueue.add(MessageSender.getRequestKeySyncMessage()); + await window.storage.put('accountEntropyPoolLastRequestTime', now); + } else { + log.warn( + 'ensureAEP: AEP not captured, but sync requested recently.' + + 'Not running' + ); + } + } + + async function registerCapabilities() { + strictAssert(server, 'server must be initialized'); + + try { + await server.registerCapabilities({ + deleteSync: true, + versionedExpirationTimer: true, + ssre2: true, + }); + } catch (error) { + log.error( + 'Error: Unable to register our capabilities.', + Errors.toLogFormat(error) + ); + } + } + + function afterEveryAuthConnect() { + log.info('afterAuthSocketConnect/afterEveryAuthConnect'); + + strictAssert(challengeHandler, 'afterEveryAuthConnect: challengeHandler'); + drop(challengeHandler.onOnline()); + reconnectBackOff.reset(); + drop(window.Signal.Services.initializeGroupCredentialFetcher()); + drop(AttachmentDownloadManager.start()); + + if (isBackupEnabled()) { + backupsService.start(); + drop(AttachmentBackupManager.start()); + } + } function onNavigatorOffline() { log.info('background: navigator offline'); @@ -1752,351 +1983,6 @@ export async function startApp(): Promise { ); } - let backupReady = explodePromise(); - - let connectCount = 0; - let connectPromise: ExplodePromiseResultType | undefined; - let remotelyExpired = false; - async function connect(firstRun?: boolean) { - if (connectPromise && !firstRun) { - log.warn('background: connect already running', { - connectCount, - firstRun, - }); - return; - } - if (connectPromise && firstRun) { - while (connectPromise) { - log.warn( - 'background: connect already running; waiting for previous run', - { - connectCount, - firstRun, - } - ); - // eslint-disable-next-line no-await-in-loop - await connectPromise.promise; - } - await connect(firstRun); - return; - } - - if (remotelyExpired) { - log.warn('background: remotely expired, not reconnecting'); - return; - } - - strictAssert(server !== undefined, 'WebAPI not connected'); - - let contactSyncComplete: Promise | undefined; - const areWePrimaryDevice = - window.ConversationController.areWePrimaryDevice(); - - const waitForEvent = createTaskWithTimeout( - (event: string): Promise => { - const { promise, resolve } = explodePromise(); - window.Whisper.events.once(event, () => resolve()); - return promise; - }, - 'connect:waitForEvent', - { timeout: 2 * durations.MINUTE } - ); - - try { - connectPromise = explodePromise(); - - if (firstRun === true && !areWePrimaryDevice) { - contactSyncComplete = waitForEvent('contactSync:complete'); - - // Send the contact sync message immediately (don't wait until after backup is - // downloaded & imported) - await singleProtoJobQueue.add( - MessageSender.getRequestContactSyncMessage() - ); - } - - // Wait for backup to be downloaded - try { - await backupReady.promise; - } catch (error) { - log.error( - 'background: backup download failed, not reconnecting', - error - ); - return; - } - log.info('background: connect unblocked by backups'); - - // Reset the flag and update it below if needed - setIsInitialSync(false); - - if (!Registration.everDone()) { - log.info('background: registration not done, not connecting'); - return; - } - - log.info('background: connect', { firstRun, connectCount }); - - // Update our profile key in the conversation if we just got linked. - const profileKey = await ourProfileKeyService.get(); - if (firstRun && profileKey) { - const me = window.ConversationController.getOurConversation(); - strictAssert(me !== undefined, "Didn't find newly created ourselves"); - await me.setProfileKey(Bytes.toBase64(profileKey), { - reason: 'connect/firstRun', - }); - } - - if (isBackupEnabled()) { - backupsService.start(); - drop(AttachmentBackupManager.start()); - } - - if (connectCount === 0 || firstRun) { - try { - // Force a re-fetch before we process our queue. We may want to turn on - // something which changes how we process incoming messages! - await window.Signal.RemoteConfig.forceRefreshRemoteConfig( - server, - `connectCount=${connectCount} firstRun=${firstRun}` - ); - - const expiration = window.Signal.RemoteConfig.getValue( - 'desktop.clientExpiration' - ); - if (expiration) { - const remoteBuildExpirationTimestamp = parseRemoteClientExpiration( - expiration as string - ); - if (remoteBuildExpirationTimestamp) { - await window.storage.put( - 'remoteBuildExpiration', - remoteBuildExpirationTimestamp - ); - } - } - } catch (error) { - log.error( - 'connect: Error refreshing remote config:', - isNumber(error.code) - ? `code: ${error.code}` - : Errors.toLogFormat(error) - ); - } - } - - connectCount += 1; - - void window.Signal.Services.initializeGroupCredentialFetcher(); - - drop(AttachmentDownloadManager.start()); - - if (connectCount === 1) { - Stickers.downloadQueuedPacks(); - if (!newVersion) { - drop(runStorageService({ reason: 'connect/connectCount=1' })); - } - } - - // On startup after upgrading to a new version, request a contact sync - // (but only if we're not the primary device) - if ( - !firstRun && - connectCount === 1 && - newVersion && - !areWePrimaryDevice - ) { - log.info('Boot after upgrading. Requesting contact sync'); - - try { - window.getSyncRequest(); - - void StorageService.reprocessUnknownFields(); - void runStorageService({ reason: 'connect/bootAfterUpgrade' }); - - const manager = window.getAccountManager(); - await Promise.all([ - manager.maybeUpdateDeviceName(), - window.textsecure.storage.user.removeSignalingKey(), - ]); - } catch (e) { - log.error( - "Problem with 'boot after upgrade' tasks: ", - Errors.toLogFormat(e) - ); - } - } - - if (!window.textsecure.storage.user.getAci()) { - log.error('UUID not captured during registration, unlinking'); - return unlinkAndDisconnect(); - } - - if (!window.textsecure.storage.user.getPni()) { - log.error('PNI not captured during registration, unlinking softly'); - return unlinkAndDisconnect(); - } - - if (connectCount === 1) { - try { - // Note: we always have to register our capabilities all at once, so we do this - // after connect on every startup - await server.registerCapabilities({ - deleteSync: true, - versionedExpirationTimer: true, - ssre2: true, - }); - } catch (error) { - log.error( - 'Error: Unable to register our capabilities.', - Errors.toLogFormat(error) - ); - } - - // Ensure we have the correct device name locally (allowing us to get eventually - // consistent with primary, in case we failed to process a deviceNameChangeSync - // for some reason). We do this after calling `maybeUpdateDeviceName` to ensure - // that the device name on server is encrypted. - drop(maybeQueueDeviceNameFetch()); - } - - if (firstRun === true && !areWePrimaryDevice) { - if (!window.storage.get('accountEntropyPool')) { - const lastSent = - window.storage.get('accountEntropyPoolLastRequestTime') ?? 0; - const now = Date.now(); - - // If we last attempted sync one day in the past, or if we time - // traveled. - if (isOlderThan(lastSent, DAY) || lastSent > now) { - log.warn('connect: AEP not captured, requesting sync'); - await singleProtoJobQueue.add( - MessageSender.getRequestKeySyncMessage() - ); - await window.storage.put('accountEntropyPoolLastRequestTime', now); - } else { - log.warn( - 'connect: AEP not captured, but sync requested recently.' + - 'Not running' - ); - } - } - let storageServiceSyncComplete: Promise; - if (window.ConversationController.areWePrimaryDevice()) { - storageServiceSyncComplete = Promise.resolve(); - } else { - storageServiceSyncComplete = waitForEvent( - 'storageService:syncComplete' - ); - } - - log.info('firstRun: requesting initial sync'); - setIsInitialSync(true); - - // Request configuration, block, GV1 sync messages, contacts - // (only avatars and inboxPosition),and Storage Service sync. - try { - await Promise.all([ - singleProtoJobQueue.add( - MessageSender.getRequestConfigurationSyncMessage() - ), - singleProtoJobQueue.add(MessageSender.getRequestBlockSyncMessage()), - runStorageService({ reason: 'firstRun/initialSync' }), - ]); - } catch (error) { - log.error( - 'connect: Failed to request initial syncs', - Errors.toLogFormat(error) - ); - } - - log.info('firstRun: waiting for storage service and contact sync'); - strictAssert( - contactSyncComplete, - 'contact sync is awaited during first run' - ); - try { - await Promise.all([storageServiceSyncComplete, contactSyncComplete]); - } catch (error) { - log.error( - 'connect: Failed to run storage service and contact syncs', - Errors.toLogFormat(error) - ); - } - - log.info('firstRun: initial sync complete'); - setIsInitialSync(false); - - // Switch to inbox view even if contact sync is still running - const state = window.reduxStore.getState(); - if (state.app.appView === AppViewType.Installer) { - log.info('firstRun: opening inbox'); - window.reduxActions.app.openInbox(); - } else { - log.info('firstRun: not opening inbox'); - } - - const installedStickerPacks = Stickers.getInstalledStickerPacks(); - if (installedStickerPacks.length) { - const operations = installedStickerPacks.map(pack => ({ - packId: pack.id, - packKey: pack.key, - installed: true, - })); - - if (!window.ConversationController.areWePrimaryDevice()) { - log.info('firstRun: requesting stickers', operations.length); - try { - await singleProtoJobQueue.add( - MessageSender.getStickerPackSync(operations) - ); - } catch (error) { - log.error( - 'connect: Failed to queue sticker sync message', - Errors.toLogFormat(error) - ); - } - } - } - - log.info('firstRun: done'); - } else { - const state = window.reduxStore.getState(); - if ( - state.app.appView === AppViewType.Installer && - state.installer.step === InstallScreenStep.BackupImport - ) { - log.info('notFirstRun: opening inbox after backup import'); - window.reduxActions.app.openInbox(); - } else { - log.info('notFirstRun: not opening inbox'); - } - } - - window.storage.onready(async () => { - idleDetector.start(); - }); - - if (!challengeHandler) { - throw new Error('Expected challenge handler to be initialized'); - } - - drop(challengeHandler.onOnline()); - - reconnectBackOff.reset(); - } finally { - if (connectPromise) { - connectPromise.resolve(); - connectPromise = undefined; - } else { - log.warn('background connect: in finally, no connectPromise!', { - connectCount, - firstRun, - }); - } - } - } - window.SignalContext.nativeThemeListener.subscribe(themeChanged); const FIVE_MINUTES = 5 * durations.MINUTE; @@ -2215,7 +2101,6 @@ export async function startApp(): Promise { log.info('manualConnect: calling connect()'); enqueueReconnectToWebSocket(); - drop(connect()); } async function onConfiguration(ev: ConfigurationEvent): Promise { diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 011964236..8638eb510 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -188,7 +188,7 @@ import { explodePromise } from '../util/explodePromise'; import { getCallHistorySelector } from '../state/selectors/callHistory'; import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus'; import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes'; -import { getIsInitialSync } from '../services/contactSync'; +import { getIsInitialContactSync } from '../services/contactSync'; import { queueAttachmentDownloadsForMessage } from '../util/queueAttachmentDownloads'; import { cleanupMessages } from '../util/cleanup'; import { MessageModel } from './messages'; @@ -2348,7 +2348,7 @@ export class ConversationModel extends window.Backbone await window.MessageCache.saveMessage(message, { forceSave: true, }); - if (!getIsInitialSync() && !this.get('active_at')) { + if (!getIsInitialContactSync() && !this.get('active_at')) { this.set({ active_at: Date.now() }); await DataWriter.updateConversation(this.attributes); } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 18668b52f..b2056b57c 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -125,12 +125,13 @@ export class BackupsService { this.api.clearCache(); }); } - - public async downloadAndImport(options: DownloadOptionsType): Promise { + public async downloadAndImport( + options: DownloadOptionsType + ): Promise<{ wasBackupImported: boolean }> { const backupDownloadPath = window.storage.get('backupDownloadPath'); if (!backupDownloadPath) { log.warn('backups.downloadAndImport: no backup download path, skipping'); - return; + return { wasBackupImported: false }; } log.info('backups.downloadAndImport: downloading...'); @@ -234,6 +235,7 @@ export class BackupsService { } log.info(`backups.downloadAndImport: done, had backup=${hasBackup}`); + return { wasBackupImported: hasBackup }; } public retryDownload(): void { diff --git a/ts/services/contactSync.ts b/ts/services/contactSync.ts index d62ecc3b8..bc55f8785 100644 --- a/ts/services/contactSync.ts +++ b/ts/services/contactSync.ts @@ -30,11 +30,11 @@ import { AttachmentVariant } from '../types/Attachment'; // linking. let isInitialSync = false; -export function setIsInitialSync(newValue: boolean): void { - log.info(`setIsInitialSync(${newValue})`); +export function setIsInitialContactSync(newValue: boolean): void { + log.info(`setIsInitialContactSync(${newValue})`); isInitialSync = newValue; } -export function getIsInitialSync(): boolean { +export function getIsInitialContactSync(): boolean { return isInitialSync; } @@ -233,6 +233,9 @@ async function doContactSync({ await window.storage.put('synced_at', Date.now()); window.Whisper.events.trigger('contactSync:complete'); + if (isInitialSync) { + isInitialSync = false; + } window.SignalCI?.handleEvent('contactSync', isFullSync); log.info(`${logId}: done`); diff --git a/ts/shims/events.ts b/ts/shims/events.ts index 6beea7328..d69262d47 100644 --- a/ts/shims/events.ts +++ b/ts/shims/events.ts @@ -1,8 +1,26 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; +import { MINUTE } from '../util/durations'; +import { explodePromise } from '../util/explodePromise'; + // Matching Whisper.events.trigger API // eslint-disable-next-line @typescript-eslint/no-explicit-any export function trigger(name: string, ...rest: Array): void { window.Whisper.events.trigger(name, ...rest); } + +export const waitForEvent = ( + eventName: string, + timeout: number = 2 * MINUTE +): Promise => + createTaskWithTimeout( + (event: string): Promise => { + const { promise, resolve } = explodePromise(); + window.Whisper.events.once(event, () => resolve()); + return promise; + }, + `waitForEvent:${eventName}`, + { timeout } + )(eventName); diff --git a/ts/test-mock/messaging/retries_test.ts b/ts/test-mock/messaging/retries_test.ts index ead4e0f06..9139b375f 100644 --- a/ts/test-mock/messaging/retries_test.ts +++ b/ts/test-mock/messaging/retries_test.ts @@ -77,6 +77,8 @@ describe('retries', function (this: Mocha.Suite) { const { desktop, contacts } = bootstrap; const [first] = contacts; + await app.close(); + debug('send a sender key message without sending skdm first'); const firstDistributionId = await first.sendSenderKey(desktop, { timestamp: bootstrap.getTimestamp(), @@ -87,7 +89,7 @@ describe('retries', function (this: Mocha.Suite) { debug('send a failing message'); const timestamp = bootstrap.getTimestamp(); - await first.sendText(desktop, content, { + const firstMessageSend = first.sendText(desktop, content, { distributionId: firstDistributionId, sealed: true, timestamp, @@ -99,12 +101,18 @@ describe('retries', function (this: Mocha.Suite) { }); debug('send same hello message, this time it should work'); - await first.sendText(desktop, content, { + const secondMessageSend = first.sendText(desktop, content, { distributionId: secondDistributionId, sealed: true, timestamp, }); + debug('starting'); + + app = await bootstrap.startApp(); + + await Promise.all([firstMessageSend, secondMessageSend]); + debug('open conversation'); const window = await app.getWindow(); const leftPane = window.locator('#LeftPane'); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index da41a8072..04288d5ef 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -1188,7 +1188,13 @@ export default class AccountManager extends EventTarget { await storage.put('identityKeyMap', identityKeyMap); await storage.put('registrationIdMap', registrationIdMap); + await ourProfileKeyService.set(profileKey); + const me = window.ConversationController.getOurConversationOrThrow(); + await me.setProfileKey(Bytes.toBase64(profileKey), { + reason: 'registration', + }); + if (userAgent) { await storage.put('userAgent', userAgent); } @@ -1351,7 +1357,7 @@ export default class AccountManager extends EventTarget { async #registrationDone(): Promise { log.info('registration done'); - this.dispatchEvent(new Event('registration')); + this.dispatchEvent(new Event('endRegistration')); } async setPni( diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 9412e7b85..1038d2561 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -300,7 +300,6 @@ export default class MessageReceiver #serverTrustRoot: Uint8Array; #stoppingProcessing?: boolean; #pniIdentityKeyCheckRequired?: boolean; - #isAppReadyForProcessing: boolean = false; constructor({ storage, serverTrustRoot }: MessageReceiverOptions) { super(); @@ -348,15 +347,6 @@ export default class MessageReceiver maxSize: 30, processBatch: this.#cacheRemoveBatch.bind(this), }); - - window.Whisper.events.on('app-ready-for-processing', () => { - this.#isAppReadyForProcessing = true; - this.reset(); - }); - - window.Whisper.events.on('online', () => { - this.reset(); - }); } public getAndResetProcessedCount(): number { @@ -477,17 +467,12 @@ export default class MessageReceiver ); } - public reset(): void { - log.info('MessageReceiver.reset'); + public startProcessingQueue(): void { + log.info('MessageReceiver.startProcessingQueue'); this.#count = 0; this.#isEmptied = false; this.#stoppingProcessing = false; - if (!this.#isAppReadyForProcessing) { - log.info('MessageReceiver.reset: not ready yet, returning early'); - return; - } - drop(this.#addCachedMessagesToQueue()); } @@ -507,7 +492,6 @@ export default class MessageReceiver public stopProcessing(): void { log.info('MessageReceiver.stopProcessing'); this.#stoppingProcessing = true; - this.#isAppReadyForProcessing = false; } public hasEmptied(): boolean { diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts index 3553dbec1..95b86d76b 100644 --- a/ts/textsecure/SocketManager.ts +++ b/ts/textsecure/SocketManager.ts @@ -554,7 +554,7 @@ export class SocketManager extends EventListener { authenticated.abort(); this.#dropAuthenticated(authenticated); } - + this.#markOffline(); this.#credentials = undefined; } diff --git a/ts/textsecure/SyncRequest.ts b/ts/textsecure/SyncRequest.ts deleted file mode 100644 index 22f74824a..000000000 --- a/ts/textsecure/SyncRequest.ts +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable max-classes-per-file */ - -import type { EventHandler } from './EventTarget'; -import EventTarget from './EventTarget'; -import MessageReceiver from './MessageReceiver'; -import type { ContactSyncEvent } from './messageReceiverEvents'; -import MessageSender from './SendMessage'; -import { assertDev } from '../util/assert'; -import * as log from '../logging/log'; -import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; -import * as Errors from '../types/errors'; - -class SyncRequestInner extends EventTarget { - #started = false; - - contactSync?: boolean; - - timeout: any; - - oncontact: (event: ContactSyncEvent) => void; - - timeoutMillis: number; - - constructor( - private receiver: MessageReceiver, - timeoutMillis?: number - ) { - super(); - - if (!(receiver instanceof MessageReceiver)) { - throw new Error( - 'Tried to construct a SyncRequest without MessageReceiver' - ); - } - - this.oncontact = this.onContactSyncComplete.bind(this); - receiver.addEventListener('contactSync', this.oncontact); - - this.timeoutMillis = timeoutMillis || 60000; - } - - async start(): Promise { - if (this.#started) { - assertDev( - false, - 'SyncRequestInner: started more than once. Doing nothing' - ); - return; - } - this.#started = true; - - if (window.ConversationController.areWePrimaryDevice()) { - log.warn('SyncRequest.start: We are primary device; returning early'); - return; - } - - log.info( - 'SyncRequest created. Sending config, block, contact, and group requests...' - ); - try { - await Promise.all([ - singleProtoJobQueue.add( - MessageSender.getRequestConfigurationSyncMessage() - ), - singleProtoJobQueue.add(MessageSender.getRequestBlockSyncMessage()), - singleProtoJobQueue.add(MessageSender.getRequestContactSyncMessage()), - ]); - } catch (error: unknown) { - log.error( - 'SyncRequest: Failed to add request jobs', - Errors.toLogFormat(error) - ); - } - - this.timeout = setTimeout(this.onTimeout.bind(this), this.timeoutMillis); - } - - onContactSyncComplete() { - this.contactSync = true; - this.update(); - } - - update() { - if (this.contactSync) { - this.dispatchEvent(new Event('success')); - this.cleanup(); - } - } - - onTimeout() { - if (this.contactSync) { - this.dispatchEvent(new Event('success')); - } else { - this.dispatchEvent(new Event('timeout')); - } - this.cleanup(); - } - - cleanup() { - clearTimeout(this.timeout); - this.receiver.removeEventListener('contactsync', this.oncontact); - delete this.listeners; - } -} - -export default class SyncRequest { - #inner: SyncRequestInner; - - addEventListener: ( - name: 'success' | 'timeout', - handler: EventHandler - ) => void; - - removeEventListener: ( - name: 'success' | 'timeout', - handler: EventHandler - ) => void; - - constructor(receiver: MessageReceiver, timeoutMillis?: number) { - const inner = new SyncRequestInner(receiver, timeoutMillis); - this.#inner = inner; - this.addEventListener = inner.addEventListener.bind(inner); - this.removeEventListener = inner.removeEventListener.bind(inner); - } - - start(): void { - void this.#inner.start(); - } -} diff --git a/ts/textsecure/index.ts b/ts/textsecure/index.ts index 1fc16c9f5..c3220a689 100644 --- a/ts/textsecure/index.ts +++ b/ts/textsecure/index.ts @@ -5,7 +5,6 @@ import EventTarget from './EventTarget'; import AccountManager from './AccountManager'; import MessageReceiver from './MessageReceiver'; import utils from './Helpers'; -import SyncRequest from './SyncRequest'; import MessageSender from './SendMessage'; import { Storage } from './Storage'; import * as WebAPI from './WebAPI'; @@ -19,7 +18,6 @@ export type TextSecureType = { EventTarget: typeof EventTarget; MessageReceiver: typeof MessageReceiver; MessageSender: typeof MessageSender; - SyncRequest: typeof SyncRequest; WebAPI: typeof WebAPI; WebSocketResource: typeof WebSocketResource; @@ -35,7 +33,6 @@ export const textsecure: TextSecureType = { EventTarget, MessageReceiver, MessageSender, - SyncRequest, WebAPI, WebSocketResource, }; diff --git a/ts/textsecure/syncRequests.ts b/ts/textsecure/syncRequests.ts new file mode 100644 index 000000000..3475692d7 --- /dev/null +++ b/ts/textsecure/syncRequests.ts @@ -0,0 +1,32 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { waitForEvent } from '../shims/events'; +import * as log from '../logging/log'; +import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; +import MessageSender from './SendMessage'; +import { toLogFormat } from '../types/errors'; + +export async function sendSyncRequests( + timeout?: number +): Promise<{ contactSyncComplete: Promise }> { + const contactSyncComplete = waitForEvent('contactSync:complete', timeout); + + log.info('sendSyncRequests: sending sync requests'); + try { + await Promise.all([ + singleProtoJobQueue.add(MessageSender.getRequestContactSyncMessage()), + singleProtoJobQueue.add( + MessageSender.getRequestConfigurationSyncMessage() + ), + singleProtoJobQueue.add(MessageSender.getRequestBlockSyncMessage()), + ]); + } catch (error: unknown) { + log.error( + 'sendSyncRequests: failed to send sync requests', + toLogFormat(error) + ); + throw error; + } + return { contactSyncComplete }; +} diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 30fd7a3bf..4728a0195 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -211,6 +211,8 @@ export type StorageAccessType = { // The `firstAppVersion` present on an BackupInfo from an imported backup. restoredBackupFirstAppVersion: string; + postRegistrationSyncsStatus: 'incomplete' | 'complete'; + // Deprecated 'challenge:retry-message-ids': never; nextSignedKeyRotationTime: number; diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index b22d3a3e3..10341db24 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -52,6 +52,7 @@ import type { } from './preload'; import type { SystemTraySetting } from '../types/SystemTraySetting'; import { drop } from './drop'; +import { sendSyncRequests } from '../textsecure/syncRequests'; type SentMediaQualityType = 'standard' | 'high'; type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; @@ -487,15 +488,12 @@ export function createIPCEvents( }, isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1, - syncRequest: () => - new Promise((resolve, reject) => { - const FIVE_MINUTES = 5 * durations.MINUTE; - const syncRequest = window.getSyncRequest(FIVE_MINUTES); - syncRequest.addEventListener('success', () => resolve()); - syncRequest.addEventListener('timeout', () => - reject(new Error('timeout')) - ); - }), + syncRequest: async () => { + const { contactSyncComplete } = await sendSyncRequests( + 5 * durations.MINUTE + ); + return contactSyncComplete; + }, getLastSyncTime: () => window.storage.get('synced_at'), setLastSyncTime: value => window.storage.put('synced_at', value), getUniversalExpireTimer: () => universalExpireTimer.get(), diff --git a/ts/window.d.ts b/ts/window.d.ts index f6139df89..18fc6ca3f 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -38,7 +38,6 @@ import type { ConfirmationDialog } from './components/ConfirmationDialog'; import type { SignalProtocolStore } from './SignalProtocolStore'; import type { SocketStatus } from './types/SocketStatus'; import type { ScreenShareStatus } from './types/Calling'; -import type SyncRequest from './textsecure/SyncRequest'; import type { MessageCache } from './services/MessageCache'; import type { StateType } from './state/reducer'; import type { Address } from './types/Address'; @@ -203,7 +202,6 @@ declare global { getSfuUrl: () => string; getIceServerOverride: () => string; getSocketStatus: () => SocketStatus; - getSyncRequest: (timeoutMillis?: number) => SyncRequest; getTitle: () => string; waitForEmptyEventQueue: () => Promise; getVersion: () => string;