Refactor app initialization logic

This commit is contained in:
trevor-signal 2025-02-12 13:37:30 -05:00 committed by GitHub
parent 205c477082
commit 4c3db76bde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 394 additions and 582 deletions

View file

@ -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),
},
};

View file

@ -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);

View file

@ -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<void> {
};
let accountManager: AccountManager;
let isInRegistration = false;
window.getAccountManager = () => {
if (accountManager) {
return accountManager;
@ -413,19 +414,23 @@ export async function startApp(): Promise<void> {
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<void> {
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<void> {
);
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<void> {
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<void> {
const hasAppEverBeenRegistered = Registration.everDone();
log.info('background: offline', {
connectCount,
authSocketConnectCount,
hasInitialLoadCompleted,
appView,
hasAppEverBeenRegistered,
@ -1620,7 +1605,11 @@ export async function startApp(): Promise<void> {
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<void> {
} else if (server.isOnline() === false) {
onOffline();
}
}
let backupReady = explodePromise<{ wasBackupImported: boolean }>();
let registrationCompleted: ExplodePromiseResultType<void> | undefined;
let authSocketConnectCount = 0;
let afterAuthSocketConnectPromise: ExplodePromiseResultType<void> | undefined;
let remotelyExpired = false;
async function afterAuthSocketConnect() {
let contactSyncComplete: Promise<void> | undefined;
let storageServiceSyncComplete: Promise<void> | 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<void> {
},
});
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<void> {
);
}
let backupReady = explodePromise<void>();
let connectCount = 0;
let connectPromise: ExplodePromiseResultType<void> | 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<void> | undefined;
const areWePrimaryDevice =
window.ConversationController.areWePrimaryDevice();
const waitForEvent = createTaskWithTimeout(
(event: string): Promise<void> => {
const { promise, resolve } = explodePromise<void>();
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<void>;
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<void> {
log.info('manualConnect: calling connect()');
enqueueReconnectToWebSocket();
drop(connect());
}
async function onConfiguration(ev: ConfigurationEvent): Promise<void> {

View file

@ -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);
}

View file

@ -125,12 +125,13 @@ export class BackupsService {
this.api.clearCache();
});
}
public async downloadAndImport(options: DownloadOptionsType): Promise<void> {
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 {

View file

@ -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`);

View file

@ -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<any>): void {
window.Whisper.events.trigger(name, ...rest);
}
export const waitForEvent = (
eventName: string,
timeout: number = 2 * MINUTE
): Promise<void> =>
createTaskWithTimeout(
(event: string): Promise<void> => {
const { promise, resolve } = explodePromise<void>();
window.Whisper.events.once(event, () => resolve());
return promise;
},
`waitForEvent:${eventName}`,
{ timeout }
)(eventName);

View file

@ -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');

View file

@ -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<void> {
log.info('registration done');
this.dispatchEvent(new Event('registration'));
this.dispatchEvent(new Event('endRegistration'));
}
async setPni(

View file

@ -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 {

View file

@ -554,7 +554,7 @@ export class SocketManager extends EventListener {
authenticated.abort();
this.#dropAuthenticated(authenticated);
}
this.#markOffline();
this.#credentials = undefined;
}

View file

@ -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<void> {
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();
}
}

View file

@ -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,
};

View file

@ -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<void> }> {
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 };
}

View file

@ -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;

View file

@ -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<void>((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(),

2
ts/window.d.ts vendored
View file

@ -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<void>;
getVersion: () => string;