Fix link-and-sync cancellation

This commit is contained in:
Fedor Indutny
2025-04-09 12:23:08 -07:00
committed by GitHub
parent 44c4cebb7d
commit a575597396
13 changed files with 165 additions and 98 deletions

View File

@@ -114,8 +114,8 @@
"@indutny/simple-windows-notifications": "2.0.16", "@indutny/simple-windows-notifications": "2.0.16",
"@indutny/sneequals": "4.0.0", "@indutny/sneequals": "4.0.0",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@react-aria/interactions": "3.23.0",
"@react-aria/focus": "3.19.1", "@react-aria/focus": "3.19.1",
"@react-aria/interactions": "3.23.0",
"@react-aria/utils": "3.25.3", "@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5", "@react-spring/web": "9.7.5",
"@signalapp/libsignal-client": "0.68.0", "@signalapp/libsignal-client": "0.68.0",
@@ -221,7 +221,7 @@
"@indutny/parallel-prettier": "3.0.0", "@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1", "@indutny/rezip-electron": "2.0.1",
"@napi-rs/canvas": "0.1.61", "@napi-rs/canvas": "0.1.61",
"@signalapp/mock-server": "11.2.0", "@signalapp/mock-server": "11.3.0",
"@storybook/addon-a11y": "8.4.4", "@storybook/addon-a11y": "8.4.4",
"@storybook/addon-actions": "8.4.4", "@storybook/addon-actions": "8.4.4",
"@storybook/addon-controls": "8.4.4", "@storybook/addon-controls": "8.4.4",

18
pnpm-lock.yaml generated
View File

@@ -430,8 +430,8 @@ importers:
specifier: 0.1.61 specifier: 0.1.61
version: 0.1.61 version: 0.1.61
'@signalapp/mock-server': '@signalapp/mock-server':
specifier: 11.2.0 specifier: 11.3.0
version: 11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) version: 11.3.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y': '@storybook/addon-a11y':
specifier: 8.4.4 specifier: 8.4.4
version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10)) version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10))
@@ -2531,8 +2531,8 @@ packages:
'@signalapp/libsignal-client@0.68.0': '@signalapp/libsignal-client@0.68.0':
resolution: {integrity: sha512-k7kUqN36wYMnx1ARVVpNmWJfVlD0AIrNEq0Mpb7X8yMc6E8QY5ankwtPX3ZlO/Yl7en2NT7ZrP4dM5xpQlGsNA==} resolution: {integrity: sha512-k7kUqN36wYMnx1ARVVpNmWJfVlD0AIrNEq0Mpb7X8yMc6E8QY5ankwtPX3ZlO/Yl7en2NT7ZrP4dM5xpQlGsNA==}
'@signalapp/mock-server@11.2.0': '@signalapp/mock-server@11.3.0':
resolution: {integrity: sha512-y8bueRcXVulyXRRVm2M/qT7YmxGpUbiwQsRSi7a+DDI4aUeZIDW9z7KgjElv1CN1/n9O6M1bYO+TLy4ys+7U6w==} resolution: {integrity: sha512-X/yqrDySJ51bRngjMJrIGDhOU/LQ7OI9vJFNrs0835bgdBPGQO1pyufndzKLJpPehMECzGIP0UCE9YZU1X6NIg==}
'@signalapp/parchment-cjs@3.0.1': '@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==} resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -5916,8 +5916,8 @@ packages:
intl-tel-input@24.7.0: intl-tel-input@24.7.0:
resolution: {integrity: sha512-OjkhKen4SJUI2kN9OHpb8ReNN619sB9gECPq51dn3zKEWvif3mnSjmrtWhm8ABIb7Ijs+AAYSS5sI33Sb4YqvQ==} resolution: {integrity: sha512-OjkhKen4SJUI2kN9OHpb8ReNN619sB9gECPq51dn3zKEWvif3mnSjmrtWhm8ABIb7Ijs+AAYSS5sI33Sb4YqvQ==}
ioredis@5.5.0: ioredis@5.6.0:
resolution: {integrity: sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==} resolution: {integrity: sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
ip-address@9.0.5: ip-address@9.0.5:
@@ -12110,7 +12110,7 @@ snapshots:
type-fest: 4.26.1 type-fest: 4.26.1
uuid: 8.3.2 uuid: 8.3.2
'@signalapp/mock-server@11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': '@signalapp/mock-server@11.3.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies: dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3) '@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2 '@signalapp/libsignal-client': 0.60.2
@@ -12615,7 +12615,7 @@ snapshots:
lodash.throttle: 4.1.1 lodash.throttle: 4.1.1
optionalDependencies: optionalDependencies:
'@redis/client': 1.6.0 '@redis/client': 1.6.0
ioredis: 5.5.0 ioredis: 5.6.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -16316,7 +16316,7 @@ snapshots:
intl-tel-input@24.7.0: {} intl-tel-input@24.7.0: {}
ioredis@5.5.0: ioredis@5.6.0:
dependencies: dependencies:
'@ioredis/commands': 1.2.0 '@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2 cluster-key-slot: 1.1.2

View File

@@ -1773,6 +1773,7 @@ export async function startApp(): Promise<void> {
} }
try { try {
log.info(`${logId}: waiting for postRegistrationSyncs`);
await Promise.all(syncsToAwaitBeforeShowingInbox); await Promise.all(syncsToAwaitBeforeShowingInbox);
await window.storage.put('postRegistrationSyncsStatus', 'complete'); await window.storage.put('postRegistrationSyncsStatus', 'complete');
log.info(`${logId}: postRegistrationSyncs complete`); log.info(`${logId}: postRegistrationSyncs complete`);

View File

@@ -105,7 +105,6 @@ export function FullFlow(): JSX.Element {
currentBytes={currentBytes} currentBytes={currentBytes}
totalBytes={totalBytes} totalBytes={totalBytes}
backupStep={backupStep} backupStep={backupStep}
onRestartLink={action('onRestartLink')}
/> />
); );
} }

View File

@@ -31,7 +31,6 @@ export type PropsType = Readonly<
error?: InstallScreenBackupError; error?: InstallScreenBackupError;
onCancel: () => void; onCancel: () => void;
onRetry: () => void; onRetry: () => void;
onRestartLink: () => void;
// Updater UI // Updater UI
updates: UpdatesStateType; updates: UpdatesStateType;
@@ -60,7 +59,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
error, error,
onCancel, onCancel,
onRetry, onRetry,
onRestartLink,
updates, updates,
currentVersion, currentVersion,
OS, OS,
@@ -140,7 +138,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
title={i18n('icu:BackupImportScreen__error__title')} title={i18n('icu:BackupImportScreen__error__title')}
actions={[ actions={[
{ {
action: onRestartLink, action: onCancel,
style: 'affirmative', style: 'affirmative',
text: i18n('icu:BackupImportScreen__error__confirm'), text: i18n('icu:BackupImportScreen__error__confirm'),
}, },
@@ -254,6 +252,21 @@ type ProgressBarPropsType = Readonly<
function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element { function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element {
const { backupStep, i18n, isCanceled } = props; const { backupStep, i18n, isCanceled } = props;
if (isCanceled) {
return (
<>
<ProgressBar
fractionComplete={null}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
<div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint--canceling')}
</div>
</>
);
}
if (backupStep === InstallScreenBackupStep.WaitForBackup) { if (backupStep === InstallScreenBackupStep.WaitForBackup) {
return ( return (
<> <>
@@ -274,20 +287,6 @@ 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

@@ -4,16 +4,48 @@
import type Long from 'long'; import type Long from 'long';
export class UnsupportedBackupVersion extends Error { import { InstallScreenBackupError } from '../../types/InstallScreen';
constructor(version: Long) {
super(`Unsupported backup version: ${version}`); export class BackupInstallerError extends Error {
constructor(
name: string,
public readonly installerError: InstallScreenBackupError
) {
super(name);
} }
} }
export class BackupDownloadFailedError extends Error {} export class UnsupportedBackupVersion extends BackupInstallerError {
constructor(version: Long) {
super(
`Unsupported backup version: ${version}`,
InstallScreenBackupError.UnsupportedVersion
);
}
}
export class BackupProcessingError extends Error {} export class BackupDownloadFailedError extends BackupInstallerError {
constructor() {
super('BackupDownloadFailedError', InstallScreenBackupError.Retriable);
}
}
export class BackupImportCanceledError extends Error {} export class BackupProcessingError extends BackupInstallerError {
constructor(cause: Error) {
super('BackupProcessingError', InstallScreenBackupError.Fatal);
export class RelinkRequestedError extends Error {} this.cause = cause;
}
}
export class BackupImportCanceledError extends BackupInstallerError {
constructor() {
super('BackupImportCanceledError', InstallScreenBackupError.Canceled);
}
}
export class RelinkRequestedError extends BackupInstallerError {
constructor() {
super('RelinkRequestedError', InstallScreenBackupError.Fatal);
}
}

View File

@@ -55,11 +55,11 @@ import { BackupAPI } from './api';
import { validateBackup } from './validator'; import { validateBackup } from './validator';
import { BackupType } from './types'; import { BackupType } from './types';
import { import {
BackupInstallerError,
BackupDownloadFailedError, BackupDownloadFailedError,
BackupImportCanceledError, BackupImportCanceledError,
BackupProcessingError, BackupProcessingError,
RelinkRequestedError, RelinkRequestedError,
UnsupportedBackupVersion,
} from './errors'; } from './errors';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version'; import { isAdhoc, isNightly } from '../../util/version';
@@ -164,43 +164,22 @@ export class BackupsService {
onProgress: options.onProgress, onProgress: options.onProgress,
ephemeralKey, ephemeralKey,
}); });
if (!hasBackup) {
// If the primary cancels sync on their end, then we can link without sync
log.info('backups.downloadAndImport: missing backup');
window.reduxActions.installer.handleMissingBackup();
}
} catch (error) { } catch (error) {
this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>(); this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();
let installerError: InstallScreenBackupError; let installerError: InstallScreenBackupError;
let shouldUnlinkAndDeleteData = false; if (error instanceof BackupInstallerError) {
if (error instanceof RelinkRequestedError) {
installerError = InstallScreenBackupError.Fatal;
log.error( log.error(
'backups.downloadAndImport: primary requested relink; unlinking & deleting data', 'backups.downloadAndImport: got installer error',
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
shouldUnlinkAndDeleteData = true; ({ installerError } = error);
} else if (error instanceof UnsupportedBackupVersion) {
installerError = InstallScreenBackupError.UnsupportedVersion;
log.error(
'backups.downloadAndImport: unsupported version',
Errors.toLogFormat(error)
);
} else if (error instanceof BackupDownloadFailedError) {
installerError = InstallScreenBackupError.Retriable;
log.warn(
'backups.downloadAndImport: download error, prompting user to retry',
Errors.toLogFormat(error)
);
} else if (error instanceof BackupProcessingError) {
installerError = InstallScreenBackupError.Fatal;
log.error(
'backups.downloadAndImport: fatal error during processing; unlinking & deleting data',
Errors.toLogFormat(error)
);
shouldUnlinkAndDeleteData = true;
} 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'
@@ -212,28 +191,38 @@ export class BackupsService {
error: installerError, error: installerError,
}); });
// Deleting data takes some time // For download errors, wait for user confirmation to retry or unlink
if (shouldUnlinkAndDeleteData) { const nextStep =
// eslint-disable-next-line no-await-in-loop error instanceof BackupImportCanceledError
await this.#unlinkAndDeleteAllData(); ? 'cancel'
: // eslint-disable-next-line no-await-in-loop
await this.#downloadRetryPromise.promise;
if (nextStep === 'retry') {
log.warn('backups.downloadAndImport: retrying');
continue;
} }
// For download errors, wait for user confirmation to retry or unlink if (nextStep !== 'cancel') {
// eslint-disable-next-line no-await-in-loop throw missingCaseError(nextStep);
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();
} }
// If we are here: the user has either canceled manually, or after
// getting an error (potentially fatal).
log.warn('backups.downloadAndImport: unlinking');
// eslint-disable-next-line no-await-in-loop
await this.#unlinkAndDeleteAllData();
try { try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await unlink(absoluteDownloadPath); await unlink(absoluteDownloadPath);
} catch { } catch {
// Best-effort // Best-effort
} }
// Make sure to fail the backup import process so that background.ts
// will not wait for the syncs.
throw error;
} }
break; break;
} }
@@ -243,12 +232,8 @@ export class BackupsService {
await window.storage.remove('backupTransitArchive'); await window.storage.remove('backupTransitArchive');
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 log.info('backups.downloadAndImport: done');
if (!hasBackup) {
window.reduxActions.installer.handleMissingBackup();
}
log.info(`backups.downloadAndImport: done, had backup=${hasBackup}`);
return { wasBackupImported: hasBackup }; return { wasBackupImported: hasBackup };
} }
@@ -609,7 +594,6 @@ export class BackupsService {
let archive = window.storage.get('backupTransitArchive'); let archive = window.storage.get('backupTransitArchive');
if (archive == null) { if (archive == null) {
const response = await this.api.getTransferArchive(controller.signal); const response = await this.api.getTransferArchive(controller.signal);
if ('error' in response) { if ('error' in response) {
switch (response.error) { switch (response.error) {
case 'RELINK_REQUESTED': case 'RELINK_REQUESTED':
@@ -650,6 +634,10 @@ export class BackupsService {
return false; return false;
} }
if (error instanceof BackupInstallerError) {
throw error;
}
log.error( log.error(
'backups.doDownloadAndImport: error downloading backup file', 'backups.doDownloadAndImport: error downloading backup file',
Errors.toLogFormat(error) Errors.toLogFormat(error)
@@ -699,10 +687,10 @@ export class BackupsService {
await window.storage.put('password', password); await window.storage.put('password', password);
} catch (e) { } catch (e) {
// Error or manual cancel during import; this is non-retriable // Error or manual cancel during import; this is non-retriable
if (e instanceof BackupImportCanceledError) { if (e instanceof BackupInstallerError) {
throw e; throw e;
} else { } else {
throw new BackupProcessingError(); throw new BackupProcessingError(e);
} }
} finally { } finally {
await unlink(downloadPath); await unlink(downloadPath);
@@ -797,6 +785,10 @@ export class BackupsService {
} }
async #unlinkAndDeleteAllData() { async #unlinkAndDeleteAllData() {
window.reduxActions.installer.updateBackupImportProgress({
error: InstallScreenBackupError.Canceled,
});
try { try {
await window.textsecure.server?.unlink(); await window.textsecure.server?.unlink();
} catch (e) { } catch (e) {

View File

@@ -143,7 +143,6 @@ export const actions = {
retryBackupImport, retryBackupImport,
showBackupImport, showBackupImport,
handleMissingBackup, handleMissingBackup,
showLinkInProgress,
}; };
export const useInstallerActions = (): BoundActionCreatorsMapObject< export const useInstallerActions = (): BoundActionCreatorsMapObject<
@@ -384,10 +383,6 @@ function showBackupImport(): ShowBackupImportActionType {
return { type: SHOW_BACKUP_IMPORT }; return { type: SHOW_BACKUP_IMPORT };
} }
function showLinkInProgress(): ShowLinkInProgressActionType {
return { type: SHOW_LINK_IN_PROGRESS };
}
function handleMissingBackup(): ShowLinkInProgressActionType { function handleMissingBackup(): ShowLinkInProgressActionType {
// If backup is missing, go to normal link-in-progress view // If backup is missing, go to normal link-in-progress view
return { type: SHOW_LINK_IN_PROGRESS }; return { type: SHOW_LINK_IN_PROGRESS };

View File

@@ -73,7 +73,6 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
...installerState, ...installerState,
onCancel: onCancelBackupImport, onCancel: onCancelBackupImport,
onRetry: retryBackupImport, onRetry: retryBackupImport,
onRestartLink: startInstaller,
updates, updates,
currentVersion: window.getVersion(), currentVersion: window.getVersion(),
forceUpdate, forceUpdate,

View File

@@ -362,4 +362,43 @@ describe('backups', function (this: Mocha.Suite) {
await contact2Elem.click(); await contact2Elem.click();
await window.locator('.module-message >> "Message 33"').waitFor(); await window.locator('.module-message >> "Message 33"').waitFor();
}); });
it('handles remote ephemeral backup cancelation', async function () {
const ephemeralBackupKey = randomBytes(32);
const { phone, server } = bootstrap;
phone.ephemeralBackupKey = ephemeralBackupKey;
app = await bootstrap.link({
ephemeralBackup: {
error: 'RELINK_REQUESTED',
},
});
const window = await app.getWindow();
const modal = window.getByTestId(
'ConfirmationDialog.InstallScreenBackupImportStep.error'
);
await modal.waitFor();
await modal.getByRole('button', { name: 'Retry' }).click();
await window
.locator('.module-InstallScreenQrCodeNotScannedStep__qr-code--loaded')
.waitFor();
debug('waiting for provision');
const provision = await server.waitForProvision();
debug('waiting for provision URL');
const provisionURL = await app.waitForProvisionURL();
debug('completing provision');
await provision.complete({
provisionURL,
primaryDevice: phone,
});
});
}); });

View File

@@ -119,10 +119,15 @@ export type BootstrapOptions = Readonly<{
useLegacyStorageEncryption?: boolean; useLegacyStorageEncryption?: boolean;
}>; }>;
export type EphemeralBackupType = Readonly<{ export type EphemeralBackupType = Readonly<
cdn: 3; | {
key: string; cdn: 3;
}>; key: string;
}
| {
error: 'RELINK_REQUESTED';
}
>;
export type LinkOptionsType = Readonly<{ export type LinkOptionsType = Readonly<{
extraConfig?: Partial<RendererConfigType>; extraConfig?: Partial<RendererConfigType>;
@@ -404,6 +409,11 @@ export class Bootstrap {
if (ephemeralBackup != null) { if (ephemeralBackup != null) {
await this.server.provideTransferArchive(this.desktop, ephemeralBackup); await this.server.provideTransferArchive(this.desktop, ephemeralBackup);
// Desktop won't get linked
if ('error' in ephemeralBackup) {
return app;
}
} }
debug('new desktop device %j', this.desktop.debugId); debug('new desktop device %j', this.desktop.debugId);

View File

@@ -1123,8 +1123,10 @@ export default class AccountManager extends EventTarget {
// until the backup is downloaded and imported. // until the backup is downloaded and imported.
if (shouldDownloadBackup && cleanStart) { if (shouldDownloadBackup && cleanStart) {
if (options.type === AccountType.Linked && options.ephemeralBackupKey) { if (options.type === AccountType.Linked && options.ephemeralBackupKey) {
log.info('createAccount: setting ephemeral key');
await storage.put('backupEphemeralKey', options.ephemeralBackupKey); await storage.put('backupEphemeralKey', options.ephemeralBackupKey);
} }
log.info('createAccount: setting backup download path');
await storage.put('backupDownloadPath', getRelativePath(createName())); await storage.put('backupDownloadPath', getRelativePath(createName()));
} }

View File

@@ -384,7 +384,6 @@ export class Provisioner {
isLinkAndSyncEnabled() && isLinkAndSyncEnabled() &&
Bytes.isNotEmpty(envelope.ephemeralBackupKey), Bytes.isNotEmpty(envelope.ephemeralBackupKey),
}); });
request.respond(200, 'OK');
} else { } else {
log.warn( log.warn(
'Provisioner.connect: unsupported request type', 'Provisioner.connect: unsupported request type',