Allow cancel during backup import processing

This commit is contained in:
ayumi-signal 2025-01-30 07:39:00 -08:00 committed by GitHub
parent 721a875c44
commit 8fef392e7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 143 additions and 83 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,6 +22,7 @@ export enum InstallScreenBackupError {
UnsupportedVersion = 'UnsupportedVersion',
Retriable = 'Retriable',
Fatal = 'Fatal',
Canceled = 'Canceled',
}
export enum InstallScreenError {