mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-19 17:58:48 +00:00
Fix link-and-sync cancellation
This commit is contained in:
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -105,7 +105,6 @@ export function FullFlow(): JSX.Element {
|
||||
currentBytes={currentBytes}
|
||||
totalBytes={totalBytes}
|
||||
backupStep={backupStep}
|
||||
onRestartLink={action('onRestartLink')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -73,7 +73,6 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
|
||||
...installerState,
|
||||
onCancel: onCancelBackupImport,
|
||||
onRetry: retryBackupImport,
|
||||
onRestartLink: startInstaller,
|
||||
updates,
|
||||
currentVersion: window.getVersion(),
|
||||
forceUpdate,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -384,7 +384,6 @@ export class Provisioner {
|
||||
isLinkAndSyncEnabled() &&
|
||||
Bytes.isNotEmpty(envelope.ephemeralBackupKey),
|
||||
});
|
||||
request.respond(200, 'OK');
|
||||
} else {
|
||||
log.warn(
|
||||
'Provisioner.connect: unsupported request type',
|
||||
|
||||
Reference in New Issue
Block a user