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/sneequals": "4.0.0",
"@popperjs/core": "2.11.8",
"@react-aria/interactions": "3.23.0",
"@react-aria/focus": "3.19.1",
"@react-aria/interactions": "3.23.0",
"@react-aria/utils": "3.25.3",
"@react-spring/web": "9.7.5",
"@signalapp/libsignal-client": "0.68.0",
@@ -221,7 +221,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1",
"@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-actions": "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
version: 0.1.61
'@signalapp/mock-server':
specifier: 11.2.0
version: 11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
specifier: 11.3.0
version: 11.3.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y':
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))
@@ -2531,8 +2531,8 @@ packages:
'@signalapp/libsignal-client@0.68.0':
resolution: {integrity: sha512-k7kUqN36wYMnx1ARVVpNmWJfVlD0AIrNEq0Mpb7X8yMc6E8QY5ankwtPX3ZlO/Yl7en2NT7ZrP4dM5xpQlGsNA==}
'@signalapp/mock-server@11.2.0':
resolution: {integrity: sha512-y8bueRcXVulyXRRVm2M/qT7YmxGpUbiwQsRSi7a+DDI4aUeZIDW9z7KgjElv1CN1/n9O6M1bYO+TLy4ys+7U6w==}
'@signalapp/mock-server@11.3.0':
resolution: {integrity: sha512-X/yqrDySJ51bRngjMJrIGDhOU/LQ7OI9vJFNrs0835bgdBPGQO1pyufndzKLJpPehMECzGIP0UCE9YZU1X6NIg==}
'@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -5916,8 +5916,8 @@ packages:
intl-tel-input@24.7.0:
resolution: {integrity: sha512-OjkhKen4SJUI2kN9OHpb8ReNN619sB9gECPq51dn3zKEWvif3mnSjmrtWhm8ABIb7Ijs+AAYSS5sI33Sb4YqvQ==}
ioredis@5.5.0:
resolution: {integrity: sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==}
ioredis@5.6.0:
resolution: {integrity: sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==}
engines: {node: '>=12.22.0'}
ip-address@9.0.5:
@@ -12110,7 +12110,7 @@ snapshots:
type-fest: 4.26.1
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:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2
@@ -12615,7 +12615,7 @@ snapshots:
lodash.throttle: 4.1.1
optionalDependencies:
'@redis/client': 1.6.0
ioredis: 5.5.0
ioredis: 5.6.0
transitivePeerDependencies:
- supports-color
@@ -16316,7 +16316,7 @@ snapshots:
intl-tel-input@24.7.0: {}
ioredis@5.5.0:
ioredis@5.6.0:
dependencies:
'@ioredis/commands': 1.2.0
cluster-key-slot: 1.1.2

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ export type PropsType = Readonly<
error?: InstallScreenBackupError;
onCancel: () => void;
onRetry: () => void;
onRestartLink: () => void;
// Updater UI
updates: UpdatesStateType;
@@ -60,7 +59,6 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
error,
onCancel,
onRetry,
onRestartLink,
updates,
currentVersion,
OS,
@@ -140,7 +138,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
title={i18n('icu:BackupImportScreen__error__title')}
actions={[
{
action: onRestartLink,
action: onCancel,
style: 'affirmative',
text: i18n('icu:BackupImportScreen__error__confirm'),
},
@@ -254,6 +252,21 @@ type ProgressBarPropsType = Readonly<
function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element {
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) {
return (
<>
@@ -274,20 +287,6 @@ 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

@@ -4,16 +4,48 @@
import type Long from 'long';
export class UnsupportedBackupVersion extends Error {
constructor(version: Long) {
super(`Unsupported backup version: ${version}`);
import { InstallScreenBackupError } from '../../types/InstallScreen';
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 { BackupType } from './types';
import {
BackupInstallerError,
BackupDownloadFailedError,
BackupImportCanceledError,
BackupProcessingError,
RelinkRequestedError,
UnsupportedBackupVersion,
} from './errors';
import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version';
@@ -164,43 +164,22 @@ export class BackupsService {
onProgress: options.onProgress,
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) {
this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();
let installerError: InstallScreenBackupError;
let shouldUnlinkAndDeleteData = false;
if (error instanceof RelinkRequestedError) {
installerError = InstallScreenBackupError.Fatal;
if (error instanceof BackupInstallerError) {
log.error(
'backups.downloadAndImport: primary requested relink; unlinking & deleting data',
'backups.downloadAndImport: got installer error',
Errors.toLogFormat(error)
);
shouldUnlinkAndDeleteData = true;
} 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;
({ installerError } = error);
} else {
log.error(
'backups.downloadAndImport: unknown error, prompting user to retry'
@@ -212,28 +191,38 @@ 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
const nextStep =
error instanceof BackupImportCanceledError
? '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
// 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();
if (nextStep !== 'cancel') {
throw missingCaseError(nextStep);
}
// 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 {
// eslint-disable-next-line no-await-in-loop
await unlink(absoluteDownloadPath);
} catch {
// Best-effort
}
// Make sure to fail the backup import process so that background.ts
// will not wait for the syncs.
throw error;
}
break;
}
@@ -243,12 +232,8 @@ export class BackupsService {
await window.storage.remove('backupTransitArchive');
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();
}
log.info('backups.downloadAndImport: done');
log.info(`backups.downloadAndImport: done, had backup=${hasBackup}`);
return { wasBackupImported: hasBackup };
}
@@ -609,7 +594,6 @@ export class BackupsService {
let archive = window.storage.get('backupTransitArchive');
if (archive == null) {
const response = await this.api.getTransferArchive(controller.signal);
if ('error' in response) {
switch (response.error) {
case 'RELINK_REQUESTED':
@@ -650,6 +634,10 @@ export class BackupsService {
return false;
}
if (error instanceof BackupInstallerError) {
throw error;
}
log.error(
'backups.doDownloadAndImport: error downloading backup file',
Errors.toLogFormat(error)
@@ -699,10 +687,10 @@ export class BackupsService {
await window.storage.put('password', password);
} catch (e) {
// Error or manual cancel during import; this is non-retriable
if (e instanceof BackupImportCanceledError) {
if (e instanceof BackupInstallerError) {
throw e;
} else {
throw new BackupProcessingError();
throw new BackupProcessingError(e);
}
} finally {
await unlink(downloadPath);
@@ -797,6 +785,10 @@ export class BackupsService {
}
async #unlinkAndDeleteAllData() {
window.reduxActions.installer.updateBackupImportProgress({
error: InstallScreenBackupError.Canceled,
});
try {
await window.textsecure.server?.unlink();
} catch (e) {

View File

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

View File

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

View File

@@ -362,4 +362,43 @@ describe('backups', function (this: Mocha.Suite) {
await contact2Elem.click();
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;
}>;
export type EphemeralBackupType = Readonly<{
cdn: 3;
key: string;
}>;
export type EphemeralBackupType = Readonly<
| {
cdn: 3;
key: string;
}
| {
error: 'RELINK_REQUESTED';
}
>;
export type LinkOptionsType = Readonly<{
extraConfig?: Partial<RendererConfigType>;
@@ -404,6 +409,11 @@ export class Bootstrap {
if (ephemeralBackup != null) {
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);

View File

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

View File

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