Allow cancel during backup import processing
This commit is contained in:
parent
721a875c44
commit
8fef392e7f
7 changed files with 143 additions and 83 deletions
|
@ -4803,6 +4803,10 @@
|
|||
"messageformat": "Finalizing message transfer...",
|
||||
"description": "Hint under the progressbar in the backup import screen shown after the backup file is downloaded and while backup data is being processed"
|
||||
},
|
||||
"icu:BackupImportScreen__progressbar-hint--canceling": {
|
||||
"messageformat": "Canceling...",
|
||||
"description": "Hint under the progressbar in the backup import screen after user cancels"
|
||||
},
|
||||
"icu:BackupImportScreen__progressbar-hint--preparing": {
|
||||
"messageformat": "Preparing to download...",
|
||||
"description": "Hint under the progressbar in the backup import screen when download size is not yet known"
|
||||
|
@ -4820,19 +4824,19 @@
|
|||
"description": "Text shown while importing messages & chats from the user's primary device."
|
||||
},
|
||||
"icu:BackupImportScreen__cancel-confirmation__title": {
|
||||
"messageformat": "Cancel transfer?",
|
||||
"messageformat": "Cancel device linking?",
|
||||
"description": "Title of the cancel confirmation modal in the backup import screen"
|
||||
},
|
||||
"icu:BackupImportScreen__cancel-confirmation__body": {
|
||||
"messageformat": "Your messages and media have not completed restoring. If you choose to cancel, your device will be linked without your message history.",
|
||||
"messageformat": "If you choose to cancel, this device will not be linked and no messages or media will be transferred.",
|
||||
"description": "Body of the cancel confirmation modal in the backup import screen"
|
||||
},
|
||||
"icu:BackupImportScreen__cancel-confirmation__cancel": {
|
||||
"messageformat": "Continue transfer",
|
||||
"messageformat": "Continue linking",
|
||||
"description": "Text of the continue button of the cancel confirmation modal in the backup import screen"
|
||||
},
|
||||
"icu:BackupImportScreen__cancel-confirmation__confirm": {
|
||||
"messageformat": "Cancel transfer",
|
||||
"messageformat": "Cancel linking",
|
||||
"description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen"
|
||||
},
|
||||
"icu:BackupImportScreen__error__title": {
|
||||
|
|
|
@ -147,6 +147,14 @@ FatalError.args = {
|
|||
error: InstallScreenBackupError.Fatal,
|
||||
};
|
||||
|
||||
export const Canceled = Template.bind({});
|
||||
Canceled.args = {
|
||||
backupStep: InstallScreenBackupStep.Process,
|
||||
currentBytes: 500 * 1024,
|
||||
totalBytes: 1024 * 1024,
|
||||
error: InstallScreenBackupError.Canceled,
|
||||
};
|
||||
|
||||
export const UnsupportedVersion = Template.bind({});
|
||||
UnsupportedVersion.args = {
|
||||
backupStep: InstallScreenBackupStep.Process,
|
||||
|
|
|
@ -69,7 +69,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
} = props;
|
||||
|
||||
const [isConfirmingCancel, setIsConfirmingCancel] = useState(false);
|
||||
const [isConfirmingSkip, setIsConfirmingSkip] = useState(false);
|
||||
|
||||
const confirmCancel = useCallback(() => {
|
||||
setIsConfirmingCancel(true);
|
||||
|
@ -84,22 +83,9 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
setIsConfirmingCancel(false);
|
||||
}, [onCancel]);
|
||||
|
||||
const confirmSkip = useCallback(() => {
|
||||
setIsConfirmingSkip(true);
|
||||
}, []);
|
||||
|
||||
const abortSkip = useCallback(() => {
|
||||
setIsConfirmingSkip(false);
|
||||
}, []);
|
||||
|
||||
const onSkipWrap = useCallback(() => {
|
||||
onCancel();
|
||||
setIsConfirmingSkip(false);
|
||||
}, [onCancel]);
|
||||
|
||||
const onRetryWrap = useCallback(() => {
|
||||
onRetry();
|
||||
setIsConfirmingSkip(false);
|
||||
setIsConfirmingCancel(false);
|
||||
}, [onRetry]);
|
||||
|
||||
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
|
||||
|
@ -109,7 +95,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
);
|
||||
|
||||
let errorElem: JSX.Element | undefined;
|
||||
if (error == null) {
|
||||
if (error == null || error === InstallScreenBackupError.Canceled) {
|
||||
// no-op
|
||||
} else if (error === InstallScreenBackupError.UnsupportedVersion) {
|
||||
errorElem = (
|
||||
|
@ -120,17 +106,19 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
startUpdate={startUpdate}
|
||||
forceUpdate={forceUpdate}
|
||||
currentVersion={currentVersion}
|
||||
onClose={confirmSkip}
|
||||
onClose={confirmCancel}
|
||||
OS={OS}
|
||||
/>
|
||||
);
|
||||
} else if (error === InstallScreenBackupError.Retriable) {
|
||||
if (!isConfirmingSkip) {
|
||||
if (!isConfirmingCancel) {
|
||||
errorElem = (
|
||||
<ConfirmationDialog
|
||||
dialogName="InstallScreenBackupImportStep.error"
|
||||
title={i18n('icu:BackupImportScreen__error__title')}
|
||||
cancelText={i18n('icu:BackupImportScreen__skip')}
|
||||
cancelText={i18n(
|
||||
'icu:BackupImportScreen__cancel-confirmation__confirm'
|
||||
)}
|
||||
actions={[
|
||||
{
|
||||
action: onRetryWrap,
|
||||
|
@ -139,7 +127,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={confirmSkip}
|
||||
onClose={confirmCancel}
|
||||
>
|
||||
{i18n('icu:BackupImportScreen__error__body')}
|
||||
</ConfirmationDialog>
|
||||
|
@ -170,6 +158,24 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
throw missingCaseError(error);
|
||||
}
|
||||
|
||||
const isCanceled = error === InstallScreenBackupError.Canceled;
|
||||
let cancelButton: JSX.Element | undefined;
|
||||
if (
|
||||
!isCanceled &&
|
||||
(backupStep === InstallScreenBackupStep.Download ||
|
||||
backupStep === InstallScreenBackupStep.Process)
|
||||
) {
|
||||
cancelButton = (
|
||||
<button
|
||||
className="InstallScreenBackupImportStep__cancel"
|
||||
type="button"
|
||||
onClick={confirmCancel}
|
||||
>
|
||||
{i18n('icu:BackupImportScreen__cancel')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="InstallScreenBackupImportStep">
|
||||
<TitlebarDragArea />
|
||||
|
@ -178,10 +184,12 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
<h3 className="InstallScreenBackupImportStep__title">
|
||||
{i18n('icu:BackupImportScreen__title')}
|
||||
</h3>
|
||||
<ProgressBarAndDescription {...props} />
|
||||
<div className="InstallScreenBackupImportStep__description">
|
||||
{i18n('icu:BackupImportScreen__description')}
|
||||
</div>
|
||||
<ProgressBarAndDescription {...props} isCanceled={isCanceled} />
|
||||
{!isCanceled && (
|
||||
<div className="InstallScreenBackupImportStep__description">
|
||||
{i18n('icu:BackupImportScreen__description')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="InstallScreenBackupImportStep__footer">
|
||||
<div className="InstallScreenBackupImportStep__security">
|
||||
|
@ -195,15 +203,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{backupStep === InstallScreenBackupStep.Download && (
|
||||
<button
|
||||
className="InstallScreenBackupImportStep__cancel"
|
||||
type="button"
|
||||
onClick={confirmCancel}
|
||||
>
|
||||
{i18n('icu:BackupImportScreen__cancel')}
|
||||
</button>
|
||||
)}
|
||||
{cancelButton}
|
||||
</div>
|
||||
|
||||
{isConfirmingCancel && (
|
||||
|
@ -229,25 +229,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{isConfirmingSkip && (
|
||||
<ConfirmationDialog
|
||||
dialogName="InstallScreenBackupImportStep.confirmSkip"
|
||||
title={i18n('icu:BackupImportScreen__skip-confirmation__title')}
|
||||
cancelText={i18n('icu:BackupImportScreen__skip-confirmation__cancel')}
|
||||
actions={[
|
||||
{
|
||||
action: onSkipWrap,
|
||||
style: 'affirmative',
|
||||
text: i18n('icu:BackupImportScreen__skip'),
|
||||
},
|
||||
]}
|
||||
i18n={i18n}
|
||||
onClose={abortSkip}
|
||||
>
|
||||
{i18n('icu:BackupImportScreen__skip-confirmation__body')}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{errorElem}
|
||||
</div>
|
||||
);
|
||||
|
@ -256,6 +237,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
|
|||
type ProgressBarPropsType = Readonly<
|
||||
{
|
||||
i18n: LocalizerType;
|
||||
isCanceled: boolean;
|
||||
} & (
|
||||
| {
|
||||
backupStep: InstallScreenBackupStep.WaitForBackup;
|
||||
|
@ -271,7 +253,7 @@ type ProgressBarPropsType = Readonly<
|
|||
>;
|
||||
|
||||
function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element {
|
||||
const { backupStep, i18n } = props;
|
||||
const { backupStep, i18n, isCanceled } = props;
|
||||
if (backupStep === InstallScreenBackupStep.WaitForBackup) {
|
||||
return (
|
||||
<>
|
||||
|
@ -292,6 +274,20 @@ function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element {
|
|||
currentBytes / totalBytes
|
||||
);
|
||||
|
||||
if (isCanceled) {
|
||||
return (
|
||||
<>
|
||||
<ProgressBar
|
||||
fractionComplete={fractionComplete}
|
||||
isRTL={i18n.getLocaleDirection() === 'rtl'}
|
||||
/>
|
||||
<div className="InstallScreenBackupImportStep__progressbar-hint">
|
||||
{i18n('icu:BackupImportScreen__progressbar-hint--canceling')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (backupStep === InstallScreenBackupStep.Download) {
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -14,6 +14,8 @@ export class BackupDownloadFailedError extends Error {}
|
|||
|
||||
export class BackupProcessingError extends Error {}
|
||||
|
||||
export class BackupImportCanceledError extends Error {}
|
||||
|
||||
export class RelinkRequestedError extends Error {}
|
||||
|
||||
export class ContinueWithoutSyncingError extends Error {}
|
||||
|
|
|
@ -51,6 +51,7 @@ import { validateBackup } from './validator';
|
|||
import { BackupType } from './types';
|
||||
import {
|
||||
BackupDownloadFailedError,
|
||||
BackupImportCanceledError,
|
||||
BackupProcessingError,
|
||||
ContinueWithoutSyncingError,
|
||||
RelinkRequestedError,
|
||||
|
@ -93,6 +94,7 @@ export type ImportOptionsType = Readonly<{
|
|||
export class BackupsService {
|
||||
#isStarted = false;
|
||||
#isRunning: 'import' | 'export' | false = false;
|
||||
#importController: AbortController | undefined;
|
||||
#downloadController: AbortController | undefined;
|
||||
|
||||
#downloadRetryPromise:
|
||||
|
@ -152,14 +154,14 @@ export class BackupsService {
|
|||
this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();
|
||||
|
||||
let installerError: InstallScreenBackupError;
|
||||
let shouldUnlinkAndDeleteData = false;
|
||||
if (error instanceof RelinkRequestedError) {
|
||||
installerError = InstallScreenBackupError.Fatal;
|
||||
log.error(
|
||||
'backups.downloadAndImport: primary requested relink; unlinking & deleting data',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.#unlinkAndDeleteAllData();
|
||||
shouldUnlinkAndDeleteData = true;
|
||||
} else if (error instanceof UnsupportedBackupVersion) {
|
||||
installerError = InstallScreenBackupError.UnsupportedVersion;
|
||||
log.error(
|
||||
|
@ -178,8 +180,13 @@ export class BackupsService {
|
|||
'backups.downloadAndImport: fatal error during processing; unlinking & deleting data',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.#unlinkAndDeleteAllData();
|
||||
shouldUnlinkAndDeleteData = true;
|
||||
} else if (error instanceof BackupImportCanceledError) {
|
||||
installerError = InstallScreenBackupError.Canceled;
|
||||
log.info(
|
||||
'backups.downloadAndImport: Processing canceled by user; unlinking & deleting data'
|
||||
);
|
||||
shouldUnlinkAndDeleteData = true;
|
||||
} else {
|
||||
log.error(
|
||||
'backups.downloadAndImport: unknown error, prompting user to retry'
|
||||
|
@ -191,10 +198,20 @@ export class BackupsService {
|
|||
error: installerError,
|
||||
});
|
||||
|
||||
// Deleting data takes some time
|
||||
if (shouldUnlinkAndDeleteData) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.#unlinkAndDeleteAllData();
|
||||
}
|
||||
|
||||
// For download errors, wait for user confirmation to retry or unlink
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const nextStep = await this.#downloadRetryPromise.promise;
|
||||
if (nextStep === 'retry') {
|
||||
continue;
|
||||
} else if (nextStep === 'cancel') {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await this.#unlinkAndDeleteAllData();
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -211,6 +228,7 @@ export class BackupsService {
|
|||
await window.storage.remove('backupEphemeralKey');
|
||||
await window.storage.put('isRestoredFromBackup', hasBackup);
|
||||
|
||||
// If the primary cancels sync on their end, then we can link without sync
|
||||
if (!hasBackup) {
|
||||
window.reduxActions.installer.handleMissingBackup();
|
||||
}
|
||||
|
@ -318,16 +336,27 @@ export class BackupsService {
|
|||
return this.importBackup(() => createReadStream(backupFile), options);
|
||||
}
|
||||
|
||||
public cancelDownload(): void {
|
||||
public cancelDownloadAndImport(): void {
|
||||
if (!this.#downloadController && !this.#importController) {
|
||||
log.error(
|
||||
'cancelDownloadAndImport: not canceling, download or import is not running'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#downloadController) {
|
||||
log.warn('importBackup: canceling download');
|
||||
log.warn('cancelDownloadAndImport: canceling download');
|
||||
this.#downloadController.abort();
|
||||
this.#downloadController = undefined;
|
||||
if (this.#downloadRetryPromise) {
|
||||
this.#downloadRetryPromise.resolve('cancel');
|
||||
}
|
||||
} else {
|
||||
log.error('importBackup: not canceling download, not running');
|
||||
}
|
||||
|
||||
if (this.#importController) {
|
||||
log.warn('cancelDownloadAndImport: canceling import processing');
|
||||
this.#importController.abort();
|
||||
this.#importController = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -350,6 +379,11 @@ export class BackupsService {
|
|||
await DataWriter.disableMessageInsertTriggers();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
|
||||
this.#importController?.abort();
|
||||
this.#importController = controller;
|
||||
|
||||
window.ConversationController.setReadOnly(true);
|
||||
|
||||
const importStream = await BackupImportStream.create(backupType);
|
||||
|
@ -378,6 +412,10 @@ export class BackupsService {
|
|||
sink
|
||||
);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
onProgress?.(0, totalBytes);
|
||||
|
||||
strictAssert(theirMac != null, 'importBackup: Missing MAC');
|
||||
|
@ -405,7 +443,8 @@ export class BackupsService {
|
|||
getIvAndDecipher(aesKey),
|
||||
createGunzip(),
|
||||
new DelimitedStream(),
|
||||
importStream
|
||||
importStream,
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
strictAssert(
|
||||
|
@ -432,7 +471,12 @@ export class BackupsService {
|
|||
|
||||
log.info('importBackup: finished...');
|
||||
} catch (error) {
|
||||
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
||||
if (error.name === 'AbortError') {
|
||||
log.info('importBackup: canceled by user');
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
log.error(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
|
||||
|
||||
if (isNightly(window.getVersion()) || isAdhoc(window.getVersion())) {
|
||||
window.reduxActions.toast.showToast({
|
||||
|
@ -444,6 +488,8 @@ export class BackupsService {
|
|||
} finally {
|
||||
window.ConversationController.setReadOnly(false);
|
||||
this.#isRunning = false;
|
||||
this.#importController = undefined;
|
||||
|
||||
await DataWriter.enableMessageInsertTriggersAndBackfill();
|
||||
|
||||
window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' });
|
||||
|
@ -531,7 +577,7 @@ export class BackupsService {
|
|||
await ensureFile(downloadPath);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return false;
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
let stream: Readable;
|
||||
|
@ -551,7 +597,7 @@ export class BackupsService {
|
|||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return false;
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
// No backup on the server
|
||||
|
@ -580,7 +626,7 @@ export class BackupsService {
|
|||
}
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return false;
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
|
@ -592,14 +638,14 @@ export class BackupsService {
|
|||
);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
return false;
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
this.#downloadController = undefined;
|
||||
|
||||
try {
|
||||
// Too late to cancel now, make sure we are unlinked if the process
|
||||
// is aborted due to error or restart.
|
||||
// Import and start writing to the DB. Make sure we are unlinked
|
||||
// if the import process is aborted due to error or restart.
|
||||
const password = window.storage.get('password');
|
||||
strictAssert(password != null, 'Must be registered to import backup');
|
||||
|
||||
|
@ -619,15 +665,19 @@ export class BackupsService {
|
|||
// Restore password on success
|
||||
await window.storage.put('password', password);
|
||||
} catch (e) {
|
||||
// Error during import; this is non-retriable
|
||||
throw new BackupProcessingError();
|
||||
// Error or manual cancel during import; this is non-retriable
|
||||
if (e instanceof BackupImportCanceledError) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new BackupProcessingError();
|
||||
}
|
||||
} finally {
|
||||
await unlink(downloadPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Download canceled
|
||||
if (error.name === 'AbortError') {
|
||||
return false;
|
||||
throw new BackupImportCanceledError();
|
||||
}
|
||||
|
||||
// Other errors bubble up and can be retried
|
||||
|
@ -731,6 +781,10 @@ export class BackupsService {
|
|||
Errors.toLogFormat(e)
|
||||
);
|
||||
}
|
||||
|
||||
// The QR code should be regenerated only after all data is cleared to prevent
|
||||
// a race where the QR code doesn't show the backup capability
|
||||
window.reduxActions.installer.startInstaller();
|
||||
}
|
||||
|
||||
public isImportRunning(): boolean {
|
||||
|
|
|
@ -8,7 +8,6 @@ import { useSelector } from 'react-redux';
|
|||
import { getIntl } from '../selectors/user';
|
||||
import { getUpdatesState } from '../selectors/updates';
|
||||
import { getInstallerState } from '../selectors/installer';
|
||||
import { useAppActions } from '../ducks/app';
|
||||
import { useInstallerActions } from '../ducks/installer';
|
||||
import { useUpdatesActions } from '../ducks/updates';
|
||||
import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
|
||||
|
@ -29,7 +28,6 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
|||
const i18n = useSelector(getIntl);
|
||||
const installerState = useSelector(getInstallerState);
|
||||
const updates = useSelector(getUpdatesState);
|
||||
const { openInbox } = useAppActions();
|
||||
const { startInstaller, finishInstall, retryBackupImport } =
|
||||
useInstallerActions();
|
||||
const { startUpdate, forceUpdate } = useUpdatesActions();
|
||||
|
@ -56,11 +54,8 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
|||
}, [backupFile, deviceName, finishInstall]);
|
||||
|
||||
const onCancelBackupImport = useCallback((): void => {
|
||||
backupsService.cancelDownload();
|
||||
if (installerState.step === InstallScreenStep.BackupImport) {
|
||||
openInbox();
|
||||
}
|
||||
}, [installerState.step, openInbox]);
|
||||
backupsService.cancelDownloadAndImport();
|
||||
}, []);
|
||||
|
||||
const suggestedDeviceName =
|
||||
installerState.step === InstallScreenStep.ChoosingDeviceName
|
||||
|
|
|
@ -22,6 +22,7 @@ export enum InstallScreenBackupError {
|
|||
UnsupportedVersion = 'UnsupportedVersion',
|
||||
Retriable = 'Retriable',
|
||||
Fatal = 'Fatal',
|
||||
Canceled = 'Canceled',
|
||||
}
|
||||
|
||||
export enum InstallScreenError {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue