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...", "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" "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": { "icu:BackupImportScreen__progressbar-hint--preparing": {
"messageformat": "Preparing to download...", "messageformat": "Preparing to download...",
"description": "Hint under the progressbar in the backup import screen when download size is not yet known" "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." "description": "Text shown while importing messages & chats from the user's primary device."
}, },
"icu:BackupImportScreen__cancel-confirmation__title": { "icu:BackupImportScreen__cancel-confirmation__title": {
"messageformat": "Cancel transfer?", "messageformat": "Cancel device linking?",
"description": "Title of the cancel confirmation modal in the backup import screen" "description": "Title of the cancel confirmation modal in the backup import screen"
}, },
"icu:BackupImportScreen__cancel-confirmation__body": { "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" "description": "Body of the cancel confirmation modal in the backup import screen"
}, },
"icu:BackupImportScreen__cancel-confirmation__cancel": { "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" "description": "Text of the continue button of the cancel confirmation modal in the backup import screen"
}, },
"icu:BackupImportScreen__cancel-confirmation__confirm": { "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" "description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen"
}, },
"icu:BackupImportScreen__error__title": { "icu:BackupImportScreen__error__title": {

View file

@ -147,6 +147,14 @@ FatalError.args = {
error: InstallScreenBackupError.Fatal, 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({}); export const UnsupportedVersion = Template.bind({});
UnsupportedVersion.args = { UnsupportedVersion.args = {
backupStep: InstallScreenBackupStep.Process, backupStep: InstallScreenBackupStep.Process,

View file

@ -69,7 +69,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
} = props; } = props;
const [isConfirmingCancel, setIsConfirmingCancel] = useState(false); const [isConfirmingCancel, setIsConfirmingCancel] = useState(false);
const [isConfirmingSkip, setIsConfirmingSkip] = useState(false);
const confirmCancel = useCallback(() => { const confirmCancel = useCallback(() => {
setIsConfirmingCancel(true); setIsConfirmingCancel(true);
@ -84,22 +83,9 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
setIsConfirmingCancel(false); setIsConfirmingCancel(false);
}, [onCancel]); }, [onCancel]);
const confirmSkip = useCallback(() => {
setIsConfirmingSkip(true);
}, []);
const abortSkip = useCallback(() => {
setIsConfirmingSkip(false);
}, []);
const onSkipWrap = useCallback(() => {
onCancel();
setIsConfirmingSkip(false);
}, [onCancel]);
const onRetryWrap = useCallback(() => { const onRetryWrap = useCallback(() => {
onRetry(); onRetry();
setIsConfirmingSkip(false); setIsConfirmingCancel(false);
}, [onRetry]); }, [onRetry]);
const learnMoreLink = (parts: Array<string | JSX.Element>) => ( const learnMoreLink = (parts: Array<string | JSX.Element>) => (
@ -109,7 +95,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
); );
let errorElem: JSX.Element | undefined; let errorElem: JSX.Element | undefined;
if (error == null) { if (error == null || error === InstallScreenBackupError.Canceled) {
// no-op // no-op
} else if (error === InstallScreenBackupError.UnsupportedVersion) { } else if (error === InstallScreenBackupError.UnsupportedVersion) {
errorElem = ( errorElem = (
@ -120,17 +106,19 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
startUpdate={startUpdate} startUpdate={startUpdate}
forceUpdate={forceUpdate} forceUpdate={forceUpdate}
currentVersion={currentVersion} currentVersion={currentVersion}
onClose={confirmSkip} onClose={confirmCancel}
OS={OS} OS={OS}
/> />
); );
} else if (error === InstallScreenBackupError.Retriable) { } else if (error === InstallScreenBackupError.Retriable) {
if (!isConfirmingSkip) { if (!isConfirmingCancel) {
errorElem = ( errorElem = (
<ConfirmationDialog <ConfirmationDialog
dialogName="InstallScreenBackupImportStep.error" dialogName="InstallScreenBackupImportStep.error"
title={i18n('icu:BackupImportScreen__error__title')} title={i18n('icu:BackupImportScreen__error__title')}
cancelText={i18n('icu:BackupImportScreen__skip')} cancelText={i18n(
'icu:BackupImportScreen__cancel-confirmation__confirm'
)}
actions={[ actions={[
{ {
action: onRetryWrap, action: onRetryWrap,
@ -139,7 +127,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
}, },
]} ]}
i18n={i18n} i18n={i18n}
onClose={confirmSkip} onClose={confirmCancel}
> >
{i18n('icu:BackupImportScreen__error__body')} {i18n('icu:BackupImportScreen__error__body')}
</ConfirmationDialog> </ConfirmationDialog>
@ -170,6 +158,24 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
throw missingCaseError(error); 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 ( return (
<div className="InstallScreenBackupImportStep"> <div className="InstallScreenBackupImportStep">
<TitlebarDragArea /> <TitlebarDragArea />
@ -178,10 +184,12 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
<h3 className="InstallScreenBackupImportStep__title"> <h3 className="InstallScreenBackupImportStep__title">
{i18n('icu:BackupImportScreen__title')} {i18n('icu:BackupImportScreen__title')}
</h3> </h3>
<ProgressBarAndDescription {...props} /> <ProgressBarAndDescription {...props} isCanceled={isCanceled} />
<div className="InstallScreenBackupImportStep__description"> {!isCanceled && (
{i18n('icu:BackupImportScreen__description')} <div className="InstallScreenBackupImportStep__description">
</div> {i18n('icu:BackupImportScreen__description')}
</div>
)}
</div> </div>
<div className="InstallScreenBackupImportStep__footer"> <div className="InstallScreenBackupImportStep__footer">
<div className="InstallScreenBackupImportStep__security"> <div className="InstallScreenBackupImportStep__security">
@ -195,15 +203,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
</div> </div>
</div> </div>
{backupStep === InstallScreenBackupStep.Download && ( {cancelButton}
<button
className="InstallScreenBackupImportStep__cancel"
type="button"
onClick={confirmCancel}
>
{i18n('icu:BackupImportScreen__cancel')}
</button>
)}
</div> </div>
{isConfirmingCancel && ( {isConfirmingCancel && (
@ -229,25 +229,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
</ConfirmationDialog> </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} {errorElem}
</div> </div>
); );
@ -256,6 +237,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
type ProgressBarPropsType = Readonly< type ProgressBarPropsType = Readonly<
{ {
i18n: LocalizerType; i18n: LocalizerType;
isCanceled: boolean;
} & ( } & (
| { | {
backupStep: InstallScreenBackupStep.WaitForBackup; backupStep: InstallScreenBackupStep.WaitForBackup;
@ -271,7 +253,7 @@ type ProgressBarPropsType = Readonly<
>; >;
function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element { function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element {
const { backupStep, i18n } = props; const { backupStep, i18n, isCanceled } = props;
if (backupStep === InstallScreenBackupStep.WaitForBackup) { if (backupStep === InstallScreenBackupStep.WaitForBackup) {
return ( return (
<> <>
@ -292,6 +274,20 @@ function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element {
currentBytes / totalBytes 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) { if (backupStep === InstallScreenBackupStep.Download) {
return ( return (
<> <>

View file

@ -14,6 +14,8 @@ export class BackupDownloadFailedError extends Error {}
export class BackupProcessingError extends Error {} export class BackupProcessingError extends Error {}
export class BackupImportCanceledError extends Error {}
export class RelinkRequestedError extends Error {} export class RelinkRequestedError extends Error {}
export class ContinueWithoutSyncingError extends Error {} export class ContinueWithoutSyncingError extends Error {}

View file

@ -51,6 +51,7 @@ import { validateBackup } from './validator';
import { BackupType } from './types'; import { BackupType } from './types';
import { import {
BackupDownloadFailedError, BackupDownloadFailedError,
BackupImportCanceledError,
BackupProcessingError, BackupProcessingError,
ContinueWithoutSyncingError, ContinueWithoutSyncingError,
RelinkRequestedError, RelinkRequestedError,
@ -93,6 +94,7 @@ export type ImportOptionsType = Readonly<{
export class BackupsService { export class BackupsService {
#isStarted = false; #isStarted = false;
#isRunning: 'import' | 'export' | false = false; #isRunning: 'import' | 'export' | false = false;
#importController: AbortController | undefined;
#downloadController: AbortController | undefined; #downloadController: AbortController | undefined;
#downloadRetryPromise: #downloadRetryPromise:
@ -152,14 +154,14 @@ export class BackupsService {
this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>(); this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();
let installerError: InstallScreenBackupError; let installerError: InstallScreenBackupError;
let shouldUnlinkAndDeleteData = false;
if (error instanceof RelinkRequestedError) { if (error instanceof RelinkRequestedError) {
installerError = InstallScreenBackupError.Fatal; installerError = InstallScreenBackupError.Fatal;
log.error( log.error(
'backups.downloadAndImport: primary requested relink; unlinking & deleting data', 'backups.downloadAndImport: primary requested relink; unlinking & deleting data',
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
// eslint-disable-next-line no-await-in-loop shouldUnlinkAndDeleteData = true;
await this.#unlinkAndDeleteAllData();
} else if (error instanceof UnsupportedBackupVersion) { } else if (error instanceof UnsupportedBackupVersion) {
installerError = InstallScreenBackupError.UnsupportedVersion; installerError = InstallScreenBackupError.UnsupportedVersion;
log.error( log.error(
@ -178,8 +180,13 @@ export class BackupsService {
'backups.downloadAndImport: fatal error during processing; unlinking & deleting data', 'backups.downloadAndImport: fatal error during processing; unlinking & deleting data',
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
// eslint-disable-next-line no-await-in-loop shouldUnlinkAndDeleteData = true;
await this.#unlinkAndDeleteAllData(); } else if (error instanceof BackupImportCanceledError) {
installerError = InstallScreenBackupError.Canceled;
log.info(
'backups.downloadAndImport: Processing canceled by user; unlinking & deleting data'
);
shouldUnlinkAndDeleteData = true;
} else { } else {
log.error( log.error(
'backups.downloadAndImport: unknown error, prompting user to retry' 'backups.downloadAndImport: unknown error, prompting user to retry'
@ -191,10 +198,20 @@ export class BackupsService {
error: installerError, 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 // eslint-disable-next-line no-await-in-loop
const nextStep = await this.#downloadRetryPromise.promise; const nextStep = await this.#downloadRetryPromise.promise;
if (nextStep === 'retry') { if (nextStep === 'retry') {
continue; continue;
} else if (nextStep === 'cancel') {
// eslint-disable-next-line no-await-in-loop
await this.#unlinkAndDeleteAllData();
} }
try { try {
@ -211,6 +228,7 @@ export class BackupsService {
await window.storage.remove('backupEphemeralKey'); await window.storage.remove('backupEphemeralKey');
await window.storage.put('isRestoredFromBackup', hasBackup); await window.storage.put('isRestoredFromBackup', hasBackup);
// If the primary cancels sync on their end, then we can link without sync
if (!hasBackup) { if (!hasBackup) {
window.reduxActions.installer.handleMissingBackup(); window.reduxActions.installer.handleMissingBackup();
} }
@ -318,16 +336,27 @@ export class BackupsService {
return this.importBackup(() => createReadStream(backupFile), options); 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) { if (this.#downloadController) {
log.warn('importBackup: canceling download'); log.warn('cancelDownloadAndImport: canceling download');
this.#downloadController.abort(); this.#downloadController.abort();
this.#downloadController = undefined; this.#downloadController = undefined;
if (this.#downloadRetryPromise) { if (this.#downloadRetryPromise) {
this.#downloadRetryPromise.resolve('cancel'); 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(); await DataWriter.disableMessageInsertTriggers();
try { try {
const controller = new AbortController();
this.#importController?.abort();
this.#importController = controller;
window.ConversationController.setReadOnly(true); window.ConversationController.setReadOnly(true);
const importStream = await BackupImportStream.create(backupType); const importStream = await BackupImportStream.create(backupType);
@ -378,6 +412,10 @@ export class BackupsService {
sink sink
); );
if (controller.signal.aborted) {
throw new BackupImportCanceledError();
}
onProgress?.(0, totalBytes); onProgress?.(0, totalBytes);
strictAssert(theirMac != null, 'importBackup: Missing MAC'); strictAssert(theirMac != null, 'importBackup: Missing MAC');
@ -405,7 +443,8 @@ export class BackupsService {
getIvAndDecipher(aesKey), getIvAndDecipher(aesKey),
createGunzip(), createGunzip(),
new DelimitedStream(), new DelimitedStream(),
importStream importStream,
{ signal: controller.signal }
); );
strictAssert( strictAssert(
@ -432,7 +471,12 @@ export class BackupsService {
log.info('importBackup: finished...'); log.info('importBackup: finished...');
} catch (error) { } 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())) { if (isNightly(window.getVersion()) || isAdhoc(window.getVersion())) {
window.reduxActions.toast.showToast({ window.reduxActions.toast.showToast({
@ -444,6 +488,8 @@ export class BackupsService {
} finally { } finally {
window.ConversationController.setReadOnly(false); window.ConversationController.setReadOnly(false);
this.#isRunning = false; this.#isRunning = false;
this.#importController = undefined;
await DataWriter.enableMessageInsertTriggersAndBackfill(); await DataWriter.enableMessageInsertTriggersAndBackfill();
window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' }); window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' });
@ -531,7 +577,7 @@ export class BackupsService {
await ensureFile(downloadPath); await ensureFile(downloadPath);
if (controller.signal.aborted) { if (controller.signal.aborted) {
return false; throw new BackupImportCanceledError();
} }
let stream: Readable; let stream: Readable;
@ -551,7 +597,7 @@ export class BackupsService {
} }
} catch (error) { } catch (error) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
return false; throw new BackupImportCanceledError();
} }
// No backup on the server // No backup on the server
@ -580,7 +626,7 @@ export class BackupsService {
} }
if (controller.signal.aborted) { if (controller.signal.aborted) {
return false; throw new BackupImportCanceledError();
} }
await pipeline( await pipeline(
@ -592,14 +638,14 @@ export class BackupsService {
); );
if (controller.signal.aborted) { if (controller.signal.aborted) {
return false; throw new BackupImportCanceledError();
} }
this.#downloadController = undefined; this.#downloadController = undefined;
try { try {
// Too late to cancel now, make sure we are unlinked if the process // Import and start writing to the DB. Make sure we are unlinked
// is aborted due to error or restart. // if the import process is aborted due to error or restart.
const password = window.storage.get('password'); const password = window.storage.get('password');
strictAssert(password != null, 'Must be registered to import backup'); strictAssert(password != null, 'Must be registered to import backup');
@ -619,15 +665,19 @@ export class BackupsService {
// Restore password on success // Restore password on success
await window.storage.put('password', password); await window.storage.put('password', password);
} catch (e) { } catch (e) {
// Error during import; this is non-retriable // Error or manual cancel during import; this is non-retriable
throw new BackupProcessingError(); if (e instanceof BackupImportCanceledError) {
throw e;
} else {
throw new BackupProcessingError();
}
} finally { } finally {
await unlink(downloadPath); await unlink(downloadPath);
} }
} catch (error) { } catch (error) {
// Download canceled // Download canceled
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
return false; throw new BackupImportCanceledError();
} }
// Other errors bubble up and can be retried // Other errors bubble up and can be retried
@ -731,6 +781,10 @@ export class BackupsService {
Errors.toLogFormat(e) 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 { public isImportRunning(): boolean {

View file

@ -8,7 +8,6 @@ import { useSelector } from 'react-redux';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getUpdatesState } from '../selectors/updates'; import { getUpdatesState } from '../selectors/updates';
import { getInstallerState } from '../selectors/installer'; import { getInstallerState } from '../selectors/installer';
import { useAppActions } from '../ducks/app';
import { useInstallerActions } from '../ducks/installer'; import { useInstallerActions } from '../ducks/installer';
import { useUpdatesActions } from '../ducks/updates'; import { useUpdatesActions } from '../ducks/updates';
import { hasExpired as hasExpiredSelector } from '../selectors/expiration'; import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
@ -29,7 +28,6 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const installerState = useSelector(getInstallerState); const installerState = useSelector(getInstallerState);
const updates = useSelector(getUpdatesState); const updates = useSelector(getUpdatesState);
const { openInbox } = useAppActions();
const { startInstaller, finishInstall, retryBackupImport } = const { startInstaller, finishInstall, retryBackupImport } =
useInstallerActions(); useInstallerActions();
const { startUpdate, forceUpdate } = useUpdatesActions(); const { startUpdate, forceUpdate } = useUpdatesActions();
@ -56,11 +54,8 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
}, [backupFile, deviceName, finishInstall]); }, [backupFile, deviceName, finishInstall]);
const onCancelBackupImport = useCallback((): void => { const onCancelBackupImport = useCallback((): void => {
backupsService.cancelDownload(); backupsService.cancelDownloadAndImport();
if (installerState.step === InstallScreenStep.BackupImport) { }, []);
openInbox();
}
}, [installerState.step, openInbox]);
const suggestedDeviceName = const suggestedDeviceName =
installerState.step === InstallScreenStep.ChoosingDeviceName installerState.step === InstallScreenStep.ChoosingDeviceName

View file

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