Resumable backup import

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-08-28 02:52:08 -05:00 committed by GitHub
parent f8b6f1b318
commit 225190415b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 498 additions and 33 deletions

View file

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

View file

@ -275,6 +275,22 @@ async function cleanupOrphanedAttachments({
);
}
{
const downloads: Array<string> = 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.

View file

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

View file

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

View file

@ -1310,6 +1310,12 @@ export async function startApp(): Promise<void> {
});
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<void> {
)
);
// 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<void> {
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<void> {
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');

View file

@ -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<string>;
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 = <SmartInstallScreen />;
} 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 = <BackupImportScreen i18n={i18n} {...state} />;
} else {
throw missingCaseError(state);
}
// This are here so that themes are properly applied to anything that is

View file

@ -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<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = (args: PropsType) => (
<BackupImportScreen {...args} i18n={i18n} />
);
export const NoBytes = Template.bind({});
NoBytes.args = {
currentBytes: undefined,
totalBytes: undefined,
};
export const Bytes = Template.bind({});
Bytes.args = {
currentBytes: 500,
totalBytes: 1024,
};

View file

@ -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 = (
<>
<div className="BackupImportScreen__progressbar">
<div
className="BackupImportScreen__progressbar__fill"
style={{ transform: `translateX(${(percentage - 1) * 100}%)` }}
/>
</div>
<div className="BackupImportScreen__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: formatFileSize(currentBytes),
totalSize: formatFileSize(totalBytes),
fractionComplete: percentage,
})}
</div>
</>
);
} else {
progress = (
<>
<div className="BackupImportScreen__progressbar" />
<div className="BackupImportScreen__progressbar-hint BackupImportScreen__progressbar-hint--hidden">
{i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: '',
totalSize: '',
fractionComplete: 0,
})}
</div>
</>
);
}
return (
<div className="BackupImportScreen">
<TitlebarDragArea />
<InstallScreenSignalLogo />
<div className="BackupImportScreen__content">
<h3 className="BackupImportScreen__title">
{i18n('icu:BackupImportScreen__title')}
</h3>
{progress}
<div className="BackupImportScreen__description">
{i18n('icu:BackupImportScreen__description')}
</div>
</div>
</div>
);
}

View file

@ -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<Readable> {
public async download({
downloadOffset,
onProgress,
}: DownloadOptionsType): Promise<Readable> {
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,
});
}

View file

@ -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<void> {
const path = window.Signal.Migrations.getAbsoluteTempPath(
randomBytes(32).toString('hex')
);
public async download(
downloadPath: string,
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
): Promise<void> {
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;
}
}

View file

@ -983,6 +983,7 @@ export type ServerReadableDirectInterface = ReadableInterface & {
finishGetKnownMessageAttachments: (
cursor: MessageAttachmentsCursorType
) => void;
getKnownDownloads: () => Array<string>;
getKnownConversationAttachments: () => Array<string>;
getAllBadgeImageFileLocalPaths: () => Set<string>;

View file

@ -346,6 +346,7 @@ export const DataReader: ServerReadableInterface = {
finishGetKnownMessageAttachments,
pageMessages,
finishPageMessages,
getKnownDownloads,
getKnownConversationAttachments,
};
@ -6727,6 +6728,17 @@ function finishPageMessages(
`);
}
function getKnownDownloads(db: ReadableDB): Array<string> {
const result = [];
const backup = getItemById(db, 'backupDownloadPath');
if (backup) {
result.push(backup.value);
}
return result;
}
function getKnownConversationAttachments(db: ReadableDB): Array<string> {
const result = new Set<string>();
const chunkSize = 500;

View file

@ -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<typeof actions> =>
@ -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;
}

View file

@ -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 <SmartInbox />;
@ -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 (
<App
appView={appView}
i18n={i18n}
state={state}
isMaximized={isMaximized}
isFullScreen={isFullScreen}
getCaptchaToken={getCaptchaToken}

View file

@ -62,6 +62,8 @@ import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import type { StorageAccessType } from '../types/Storage';
import { linkDeviceRoute } from '../util/signalRoutes';
import { getRelativePath, createName } from '../util/attachmentPath';
import { isBackupEnabled } from '../util/isBackupEnabled';
type StorageKeyByServiceIdKind = {
[kind in ServiceIdKind]: keyof StorageAccessType;
@ -1271,6 +1273,9 @@ export default class AccountManager extends EventTarget {
const regionCode = getRegionCodeForNumber(number);
await storage.put('regionCode', regionCode);
if (isBackupEnabled()) {
await storage.put('backupDownloadPath', getRelativePath(createName()));
}
await storage.protocol.hydrateCaches();
const store = storage.protocol;

View file

@ -69,6 +69,7 @@ import { handleStatusCode, translateError } from './Utils';
import * as log from '../logging/log';
import { maybeParseUrl, urlPathFromComponents } from '../util/url';
import { SECOND } from '../util/durations';
import { safeParseNumber } from '../util/numbers';
import type { IWebSocketResource } from './WebsocketResources';
import { Environment, getEnvironment } from '../environment';
@ -1187,6 +1188,8 @@ export type GetBackupStreamOptionsType = Readonly<{
backupDir: string;
backupName: string;
headers: Record<string, string>;
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<Readable> {
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<Readable> {
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;
}

View file

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