diff --git a/package.json b/package.json index e8dfbd5940..358ea80ca4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0066a8c659..982dfe7bd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/ts/background.ts b/ts/background.ts index 9a0b0282fe..90bd282b68 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1773,6 +1773,7 @@ export async function startApp(): Promise { } try { + log.info(`${logId}: waiting for postRegistrationSyncs`); await Promise.all(syncsToAwaitBeforeShowingInbox); await window.storage.put('postRegistrationSyncsStatus', 'complete'); log.info(`${logId}: postRegistrationSyncs complete`); diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx index 54eae5eb2d..6ad56dd32c 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx @@ -105,7 +105,6 @@ export function FullFlow(): JSX.Element { currentBytes={currentBytes} totalBytes={totalBytes} backupStep={backupStep} - onRestartLink={action('onRestartLink')} /> ); } diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.tsx index f6629c26ed..d79fd4f4f9 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.tsx @@ -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 ( + <> + +
+ {i18n('icu:BackupImportScreen__progressbar-hint--canceling')} +
+ + ); + } + if (backupStep === InstallScreenBackupStep.WaitForBackup) { return ( <> @@ -274,20 +287,6 @@ function ProgressBarAndDescription(props: ProgressBarPropsType): JSX.Element { currentBytes / totalBytes ); - if (isCanceled) { - return ( - <> - -
- {i18n('icu:BackupImportScreen__progressbar-hint--canceling')} -
- - ); - } - if (backupStep === InstallScreenBackupStep.Download) { return ( <> diff --git a/ts/services/backups/errors.ts b/ts/services/backups/errors.ts index b688b13cfb..efcde06d92 100644 --- a/ts/services/backups/errors.ts +++ b/ts/services/backups/errors.ts @@ -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); + } +} diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 2b9c04c659..2f7c0bdc31 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -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(); 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) { diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts index 268f8de2ed..43c82627e2 100644 --- a/ts/state/ducks/installer.ts +++ b/ts/state/ducks/installer.ts @@ -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 }; diff --git a/ts/state/smart/InstallScreen.tsx b/ts/state/smart/InstallScreen.tsx index 12dc6e8689..12b2c9d3ad 100644 --- a/ts/state/smart/InstallScreen.tsx +++ b/ts/state/smart/InstallScreen.tsx @@ -73,7 +73,6 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { ...installerState, onCancel: onCancelBackupImport, onRetry: retryBackupImport, - onRestartLink: startInstaller, updates, currentVersion: window.getVersion(), forceUpdate, diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts index 8b943296e5..5a836e6328 100644 --- a/ts/test-mock/backups/backups_test.ts +++ b/ts/test-mock/backups/backups_test.ts @@ -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, + }); + }); }); diff --git a/ts/test-mock/bootstrap.ts b/ts/test-mock/bootstrap.ts index 83f16a5186..563fa0bd14 100644 --- a/ts/test-mock/bootstrap.ts +++ b/ts/test-mock/bootstrap.ts @@ -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; @@ -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); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 6a74f708ba..e88484b3ef 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -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())); } diff --git a/ts/textsecure/Provisioner.ts b/ts/textsecure/Provisioner.ts index e2f34a46fa..eaf3c1d5ef 100644 --- a/ts/textsecure/Provisioner.ts +++ b/ts/textsecure/Provisioner.ts @@ -384,7 +384,6 @@ export class Provisioner { isLinkAndSyncEnabled() && Bytes.isNotEmpty(envelope.ephemeralBackupKey), }); - request.respond(200, 'OK'); } else { log.warn( 'Provisioner.connect: unsupported request type',