diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c41a6b942384..1f2aa6501b65 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4687,6 +4687,18 @@ "messageformat": "Tap on \"Donate to Signal\" and subscribe", "description": "In the instructions for becoming a sustainer. Third instruction." }, + "icu:BackupImportScreen__title": { + "messageformat": "Syncing messages", + "description": "Title of backup import screen" + }, + "icu:BackupImportScreen__progressbar-hint": { + "messageformat": "Downloading {currentSize} of {totalSize} ({fractionComplete, number, percent})...", + "description": "Hint under the progressbar in the backup import screen" + }, + "icu:BackupImportScreen__description": { + "messageformat": "This may take a few minutes depending on the size of your backup", + "description": "Description at the bottom of backup import screen" + }, "icu:CompositionArea--expand": { "messageformat": "Expand", "description": "Aria label for expanding composition area" diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index e3628319260c..24442560d5b5 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -275,6 +275,22 @@ async function cleanupOrphanedAttachments({ ); } + { + const downloads: Array = await sql.sqlRead('getKnownDownloads'); + + let missing = 0; + for (const known of downloads) { + if (!orphanedDownloads.delete(known)) { + missing += 1; + } + } + + console.log( + `cleanupOrphanedAttachments: found ${downloads.length} downloads ` + + `(${missing} missing), ${orphanedDownloads.size} remain` + ); + } + // This call is intentionally not awaited. We block the app while running // all fetches above to ensure that there are no in-flight attachments that // are saved to disk, but not put into any message or conversation model yet. diff --git a/stylesheets/components/BackupImportScreen.scss b/stylesheets/components/BackupImportScreen.scss new file mode 100644 index 000000000000..1fe6dcbac8cb --- /dev/null +++ b/stylesheets/components/BackupImportScreen.scss @@ -0,0 +1,75 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.BackupImportScreen { + display: flex; + width: 100vw; + height: 100vh; + + justify-content: center; + align-items: center; +} + +.BackupImportScreen__content { + text-align: center; +} + +.BackupImportScreen__title { + @include font-title-2; + margin-block: 0 20px; +} + +.BackupImportScreen__progressbar { + overflow: hidden; + margin-block-end: 14px; + + background: rgba($color-ultramarine, 0.2); + height: 5px; + border-radius: 2px; +} + +.BackupImportScreen__progressbar__fill { + background-color: $color-ultramarine; + border-radius: 2px; + display: block; + height: 100%; + width: 100%; + &:dir(ltr) { + /* stylelint-disable-next-line declaration-property-value-disallowed-list */ + transform: translateX(-100%); + } + &:dir(rtl) { + /* stylelint-disable-next-line declaration-property-value-disallowed-list */ + transform: translateX(100%); + } + transition: transform 500ms ease-out; +} + +.BackupImportScreen__progressbar-hint { + @include font-caption; + margin-block-end: 22px; + + @include light-theme { + color: rgba($color-gray-60, 0.8); + } + + @include dark-theme { + color: $color-gray-25; + } +} + +.BackupImportScreen__progressbar-hint--hidden { + visibility: hidden; +} + +.BackupImportScreen__description { + @include font-body-1; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 733af684d624..7078f84dc225 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -32,6 +32,7 @@ @import './components/AvatarModalButtons.scss'; @import './components/AvatarPreview.scss'; @import './components/AvatarTextEditor.scss'; +@import './components/BackupImportScreen.scss'; @import './components/BadgeCarouselIndex.scss'; @import './components/BadgeDialog.scss'; @import './components/BadgeSustainerInstructionsDialog.scss'; diff --git a/ts/background.ts b/ts/background.ts index 11a7cae9cda0..f63f4464eb98 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1310,6 +1310,12 @@ export async function startApp(): Promise { }); async function runStorageService() { + if (window.storage.get('backupDownloadPath')) { + log.info( + 'background: not running storage service while downloading backup' + ); + return; + } StorageService.enableStorageService(); StorageService.runStorageServiceSyncJob(); } @@ -1414,12 +1420,6 @@ export async function startApp(): Promise { ) ); - // Now that we authenticated - time to download the backup! - if (isBackupEnabled()) { - backupsService.start(); - drop(backupsService.download()); - } - // Cancel throttled calls to refreshRemoteConfig since our auth changed. window.Signal.RemoteConfig.maybeRefreshRemoteConfig.cancel(); drop(window.Signal.RemoteConfig.maybeRefreshRemoteConfig(server)); @@ -1460,7 +1460,11 @@ export async function startApp(): Promise { if (isCoreDataValid && Registration.everDone()) { drop(connect()); - window.reduxActions.app.openInbox(); + if (window.storage.get('backupDownloadPath')) { + window.reduxActions.app.openBackupImport(); + } else { + window.reduxActions.app.openInbox(); + } } else { window.IPC.readyForUpdates(); window.reduxActions.app.openInstaller(); @@ -1576,9 +1580,48 @@ export async function startApp(): Promise { onOffline(); } + if (window.storage.get('backupDownloadPath')) { + log.info( + 'background: not running storage service while downloading backup' + ); + drop(downloadBackup()); + return; + } + server.registerRequestHandler(messageReceiver); } + async function downloadBackup() { + const backupDownloadPath = window.storage.get('backupDownloadPath'); + if (!backupDownloadPath) { + log.warn('No backup download path, cannot download backup'); + return; + } + + const absoluteDownloadPath = + window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath); + log.info('downloadBackup: downloading to', absoluteDownloadPath); + await backupsService.download(absoluteDownloadPath, { + onProgress: (currentBytes, totalBytes) => { + window.reduxActions.app.updateBackupImportProgress({ + currentBytes, + totalBytes, + }); + }, + }); + await window.storage.remove('backupDownloadPath'); + window.reduxActions.app.openInbox(); + + log.info('downloadBackup: processing websocket messages, storage service'); + strictAssert(server != null, 'server must be initialized'); + strictAssert( + messageReceiver != null, + 'MessageReceiver must be initialized' + ); + server.registerRequestHandler(messageReceiver); + drop(runStorageService()); + } + window.getSyncRequest = (timeoutMillis?: number) => { strictAssert(messageReceiver, 'MessageReceiver not initialized'); diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 2861332676dd..0fa1f6fb013a 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -8,14 +8,18 @@ import classNames from 'classnames'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import type { VerificationTransport } from '../types/VerificationTransport'; import { ThemeType } from '../types/Util'; -import { AppViewType } from '../state/ducks/app'; +import type { LocalizerType } from '../types/Util'; +import { missingCaseError } from '../util/missingCaseError'; +import { type AppStateType, AppViewType } from '../state/ducks/app'; import { SmartInstallScreen } from '../state/smart/InstallScreen'; import { StandaloneRegistration } from './StandaloneRegistration'; +import { BackupImportScreen } from './BackupImportScreen'; import { usePageVisibility } from '../hooks/usePageVisibility'; import { useReducedMotion } from '../hooks/useReducedMotion'; type PropsType = { - appView: AppViewType; + i18n: LocalizerType; + state: AppStateType; openInbox: () => void; getCaptchaToken: () => Promise; registerSingleDevice: ( @@ -49,7 +53,8 @@ type PropsType = { }; export function App({ - appView, + i18n, + state, getCaptchaToken, hasSelectedStoryData, isFullScreen, @@ -70,9 +75,9 @@ export function App({ }: PropsType): JSX.Element { let contents; - if (appView === AppViewType.Installer) { + if (state.appView === AppViewType.Installer) { contents = ; - } else if (appView === AppViewType.Standalone) { + } else if (state.appView === AppViewType.Standalone) { const onComplete = () => { window.IPC.removeSetupMenuItems(); openInbox(); @@ -87,8 +92,14 @@ export function App({ uploadProfile={uploadProfile} /> ); - } else if (appView === AppViewType.Inbox) { + } else if (state.appView === AppViewType.Inbox) { contents = renderInbox(); + } else if (state.appView === AppViewType.Blank) { + contents = undefined; + } else if (state.appView === AppViewType.BackupImport) { + contents = ; + } else { + throw missingCaseError(state); } // This are here so that themes are properly applied to anything that is diff --git a/ts/components/BackupImportScreen.stories.tsx b/ts/components/BackupImportScreen.stories.tsx new file mode 100644 index 000000000000..00219c1ce1a5 --- /dev/null +++ b/ts/components/BackupImportScreen.stories.tsx @@ -0,0 +1,32 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { Meta, StoryFn } from '@storybook/react'; +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; +import type { PropsType } from './BackupImportScreen'; +import { BackupImportScreen } from './BackupImportScreen'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/BackupImportScreen', +} satisfies Meta; + +// eslint-disable-next-line react/function-component-definition +const Template: StoryFn = (args: PropsType) => ( + +); + +export const NoBytes = Template.bind({}); +NoBytes.args = { + currentBytes: undefined, + totalBytes: undefined, +}; + +export const Bytes = Template.bind({}); +Bytes.args = { + currentBytes: 500, + totalBytes: 1024, +}; diff --git a/ts/components/BackupImportScreen.tsx b/ts/components/BackupImportScreen.tsx new file mode 100644 index 000000000000..13eef852ec39 --- /dev/null +++ b/ts/components/BackupImportScreen.tsx @@ -0,0 +1,85 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { formatFileSize } from '../util/formatFileSize'; +import { TitlebarDragArea } from './TitlebarDragArea'; +import { InstallScreenSignalLogo } from './installScreen/InstallScreenSignalLogo'; + +// We can't always use destructuring assignment because of the complexity of this props +// type. + +export type PropsType = Readonly<{ + i18n: LocalizerType; + currentBytes?: number; + totalBytes?: number; +}>; + +export function BackupImportScreen({ + i18n, + currentBytes, + totalBytes, +}: PropsType): JSX.Element { + let percentage = 0; + let progress: JSX.Element; + if (currentBytes != null && totalBytes != null) { + percentage = Math.max(0, Math.min(1, currentBytes / totalBytes)); + if (percentage > 0 && percentage <= 0.01) { + percentage = 0.01; + } else if (percentage >= 0.99 && percentage < 1) { + percentage = 0.99; + } else { + percentage = Math.round(percentage * 100) / 100; + } + + progress = ( + <> +
+
+
+
+ {i18n('icu:BackupImportScreen__progressbar-hint', { + currentSize: formatFileSize(currentBytes), + totalSize: formatFileSize(totalBytes), + fractionComplete: percentage, + })} +
+ + ); + } else { + progress = ( + <> +
+
+ {i18n('icu:BackupImportScreen__progressbar-hint', { + currentSize: '', + totalSize: '', + fractionComplete: 0, + })} +
+ + ); + } + return ( +
+ + + + +
+

+ {i18n('icu:BackupImportScreen__title')} +

+ {progress} +
+ {i18n('icu:BackupImportScreen__description')} +
+
+
+ ); +} diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index 5ee4ab4f558f..9a46a947e199 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -14,6 +14,11 @@ import type { import type { BackupCredentials } from './credentials'; import { uploadFile } from '../../util/uploadAttachment'; +export type DownloadOptionsType = Readonly<{ + downloadOffset: number; + onProgress: (currentBytes: number, totalBytes: number) => void; +}>; + export class BackupAPI { private cachedBackupInfo: GetBackupInfoResponseType | undefined; constructor(private credentials: BackupCredentials) {} @@ -67,7 +72,10 @@ export class BackupAPI { }); } - public async download(): Promise { + public async download({ + downloadOffset, + onProgress, + }: DownloadOptionsType): Promise { const { cdn, backupDir, backupName } = await this.getInfo(); const { headers } = await this.credentials.getCDNReadCredentials(cdn); @@ -76,6 +84,8 @@ export class BackupAPI { backupDir, backupName, headers, + downloadOffset, + onProgress, }); } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 3c93c5a1b915..d3d55adde1ba 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -5,7 +5,8 @@ import { pipeline } from 'stream/promises'; import { PassThrough } from 'stream'; import type { Readable, Writable } from 'stream'; import { createReadStream, createWriteStream } from 'fs'; -import { unlink } from 'fs/promises'; +import { unlink, stat } from 'fs/promises'; +import { ensureFile } from 'fs-extra'; import { join } from 'path'; import { createGzip, createGunzip } from 'zlib'; import { createCipheriv, createHmac, randomBytes } from 'crypto'; @@ -26,13 +27,14 @@ import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac'; import { HOUR } from '../../util/durations'; import { CipherType, HashType } from '../../types/Crypto'; import * as Errors from '../../types/errors'; +import { HTTPError } from '../../textsecure/Errors'; import { constantTimeEqual } from '../../Crypto'; import { measureSize } from '../../AttachmentCrypto'; import { BackupExportStream } from './export'; import { BackupImportStream } from './import'; import { getKeyMaterial } from './crypto'; import { BackupCredentials } from './credentials'; -import { BackupAPI } from './api'; +import { BackupAPI, type DownloadOptionsType } from './api'; import { validateBackup } from './validator'; import { reinitializeRedux } from '../../state/reinitializeRedux'; import { getParametersForRedux, loadAll } from '../allLoaders'; @@ -131,18 +133,54 @@ export class BackupsService { return backupsService.importBackup(() => createReadStream(backupFile)); } - public async download(): Promise { - const path = window.Signal.Migrations.getAbsoluteTempPath( - randomBytes(32).toString('hex') - ); + public async download( + downloadPath: string, + { onProgress }: Omit + ): Promise { + let downloadOffset = 0; + try { + ({ size: downloadOffset } = await stat(downloadPath)); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } - const stream = await this.api.download(); - await pipeline(stream, createWriteStream(path)); + // File is missing - start from the beginning + } try { - await this.importFromDisk(path); - } finally { - await unlink(path); + await ensureFile(downloadPath); + + const stream = await this.api.download({ + downloadOffset, + onProgress, + }); + + await pipeline( + stream, + createWriteStream(downloadPath, { + flags: 'a', + start: downloadOffset, + }) + ); + + try { + await this.importFromDisk(downloadPath); + } finally { + await unlink(downloadPath); + } + } catch (error) { + // No backup on the server + if (error instanceof HTTPError && error.code === 404) { + return; + } + + try { + await unlink(downloadPath); + } catch { + // Best-effort + } + throw error; } } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index f52be0be1d62..326801b75faf 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -983,6 +983,7 @@ export type ServerReadableDirectInterface = ReadableInterface & { finishGetKnownMessageAttachments: ( cursor: MessageAttachmentsCursorType ) => void; + getKnownDownloads: () => Array; getKnownConversationAttachments: () => Array; getAllBadgeImageFileLocalPaths: () => Set; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index c27157f9c2ec..27269f48970b 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -346,6 +346,7 @@ export const DataReader: ServerReadableInterface = { finishGetKnownMessageAttachments, pageMessages, finishPageMessages, + getKnownDownloads, getKnownConversationAttachments, }; @@ -6810,6 +6811,17 @@ function finishPageMessages( `); } +function getKnownDownloads(db: ReadableDB): Array { + const result = []; + + const backup = getItemById(db, 'backupDownloadPath'); + if (backup) { + result.push(backup.value); + } + + return result; +} + function getKnownConversationAttachments(db: ReadableDB): Array { const result = new Set(); const chunkSize = 500; diff --git a/ts/state/ducks/app.ts b/ts/state/ducks/app.ts index fe42c36b491b..ae5e64c6843a 100644 --- a/ts/state/ducks/app.ts +++ b/ts/state/ducks/app.ts @@ -15,12 +15,32 @@ export enum AppViewType { Inbox = 'Inbox', Installer = 'Installer', Standalone = 'Standalone', + BackupImport = 'BackupImport', } -export type AppStateType = ReadonlyDeep<{ - appView: AppViewType; - hasInitialLoadCompleted: boolean; -}>; +export type AppStateType = ReadonlyDeep< + { + hasInitialLoadCompleted: boolean; + } & ( + | { + appView: AppViewType.Blank; + } + | { + appView: AppViewType.Inbox; + } + | { + appView: AppViewType.Installer; + } + | { + appView: AppViewType.Standalone; + } + | { + appView: AppViewType.BackupImport; + currentBytes?: number; + totalBytes?: number; + } + ) +>; // Actions @@ -28,6 +48,8 @@ const INITIAL_LOAD_COMPLETE = 'app/INITIAL_LOAD_COMPLETE'; const OPEN_INBOX = 'app/OPEN_INBOX'; const OPEN_INSTALLER = 'app/OPEN_INSTALLER'; const OPEN_STANDALONE = 'app/OPEN_STANDALONE'; +const OPEN_BACKUP_IMPORT = 'app/OPEN_BACKUP_IMPORT'; +const UPDATE_BACKUP_IMPORT_PROGRESS = 'app/UPDATE_BACKUP_IMPORT_PROGRESS'; type InitialLoadCompleteActionType = ReadonlyDeep<{ type: typeof INITIAL_LOAD_COMPLETE; @@ -45,11 +67,25 @@ type OpenStandaloneActionType = ReadonlyDeep<{ type: typeof OPEN_STANDALONE; }>; +type OpenBackupImportActionType = ReadonlyDeep<{ + type: typeof OPEN_BACKUP_IMPORT; +}>; + +type UpdateBackupImportProgressActionType = ReadonlyDeep<{ + type: typeof UPDATE_BACKUP_IMPORT_PROGRESS; + payload: { + currentBytes: number; + totalBytes: number; + }; +}>; + export type AppActionType = ReadonlyDeep< | InitialLoadCompleteActionType | OpenInboxActionType | OpenInstallerActionType | OpenStandaloneActionType + | OpenBackupImportActionType + | UpdateBackupImportProgressActionType >; export const actions = { @@ -57,6 +93,8 @@ export const actions = { openInbox, openInstaller, openStandalone, + openBackupImport, + updateBackupImportProgress, }; export const useAppActions = (): BoundActionCreatorsMapObject => @@ -118,6 +156,16 @@ function openStandalone(): ThunkAction< }; } +function openBackupImport(): OpenBackupImportActionType { + return { type: OPEN_BACKUP_IMPORT }; +} + +function updateBackupImportProgress( + payload: UpdateBackupImportProgressActionType['payload'] +): UpdateBackupImportProgressActionType { + return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload }; +} + // Reducer export function getEmptyState(): AppStateType { @@ -159,5 +207,24 @@ export function reducer( }; } + if (action.type === OPEN_BACKUP_IMPORT) { + return { + ...state, + appView: AppViewType.BackupImport, + }; + } + + if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) { + if (state.appView !== AppViewType.BackupImport) { + return state; + } + + return { + ...state, + currentBytes: action.payload.currentBytes, + totalBytes: action.payload.totalBytes, + }; + } + return state; } diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index 1a59ce57144e..f53143495779 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -18,6 +18,7 @@ import { getIsMainWindowMaximized, getIsMainWindowFullScreen, getTheme, + getIntl, } from '../selectors/user'; import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories'; import { useAppActions } from '../ducks/app'; @@ -26,7 +27,7 @@ import { useStoriesActions } from '../ducks/stories'; import { ErrorBoundary } from '../../components/ErrorBoundary'; import { ModalContainer } from '../../components/ModalContainer'; import { SmartInbox } from './Inbox'; -import { getAppView } from '../selectors/app'; +import { getApp } from '../selectors/app'; function renderInbox(): JSX.Element { return ; @@ -110,7 +111,8 @@ async function uploadProfile({ } export const SmartApp = memo(function SmartApp() { - const appView = useSelector(getAppView); + const i18n = useSelector(getIntl); + const state = useSelector(getApp); const isMaximized = useSelector(getIsMainWindowMaximized); const isFullScreen = useSelector(getIsMainWindowFullScreen); const hasSelectedStoryData = useSelector(getHasSelectedStoryData); @@ -124,7 +126,8 @@ export const SmartApp = memo(function SmartApp() { return ( ; + downloadOffset: number; + onProgress: (currentBytes: number, totalBytes: number) => void; }>; export const getBackupInfoResponseSchema = z.object({ @@ -2825,12 +2828,18 @@ export function initialize({ cdn, backupDir, backupName, + downloadOffset, + onProgress, }: GetBackupStreamOptionsType): Promise { return _getAttachment({ cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`, cdnNumber: cdn, redactor: _createRedactor(backupDir, backupName), headers, + options: { + downloadOffset, + onProgress, + }, }); } @@ -3555,6 +3564,7 @@ export function initialize({ disableRetries?: boolean; timeout?: number; downloadOffset?: number; + onProgress?: (currentBytes: number, totalBytes: number) => void; }; }): Promise { const abortController = new AbortController(); @@ -3568,6 +3578,8 @@ export function initialize({ registerInflightRequest(cancelRequest); + let totalBytes = 0; + // This is going to the CDN, not the service, so we use _outerAjax try { const targetHeaders = { ...headers }; @@ -3587,7 +3599,22 @@ export function initialize({ abortSignal: abortController.signal, }); - if (targetHeaders.range != null) { + if (targetHeaders.range == null) { + const contentLength = + streamWithDetails.response.headers.get('content-length'); + strictAssert( + contentLength != null, + 'Attachment Content-Length is absent' + ); + + const maybeSize = safeParseNumber(contentLength); + strictAssert( + maybeSize != null, + 'Attachment Content-Length is not a number' + ); + + totalBytes = maybeSize; + } else { strictAssert( streamWithDetails.response.status === 206, `Expected 206 status code for offset ${options?.downloadOffset}` @@ -3596,6 +3623,19 @@ export function initialize({ !streamWithDetails.contentType?.includes('multipart'), `Expected non-multipart response for ${cdnUrl}${cdnPath}` ); + + const range = streamWithDetails.response.headers.get('content-range'); + strictAssert(range != null, 'Attachment Content-Range is absent'); + + const match = PARSE_RANGE_HEADER.exec(range); + strictAssert(match != null, 'Attachment Content-Range is invalid'); + const maybeSize = safeParseNumber(match[1]); + strictAssert( + maybeSize != null, + 'Attachment Content-Range[1] is not a number' + ); + + totalBytes = maybeSize; } } finally { if (!streamWithDetails) { @@ -3620,6 +3660,17 @@ export function initialize({ }) .pipe(timeoutStream); + if (options?.onProgress) { + const { onProgress } = options; + let currentBytes = options.downloadOffset ?? 0; + + combinedStream.pause(); + combinedStream.on('data', chunk => { + currentBytes += chunk.byteLength; + onProgress(currentBytes, totalBytes); + }); + } + return combinedStream; } diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 9168474a79cf..5e41c2ecef0a 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -183,6 +183,9 @@ export type StorageAccessType = { // remove it in `ts/background.ts` }; + // If present - we are downloading backup + backupDownloadPath: string; + // Deprecated 'challenge:retry-message-ids': never; nextSignedKeyRotationTime: number;