diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2aaeeff8a8b9..e5e9b8fd2447 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6263,6 +6263,18 @@ "messageformat": "Signal is experiencing technical difficulties. We are working hard to restore service as quickly as possible.", "description": "The title of outage dialog during service outage." }, + "icu:InstallScreenUpdateDialog--update-required__title": { + "messageformat": "Update Required", + "description": "The title of update dialog on install screen when app update is required before proceeding with backup import" + }, + "icu:InstallScreenUpdateDialog--update-required__body": { + "messageformat": "To complete syncing your messages, update Signal desktop now.", + "description": "The body of update dialog on install screen when app update is required before proceeding with backup import" + }, + "icu:InstallScreenUpdateDialog--update-required__action-update": { + "messageformat": "Update", + "description": "The update action of update dialog on install screen when app update is required before proceeding with backup import" + }, "icu:InstallScreenUpdateDialog--unsupported-os__title": { "messageformat": "Update Required", "description": "The title of update dialog on install screen when user OS is unsupported" diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx index 6e2eaa806a91..1b2a261c32f5 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx @@ -1,30 +1,77 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useState, useCallback } from 'react'; import type { Meta, StoryFn } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import { setupI18n } from '../../util/setupI18n'; -import { InstallScreenBackupStep } from '../../types/InstallScreen'; +import { sleep } from '../../util/sleep'; +import { + InstallScreenBackupStep, + InstallScreenBackupError, +} from '../../types/InstallScreen'; +import { DialogType } from '../../types/Dialogs'; import enMessages from '../../../_locales/en/messages.json'; import type { PropsType } from './InstallScreenBackupImportStep'; import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep'; const i18n = setupI18n('en', enMessages); +const DEFAULT_UPDATES = { + dialogType: DialogType.None, + didSnooze: false, + isCheckingForUpdates: false, + showEventsCount: 0, + downloadSize: 42 * 1024 * 1024, +}; + export default { title: 'Components/InstallScreenBackupImportStep', } satisfies Meta; // eslint-disable-next-line react/function-component-definition -const Template: StoryFn = (args: PropsType) => ( - -); +const Template: StoryFn = (args: PropsType) => { + const [updates, setUpdates] = useState(DEFAULT_UPDATES); + const forceUpdate = useCallback(async () => { + setUpdates(state => ({ + ...state, + isCheckingForUpdates: true, + })); + await sleep(500); + setUpdates(state => ({ + ...state, + isCheckingForUpdates: false, + dialogType: DialogType.Downloading, + downloadSize: 100, + downloadedSize: 0, + version: 'v7.7.7', + })); + await sleep(500); + setUpdates(state => ({ + ...state, + downloadedSize: 50, + })); + await sleep(500); + setUpdates(state => ({ + ...state, + downloadedSize: 100, + })); + }, [setUpdates]); + + return ( + + ); +}; export const NoBytes = Template.bind({}); NoBytes.args = { @@ -52,7 +99,15 @@ Error.args = { backupStep: InstallScreenBackupStep.Download, currentBytes: 500 * 1024, totalBytes: 1024 * 1024, - hasError: true, + error: InstallScreenBackupError.Unknown, +}; + +export const UnsupportedVersion = Template.bind({}); +UnsupportedVersion.args = { + backupStep: InstallScreenBackupStep.Process, + currentBytes: 1, + totalBytes: 1024 * 1024, + error: InstallScreenBackupError.UnsupportedVersion, }; export const Processing = Template.bind({}); diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.tsx index 83d746d8554b..3ca10f0e7728 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.tsx @@ -4,7 +4,12 @@ import React, { useState, useCallback } from 'react'; import type { LocalizerType } from '../../types/Util'; -import { InstallScreenBackupStep } from '../../types/InstallScreen'; +import type { UpdatesStateType } from '../../state/ducks/updates'; +import { + InstallScreenStep, + InstallScreenBackupStep, + InstallScreenBackupError, +} from '../../types/InstallScreen'; import { formatFileSize } from '../../util/formatFileSize'; import { TitlebarDragArea } from '../TitlebarDragArea'; import { ProgressBar } from '../ProgressBar'; @@ -14,6 +19,7 @@ import { roundFractionForProgressBar } from '../../util/numbers'; import { missingCaseError } from '../../util/missingCaseError'; import { SYNCING_MESSAGES_SECURITY_URL } from '../../types/support'; import { I18n } from '../I18n'; +import { InstallScreenUpdateDialog } from './InstallScreenUpdateDialog'; // We can't always use destructuring assignment because of the complexity of this props // type. @@ -23,9 +29,16 @@ export type PropsType = Readonly<{ backupStep: InstallScreenBackupStep; currentBytes?: number; totalBytes?: number; - hasError?: boolean; + error?: InstallScreenBackupError; onCancel: () => void; onRetry: () => void; + + // Updater UI + updates: UpdatesStateType; + currentVersion: string; + OS: string; + startUpdate: () => void; + forceUpdate: () => void; }>; export function InstallScreenBackupImportStep({ @@ -33,9 +46,15 @@ export function InstallScreenBackupImportStep({ backupStep, currentBytes, totalBytes, - hasError, + error, onCancel, onRetry, + + updates, + currentVersion, + OS, + startUpdate, + forceUpdate, }: PropsType): JSX.Element { const [isConfirmingCancel, setIsConfirmingCancel] = useState(false); const [isConfirmingSkip, setIsConfirmingSkip] = useState(false); @@ -123,6 +142,47 @@ export function InstallScreenBackupImportStep({ ); + let errorElem: JSX.Element | undefined; + if (error == null) { + // no-op + } else if (error === InstallScreenBackupError.UnsupportedVersion) { + errorElem = ( + + ); + } else if (error === InstallScreenBackupError.Unknown) { + if (!isConfirmingSkip) { + errorElem = ( + + {i18n('icu:BackupImportScreen__error__body')} + + ); + } + } else { + throw missingCaseError(error); + } + return (
@@ -202,24 +262,7 @@ export function InstallScreenBackupImportStep({ )} - {hasError && !isConfirmingSkip && ( - - {i18n('icu:BackupImportScreen__error__body')} - - )} + {errorElem}
); } diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx index e6bdba1ce3c8..f775f465c789 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx @@ -24,6 +24,7 @@ const LOADED_URL = { const DEFAULT_UPDATES = { dialogType: DialogType.None, didSnooze: false, + isCheckingForUpdates: false, showEventsCount: 0, downloadSize: 67 * 1024 * 1024, downloadedSize: 15 * 1024 * 1024, @@ -63,6 +64,7 @@ function Simulation({ updates={DEFAULT_UPDATES} OS="macOS" startUpdate={action('startUpdate')} + forceUpdate={action('forceUpdate')} currentVersion="v6.0.0" retryGetQrCode={action('retryGetQrCode')} /> @@ -80,6 +82,7 @@ export function QrCodeLoading(): JSX.Element { updates={DEFAULT_UPDATES} OS="macOS" startUpdate={action('startUpdate')} + forceUpdate={action('forceUpdate')} currentVersion="v6.0.0" retryGetQrCode={action('retryGetQrCode')} /> @@ -98,6 +101,7 @@ export function QrCodeFailedToLoad(): JSX.Element { updates={DEFAULT_UPDATES} OS="macOS" startUpdate={action('startUpdate')} + forceUpdate={action('forceUpdate')} currentVersion="v6.0.0" retryGetQrCode={action('retryGetQrCode')} /> @@ -113,6 +117,7 @@ export function QrCodeLoaded(): JSX.Element { updates={DEFAULT_UPDATES} OS="macOS" startUpdate={action('startUpdate')} + forceUpdate={action('forceUpdate')} currentVersion="v6.0.0" retryGetQrCode={action('retryGetQrCode')} /> @@ -177,6 +182,7 @@ export const WithUpdateKnobs: StoryFn = }} OS="macOS" startUpdate={action('startUpdate')} + forceUpdate={action('forceUpdate')} currentVersion={currentVersion} retryGetQrCode={action('retryGetQrCode')} /> diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx index b2f3c98035ff..63844cf6d255 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx @@ -6,7 +6,10 @@ import React, { useCallback } from 'react'; import classNames from 'classnames'; import type { LocalizerType } from '../../types/Util'; -import { InstallScreenQRCodeError } from '../../types/InstallScreen'; +import { + InstallScreenStep, + InstallScreenQRCodeError, +} from '../../types/InstallScreen'; import { missingCaseError } from '../../util/missingCaseError'; import type { Loadable } from '../../util/loadable'; import { LoadingState } from '../../util/loadable'; @@ -33,6 +36,7 @@ export type PropsType = Readonly<{ isStaging: boolean; retryGetQrCode: () => void; startUpdate: () => void; + forceUpdate: () => void; }>; const getQrCodeClassName = getClassNamesFor( @@ -51,6 +55,7 @@ export function InstallScreenQrCodeNotScannedStep({ provisioningUrl, retryGetQrCode, startUpdate, + forceUpdate, updates, }: Readonly): ReactElement { return ( @@ -63,7 +68,9 @@ export function InstallScreenQrCodeNotScannedStep({ diff --git a/ts/components/installScreen/InstallScreenUpdateDialog.tsx b/ts/components/installScreen/InstallScreenUpdateDialog.tsx index c430cd04cc8a..b10562030149 100644 --- a/ts/components/installScreen/InstallScreenUpdateDialog.tsx +++ b/ts/components/installScreen/InstallScreenUpdateDialog.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { noop } from 'lodash'; import { DialogType } from '../../types/Dialogs'; +import { InstallScreenStep } from '../../types/InstallScreen'; import type { LocalizerType } from '../../types/Util'; import { PRODUCTION_DOWNLOAD_URL, @@ -13,6 +14,8 @@ import { } from '../../types/support'; import type { UpdatesStateType } from '../../state/ducks/updates'; import { isBeta } from '../../util/version'; +import { missingCaseError } from '../../util/missingCaseError'; +import { roundFractionForProgressBar } from '../../util/numbers'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { Modal } from '../Modal'; import { I18n } from '../I18n'; @@ -21,19 +24,26 @@ import { formatFileSize } from '../../util/formatFileSize'; export type PropsType = UpdatesStateType & Readonly<{ i18n: LocalizerType; + step: InstallScreenStep; + forceUpdate: () => void; startUpdate: () => void; currentVersion: string; OS: string; + onClose?: () => void; }>; export function InstallScreenUpdateDialog({ i18n, + step, dialogType, + isCheckingForUpdates, downloadSize, downloadedSize, + forceUpdate, startUpdate, currentVersion, OS, + onClose = noop, }: PropsType): JSX.Element | null { const learnMoreLink = (parts: Array) => ( ; + } + + return ( + + {i18n('icu:InstallScreenUpdateDialog--update-required__body')} + + ); + } + + return null; + } + if (dialogType === DialogType.UnsupportedOS) { return ( {bodyText} @@ -127,27 +172,10 @@ export function InstallScreenUpdateDialog({ } if (dialogType === DialogType.Downloading) { - // Focus trap can't be used because there are no elements that can be - // focused within the modal. - const width = Math.ceil( - ((downloadedSize || 1) / (downloadSize || 1)) * 100 - ); - return ( - -
-
-
- + const fractionComplete = roundFractionForProgressBar( + (downloadedSize || 0) / (downloadSize || 1) ); + return ; } if ( @@ -179,6 +207,7 @@ export function InstallScreenUpdateDialog({ dialogName={dialogName} moduleClassName="InstallScreenUpdateDialog" title={title} + noMouseClose noDefaultCancelButton actions={[ { @@ -188,7 +217,7 @@ export function InstallScreenUpdateDialog({ autoClose: false, }, ]} - onClose={noop} + onClose={onClose} > {body} @@ -230,5 +259,32 @@ export function InstallScreenUpdateDialog({ ); } - return null; + throw missingCaseError(dialogType); +} + +export function DownloadingModal({ + i18n, + width, +}: { + i18n: LocalizerType; + width: number; +}): JSX.Element { + // Focus trap can't be used because there are no elements that can be + // focused within the modal. + return ( + +
+
+
+ + ); } diff --git a/ts/services/backups/errors.ts b/ts/services/backups/errors.ts new file mode 100644 index 000000000000..9b48b7b1474e --- /dev/null +++ b/ts/services/backups/errors.ts @@ -0,0 +1,10 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type Long from 'long'; + +export class UnsupportedBackupVersion extends Error { + constructor(version: Long) { + super(`Unsupported backup version: ${version}`); + } +} diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index fd12a69d73ac..4fdd186b9920 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -77,6 +77,7 @@ import { SeenStatus } from '../../MessageSeenStatus'; import { constantTimeEqual } from '../../Crypto'; import * as Bytes from '../../Bytes'; import { BACKUP_VERSION, WALLPAPER_TO_BUBBLE_COLOR } from './constants'; +import { UnsupportedBackupVersion } from './errors'; import type { AboutMe, LocalChatStyle } from './types'; import { BackupType } from './types'; import { getBackupMediaRootKey } from './crypto'; @@ -253,7 +254,7 @@ export class BackupImportStream extends Writable { log.info(`${this.logId}: got BackupInfo`); if (info.version?.toNumber() !== BACKUP_VERSION) { - throw new Error(`Unsupported backup version: ${info.version}`); + throw new UnsupportedBackupVersion(info.version); } if (Bytes.isEmpty(info.mediaRootBackupKey)) { diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index c100f763eae8..cefb59c13bd1 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -31,7 +31,10 @@ import type { ExplodePromiseResultType } from '../../util/explodePromise'; import { explodePromise } from '../../util/explodePromise'; import type { RetryBackupImportValue } from '../../state/ducks/installer'; import { CipherType, HashType } from '../../types/Crypto'; -import { InstallScreenBackupStep } from '../../types/InstallScreen'; +import { + InstallScreenBackupStep, + InstallScreenBackupError, +} from '../../types/InstallScreen'; import * as Errors from '../../types/errors'; import { BackupCredentialType } from '../../types/backups'; import { HTTPError } from '../../textsecure/Errors'; @@ -46,6 +49,7 @@ import { BackupCredentials } from './credentials'; import { BackupAPI } from './api'; import { validateBackup } from './validator'; import { BackupType } from './types'; +import { UnsupportedBackupVersion } from './errors'; export { BackupType }; @@ -142,7 +146,10 @@ export class BackupsService { ); this.downloadRetryPromise = explodePromise(); window.reduxActions.installer.updateBackupImportProgress({ - hasError: true, + error: + error instanceof UnsupportedBackupVersion + ? InstallScreenBackupError.UnsupportedVersion + : InstallScreenBackupError.Unknown, }); // eslint-disable-next-line no-await-in-loop diff --git a/ts/shims/updateIpc.ts b/ts/shims/updateIpc.ts index 608f5cceac46..17be8d90cff1 100644 --- a/ts/shims/updateIpc.ts +++ b/ts/shims/updateIpc.ts @@ -6,3 +6,7 @@ import { ipcRenderer } from 'electron'; export function startUpdate(): Promise { return ipcRenderer.invoke('start-update'); } + +export function forceUpdate(): Promise { + return ipcRenderer.invoke('updater/force-update'); +} diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts index 7f394f0aac93..eeecb33641c1 100644 --- a/ts/state/ducks/installer.ts +++ b/ts/state/ducks/installer.ts @@ -7,6 +7,7 @@ import pTimeout, { TimeoutError } from 'p-timeout'; import type { StateType as RootStateType } from '../reducer'; import { + type InstallScreenBackupError, InstallScreenBackupStep, InstallScreenStep, InstallScreenError, @@ -67,7 +68,7 @@ export type InstallerStateType = ReadonlyDeep< backupStep: InstallScreenBackupStep; currentBytes?: number; totalBytes?: number; - hasError?: boolean; + error?: InstallScreenBackupError; } >; @@ -132,7 +133,7 @@ type UpdateBackupImportProgressActionType = ReadonlyDeep<{ totalBytes: number; } | { - hasError: boolean; + error: InstallScreenBackupError; }; }>; @@ -600,10 +601,10 @@ export function reducer( return state; } - if ('hasError' in action.payload) { + if ('error' in action.payload) { return { ...state, - hasError: action.payload.hasError, + error: action.payload.error, }; } @@ -626,7 +627,7 @@ export function reducer( return { ...state, - hasError: false, + error: undefined, }; } diff --git a/ts/state/ducks/updates.ts b/ts/state/ducks/updates.ts index c96d7a191be8..3fef9f736dcc 100644 --- a/ts/state/ducks/updates.ts +++ b/ts/state/ducks/updates.ts @@ -18,6 +18,7 @@ export type UpdatesStateType = ReadonlyDeep<{ downloadSize?: number; downloadedSize?: number; showEventsCount: number; + isCheckingForUpdates: boolean; version?: string; }>; @@ -27,6 +28,8 @@ const DISMISS_DIALOG = 'updates/DISMISS_DIALOG'; const SHOW_UPDATE_DIALOG = 'updates/SHOW_UPDATE_DIALOG'; const SNOOZE_UPDATE = 'updates/SNOOZE_UPDATE'; const START_UPDATE = 'updates/START_UPDATE'; +const CHECK_FOR_UPDATES = 'updates/CHECK_FOR_UPDATES'; +const CHECK_FOR_UPDATES_FINISHED = 'updates/CHECK_FOR_UPDATES_FINISHED'; const UNSNOOZE_UPDATE = 'updates/UNSNOOZE_UPDATE'; export type UpdateDialogOptionsType = ReadonlyDeep<{ @@ -55,6 +58,14 @@ type StartUpdateActionType = ReadonlyDeep<{ type: typeof START_UPDATE; }>; +type CheckForUpdatesActionType = ReadonlyDeep<{ + type: typeof CHECK_FOR_UPDATES; +}>; + +type CheckForUpdatesFinishedActionType = ReadonlyDeep<{ + type: typeof CHECK_FOR_UPDATES_FINISHED; +}>; + type UnsnoozeUpdateActionType = ReadonlyDeep<{ type: typeof UNSNOOZE_UPDATE; payload: DialogType; @@ -65,6 +76,8 @@ export type UpdatesActionType = ReadonlyDeep< | ShowUpdateDialogActionType | SnoozeUpdateActionType | StartUpdateActionType + | CheckForUpdatesActionType + | CheckForUpdatesFinishedActionType | UnsnoozeUpdateActionType >; @@ -135,11 +148,43 @@ function startUpdate(): ThunkAction< }; } +function forceUpdate(): ThunkAction< + void, + RootStateType, + unknown, + | CheckForUpdatesActionType + | CheckForUpdatesFinishedActionType + | ShowUpdateDialogActionType +> { + return async dispatch => { + dispatch({ + type: CHECK_FOR_UPDATES, + }); + + try { + await updateIpc.forceUpdate(); + } catch { + dispatch({ + type: SHOW_UPDATE_DIALOG, + payload: { + dialogType: DialogType.Cannot_Update, + otherState: {}, + }, + }); + } finally { + dispatch({ + type: CHECK_FOR_UPDATES_FINISHED, + }); + } + }; +} + export const actions = { dismissDialog, showUpdateDialog, snoozeUpdate, startUpdate, + forceUpdate, }; export const useUpdatesActions = (): BoundActionCreatorsMapObject< @@ -152,6 +197,7 @@ export function getEmptyState(): UpdatesStateType { return { dialogType: DialogType.None, didSnooze: false, + isCheckingForUpdates: false, showEventsCount: 0, }; } @@ -187,6 +233,22 @@ export function reducer( }; } + if (action.type === CHECK_FOR_UPDATES) { + return { + ...state, + dialogType: DialogType.None, + didSnooze: false, + isCheckingForUpdates: true, + }; + } + + if (action.type === CHECK_FOR_UPDATES_FINISHED) { + return { + ...state, + isCheckingForUpdates: false, + }; + } + if ( action.type === DISMISS_DIALOG && state.dialogType === DialogType.MacOS_Read_Only diff --git a/ts/state/smart/InstallScreen.tsx b/ts/state/smart/InstallScreen.tsx index 9036f9ba2be5..b4e7544797d1 100644 --- a/ts/state/smart/InstallScreen.tsx +++ b/ts/state/smart/InstallScreen.tsx @@ -32,7 +32,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { const { openInbox } = useAppActions(); const { startInstaller, finishInstall, retryBackupImport } = useInstallerActions(); - const { startUpdate } = useUpdatesActions(); + const { startUpdate, forceUpdate } = useUpdatesActions(); const hasExpired = useSelector(hasExpiredSelector); const [deviceName, setDeviceName] = useState(''); @@ -80,6 +80,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { updates, currentVersion: window.getVersion(), startUpdate, + forceUpdate, retryGetQrCode: startInstaller, OS: OS.getName(), isStaging: isStagingServer(), @@ -112,9 +113,15 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { backupStep: installerState.backupStep, currentBytes: installerState.currentBytes, totalBytes: installerState.totalBytes, - hasError: installerState.hasError, + error: installerState.error, onCancel: onCancelBackupImport, onRetry: retryBackupImport, + + updates, + currentVersion: window.getVersion(), + forceUpdate, + startUpdate, + OS: OS.getName(), }, }; break; diff --git a/ts/types/InstallScreen.ts b/ts/types/InstallScreen.ts index 701148fc72d8..3faa33fbc9d5 100644 --- a/ts/types/InstallScreen.ts +++ b/ts/types/InstallScreen.ts @@ -17,6 +17,11 @@ export enum InstallScreenBackupStep { Process = 'Process', } +export enum InstallScreenBackupError { + Unknown = 'Unknown', + UnsupportedVersion = 'UnsupportedVersion', +} + export enum InstallScreenError { TooManyDevices = 'TooManyDevices', TooOld = 'TooOld', diff --git a/ts/updater/common.ts b/ts/updater/common.ts index 88f3f8e0bf8d..9e526446d4ea 100644 --- a/ts/updater/common.ts +++ b/ts/updater/common.ts @@ -155,6 +155,8 @@ export abstract class Updater { }, 50 ); + + ipcMain.handle('updater/force-update', () => this.force()); } //