From 6330156242171ae4850cfbb03774b758c4172442 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:09:01 -0800 Subject: [PATCH] Make link-and-sync downloads resumable --- ts/services/backups/api.ts | 43 +++++++------ ts/services/backups/errors.ts | 2 - ts/services/backups/index.ts | 111 +++++++++++++++++++--------------- ts/types/Storage.d.ts | 6 ++ 4 files changed, 90 insertions(+), 72 deletions(-) diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index 64aa4b841b..caa1da5a6e 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -11,12 +11,11 @@ import type { BackupMediaItemType, BackupMediaBatchResponseType, BackupListMediaResponseType, + TransferArchiveType, } from '../../textsecure/WebAPI'; import type { BackupCredentials } from './credentials'; import { BackupCredentialType } from '../../types/backups'; import { uploadFile } from '../../util/uploadAttachment'; -import { ContinueWithoutSyncingError, RelinkRequestedError } from './errors'; -import { missingCaseError } from '../../util/missingCaseError'; export type DownloadOptionsType = Readonly<{ downloadOffset: number; @@ -24,6 +23,14 @@ export type DownloadOptionsType = Readonly<{ abortSignal?: AbortSignal; }>; +export type EphemeralDownloadOptionsType = Readonly<{ + archive: Readonly<{ + cdn: number; + key: string; + }>; +}> & + DownloadOptionsType; + export class BackupAPI { #cachedBackupInfo = new Map< BackupCredentialType, @@ -106,31 +113,23 @@ export class BackupAPI { }); } + public async getTransferArchive( + abortSignal: AbortSignal + ): Promise { + return this.#server.getTransferArchive({ + abortSignal, + }); + } + public async downloadEphemeral({ + archive, downloadOffset, onProgress, abortSignal, - }: DownloadOptionsType): Promise { - const response = await this.#server.getTransferArchive({ - abortSignal, - }); - - if ('error' in response) { - switch (response.error) { - case 'RELINK_REQUESTED': - throw new RelinkRequestedError(); - case 'CONTINUE_WITHOUT_UPLOAD': - throw new ContinueWithoutSyncingError(); - default: - throw missingCaseError(response.error); - } - } - - const { cdn, key } = response; - + }: EphemeralDownloadOptionsType): Promise { return this.#server.getEphemeralBackupStream({ - cdn, - key, + cdn: archive.cdn, + key: archive.key, downloadOffset, onProgress, abortSignal, diff --git a/ts/services/backups/errors.ts b/ts/services/backups/errors.ts index d5f82e8706..b688b13cfb 100644 --- a/ts/services/backups/errors.ts +++ b/ts/services/backups/errors.ts @@ -17,5 +17,3 @@ export class BackupProcessingError extends Error {} export class BackupImportCanceledError extends Error {} export class RelinkRequestedError extends Error {} - -export class ContinueWithoutSyncingError extends Error {} diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index eadde02f6d..219b6f29b6 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -53,7 +53,6 @@ import { BackupDownloadFailedError, BackupImportCanceledError, BackupProcessingError, - ContinueWithoutSyncingError, RelinkRequestedError, UnsupportedBackupVersion, } from './errors'; @@ -227,6 +226,7 @@ export class BackupsService { await window.storage.remove('backupDownloadPath'); await window.storage.remove('backupEphemeralKey'); + await window.storage.remove('backupTransitArchive'); await window.storage.put('isRestoredFromBackup', hasBackup); // If the primary cancels sync on their end, then we can link without sync @@ -577,62 +577,77 @@ export class BackupsService { onProgress?.(InstallScreenBackupStep.Download, currentBytes, totalBytes); }; + await ensureFile(downloadPath); + if (controller.signal.aborted) { + throw new BackupImportCanceledError(); + } + + let stream: Readable; + try { - await ensureFile(downloadPath); + if (ephemeralKey == null) { + stream = await this.api.download({ + downloadOffset, + onProgress: onDownloadProgress, + abortSignal: controller.signal, + }); + } else { + let archive = window.storage.get('backupTransitArchive'); + if (archive == null) { + const response = await this.api.getTransferArchive(controller.signal); + if ('error' in response) { + switch (response.error) { + case 'RELINK_REQUESTED': + throw new RelinkRequestedError(); + + // Primary decided to abort syncing process; continue on with no backup + case 'CONTINUE_WITHOUT_UPLOAD': + log.error( + 'backups.doDownloadAndImport: primary requested to continue without syncing' + ); + return false; + default: + throw missingCaseError(response.error); + } + } + + archive = { + cdn: response.cdn, + key: response.key, + }; + await window.storage.put('backupTransitArchive', archive); + } + + stream = await this.api.downloadEphemeral({ + archive, + downloadOffset, + onProgress: onDownloadProgress, + abortSignal: controller.signal, + }); + } + } catch (error) { if (controller.signal.aborted) { throw new BackupImportCanceledError(); } - let stream: Readable; - try { - if (ephemeralKey == null) { - stream = await this.api.download({ - downloadOffset, - onProgress: onDownloadProgress, - abortSignal: controller.signal, - }); - } else { - stream = await this.api.downloadEphemeral({ - downloadOffset, - onProgress: onDownloadProgress, - abortSignal: controller.signal, - }); - } - } catch (error) { - if (controller.signal.aborted) { - throw new BackupImportCanceledError(); - } - - // No backup on the server - if (error instanceof HTTPError && error.code === 404) { - return false; - } - - // Primary decided to abort syncing process; continue on with no backup - if (error instanceof ContinueWithoutSyncingError) { - log.error( - 'backups.doDownloadAndImport: primary requested to continue without syncing' - ); - return false; - } - - // Primary wants to try link & sync again - if (error instanceof RelinkRequestedError) { - throw error; - } - - log.error( - 'backups.doDownloadAndImport: error downloading backup file', - Errors.toLogFormat(error) - ); - throw new BackupDownloadFailedError(); + // No backup on the server + if (error instanceof HTTPError && error.code === 404) { + return false; } - if (controller.signal.aborted) { - throw new BackupImportCanceledError(); - } + log.error( + 'backups.doDownloadAndImport: error downloading backup file', + Errors.toLogFormat(error) + ); + throw new BackupDownloadFailedError(); + } + if (controller.signal.aborted) { + throw new BackupImportCanceledError(); + } + + try { await pipeline( stream, createWriteStream(downloadPath, { diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 4728a01957..db11008f06 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -205,6 +205,12 @@ export type StorageAccessType = { // link-and-sync backup backupEphemeralKey: Uint8Array; + // If present - we are resuming the download of known transfer archive + backupTransitArchive: { + cdn: number; + key: string; + }; + // If true Desktop message history was restored from backup isRestoredFromBackup: boolean;