Resumable backup import
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
parent
f8b6f1b318
commit
225190415b
17 changed files with 498 additions and 33 deletions
|
@ -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"
|
||||
|
|
|
@ -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.
|
||||
|
|
75
stylesheets/components/BackupImportScreen.scss
Normal file
75
stylesheets/components/BackupImportScreen.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
32
ts/components/BackupImportScreen.stories.tsx
Normal file
32
ts/components/BackupImportScreen.stories.tsx
Normal 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,
|
||||
};
|
85
ts/components/BackupImportScreen.tsx
Normal file
85
ts/components/BackupImportScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -983,6 +983,7 @@ export type ServerReadableDirectInterface = ReadableInterface & {
|
|||
finishGetKnownMessageAttachments: (
|
||||
cursor: MessageAttachmentsCursorType
|
||||
) => void;
|
||||
getKnownDownloads: () => Array<string>;
|
||||
getKnownConversationAttachments: () => Array<string>;
|
||||
|
||||
getAllBadgeImageFileLocalPaths: () => Set<string>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
3
ts/types/Storage.d.ts
vendored
3
ts/types/Storage.d.ts
vendored
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue