diff --git a/ts/background.ts b/ts/background.ts index 9bdf4775822f..94e3db11ea6c 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -125,6 +125,7 @@ import type { SendStateByConversationId } from './messages/MessageSendState'; import { SendStatus } from './messages/MessageSendState'; import * as Stickers from './types/Stickers'; import * as Errors from './types/errors'; +import { InstallScreenStep } from './types/InstallScreen'; import { SignalService as Proto } from './protobuf'; import { onRetryRequest, @@ -1242,7 +1243,7 @@ export async function startApp(): Promise { window.Whisper.events.on('setupAsNewDevice', () => { window.IPC.readyForUpdates(); - window.reduxActions.app.openInstaller(); + window.reduxActions.installer.startInstaller(); }); window.Whisper.events.on('setupAsStandalone', () => { @@ -1461,13 +1462,13 @@ export async function startApp(): Promise { if (isCoreDataValid && Registration.everDone()) { drop(connect()); if (window.storage.get('backupDownloadPath')) { - window.reduxActions.app.openBackupImport(); + window.reduxActions.installer.showBackupImport(); } else { window.reduxActions.app.openInbox(); } } else { window.IPC.readyForUpdates(); - window.reduxActions.app.openInstaller(); + window.reduxActions.installer.startInstaller(); } const { activeWindowService } = window.SignalContext; @@ -1518,6 +1519,8 @@ export async function startApp(): Promise { afterStart(); } + const backupReady = explodePromise(); + function afterStart() { strictAssert(messageReceiver, 'messageReceiver must be initialized'); strictAssert(server, 'server must be initialized'); @@ -1553,13 +1556,17 @@ export async function startApp(): Promise { drop(messageReceiver?.drain()); if (hasAppEverBeenRegistered) { - if ( - window.reduxStore.getState().app.appView === AppViewType.Installer - ) { - log.info( - 'background: offline, but app has been registered before; opening inbox' - ); - window.reduxActions.app.openInbox(); + const state = window.reduxStore.getState(); + if (state.app.appView === AppViewType.Installer) { + if (state.installer.step === InstallScreenStep.LinkInProgress) { + log.info( + 'background: offline, but app has been registered before; opening inbox' + ); + window.reduxActions.app.openInbox(); + } else if (state.installer.step === InstallScreenStep.BackupImport) { + log.warn('background: offline, but app has needs to import backup'); + // TODO: DESKTOP-7584 + } } if (!hasInitialLoadCompleted) { @@ -1580,44 +1587,43 @@ export async function startApp(): Promise { onOffline(); } - if (window.storage.get('backupDownloadPath')) { - log.info( - 'background: not running storage service while downloading backup' - ); - drop(downloadBackup()); - return; - } - - server.registerRequestHandler(messageReceiver); + drop(downloadBackup()); } async function downloadBackup() { + strictAssert(server != null, 'server must be initialized'); + strictAssert( + messageReceiver != null, + 'MessageReceiver must be initialized' + ); + const backupDownloadPath = window.storage.get('backupDownloadPath'); if (!backupDownloadPath) { - log.warn('No backup download path, cannot download backup'); + log.warn('downloadBackup: no backup download path, skipping'); + backupReady.resolve(); + server.registerRequestHandler(messageReceiver); + drop(runStorageService()); return; } const absoluteDownloadPath = window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath); - log.info('downloadBackup: downloading to', absoluteDownloadPath); - await backupsService.download(absoluteDownloadPath, { + log.info('downloadBackup: downloading...'); + const hasBackup = await backupsService.download(absoluteDownloadPath, { onProgress: (currentBytes, totalBytes) => { - window.reduxActions.app.updateBackupImportProgress({ + window.reduxActions.installer.updateBackupImportProgress({ currentBytes, totalBytes, }); }, }); await window.storage.remove('backupDownloadPath'); - window.reduxActions.app.openInbox(); + log.info(`downloadBackup: done, had backup=${hasBackup}`); + + // Start storage service sync, etc log.info('downloadBackup: processing websocket messages, storage service'); - strictAssert(server != null, 'server must be initialized'); - strictAssert( - messageReceiver != null, - 'MessageReceiver must be initialized' - ); + backupReady.resolve(); server.registerRequestHandler(messageReceiver); drop(runStorageService()); } @@ -1718,6 +1724,8 @@ export async function startApp(): Promise { strictAssert(server !== undefined, 'WebAPI not connected'); + await backupReady.promise; + try { connectPromise = explodePromise(); // Reset the flag and update it below if needed @@ -1923,9 +1931,8 @@ export async function startApp(): Promise { setIsInitialSync(false); // Switch to inbox view even if contact sync is still running - if ( - window.reduxStore.getState().app.appView === AppViewType.Installer - ) { + const state = window.reduxStore.getState(); + if (state.app.appView === AppViewType.Installer) { log.info('firstRun: opening inbox'); window.reduxActions.app.openInbox(); } else { @@ -1961,6 +1968,17 @@ export async function startApp(): Promise { } log.info('firstRun: done'); + } else { + const state = window.reduxStore.getState(); + if ( + state.app.appView === AppViewType.Installer && + state.installer.step === InstallScreenStep.BackupImport + ) { + log.info('notFirstRun: opening inbox after backup import'); + window.reduxActions.app.openInbox(); + } else { + log.info('notFirstRun: not opening inbox'); + } } window.storage.onready(async () => { diff --git a/ts/components/App.tsx b/ts/components/App.tsx index 0fa1f6fb013a..9ab276a1e614 100644 --- a/ts/components/App.tsx +++ b/ts/components/App.tsx @@ -8,17 +8,14 @@ import classNames from 'classnames'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import type { VerificationTransport } from '../types/VerificationTransport'; import { ThemeType } from '../types/Util'; -import type { LocalizerType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; import { type AppStateType, AppViewType } from '../state/ducks/app'; import { SmartInstallScreen } from '../state/smart/InstallScreen'; import { StandaloneRegistration } from './StandaloneRegistration'; -import { BackupImportScreen } from './BackupImportScreen'; import { usePageVisibility } from '../hooks/usePageVisibility'; import { useReducedMotion } from '../hooks/useReducedMotion'; type PropsType = { - i18n: LocalizerType; state: AppStateType; openInbox: () => void; getCaptchaToken: () => Promise; @@ -53,7 +50,6 @@ type PropsType = { }; export function App({ - i18n, state, getCaptchaToken, hasSelectedStoryData, @@ -96,10 +92,8 @@ export function App({ contents = renderInbox(); } else if (state.appView === AppViewType.Blank) { contents = undefined; - } else if (state.appView === AppViewType.BackupImport) { - contents = ; } else { - throw missingCaseError(state); + throw missingCaseError(state.appView); } // This are here so that themes are properly applied to anything that is diff --git a/ts/components/InstallScreen.tsx b/ts/components/InstallScreen.tsx index 6a1f7460c9c9..39d26a8cec77 100644 --- a/ts/components/InstallScreen.tsx +++ b/ts/components/InstallScreen.tsx @@ -5,26 +5,17 @@ import type { ComponentProps, ReactElement } from 'react'; import React from 'react'; import { missingCaseError } from '../util/missingCaseError'; +import { InstallScreenStep } from '../types/InstallScreen'; import { InstallScreenErrorStep } from './installScreen/InstallScreenErrorStep'; import { InstallScreenChoosingDeviceNameStep } from './installScreen/InstallScreenChoosingDeviceNameStep'; import { InstallScreenLinkInProgressStep } from './installScreen/InstallScreenLinkInProgressStep'; import { InstallScreenQrCodeNotScannedStep } from './installScreen/InstallScreenQrCodeNotScannedStep'; - -export enum InstallScreenStep { - Error, - QrCodeNotScanned, - ChoosingDeviceName, - LinkInProgress, -} +import { InstallScreenBackupImportStep } from './installScreen/InstallScreenBackupImportStep'; // We can't always use destructuring assignment because of the complexity of this props // type. type PropsType = - | { - step: InstallScreenStep.Error; - screenSpecificProps: ComponentProps; - } | { step: InstallScreenStep.QrCodeNotScanned; screenSpecificProps: ComponentProps< @@ -42,6 +33,14 @@ type PropsType = screenSpecificProps: ComponentProps< typeof InstallScreenLinkInProgressStep >; + } + | { + step: InstallScreenStep.BackupImport; + screenSpecificProps: ComponentProps; + } + | { + step: InstallScreenStep.Error; + screenSpecificProps: ComponentProps; }; export function InstallScreen(props: Readonly): ReactElement { @@ -58,6 +57,8 @@ export function InstallScreen(props: Readonly): ReactElement { ); case InstallScreenStep.LinkInProgress: return ; + case InstallScreenStep.BackupImport: + return ; default: throw missingCaseError(props); } diff --git a/ts/components/BackupImportScreen.stories.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx similarity index 61% rename from ts/components/BackupImportScreen.stories.tsx rename to ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx index 00219c1ce1a5..bf43330a5c46 100644 --- a/ts/components/BackupImportScreen.stories.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx @@ -3,20 +3,20 @@ import React from 'react'; import type { Meta, StoryFn } from '@storybook/react'; -import { setupI18n } from '../util/setupI18n'; -import enMessages from '../../_locales/en/messages.json'; -import type { PropsType } from './BackupImportScreen'; -import { BackupImportScreen } from './BackupImportScreen'; +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; +import type { PropsType } from './InstallScreenBackupImportStep'; +import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep'; const i18n = setupI18n('en', enMessages); export default { - title: 'Components/BackupImportScreen', + title: 'Components/InstallScreenBackupImportStep', } satisfies Meta; // eslint-disable-next-line react/function-component-definition const Template: StoryFn = (args: PropsType) => ( - + ); export const NoBytes = Template.bind({}); diff --git a/ts/components/BackupImportScreen.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.tsx similarity index 85% rename from ts/components/BackupImportScreen.tsx rename to ts/components/installScreen/InstallScreenBackupImportStep.tsx index 25a4f1ccd039..0f03cb94b9ff 100644 --- a/ts/components/BackupImportScreen.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.tsx @@ -3,11 +3,11 @@ import React from 'react'; -import type { LocalizerType } from '../types/Util'; -import { formatFileSize } from '../util/formatFileSize'; -import { TitlebarDragArea } from './TitlebarDragArea'; -import { InstallScreenSignalLogo } from './installScreen/InstallScreenSignalLogo'; -import { ProgressBar } from './ProgressBar'; +import type { LocalizerType } from '../../types/Util'; +import { formatFileSize } from '../../util/formatFileSize'; +import { TitlebarDragArea } from '../TitlebarDragArea'; +import { ProgressBar } from '../ProgressBar'; +import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; // We can't always use destructuring assignment because of the complexity of this props // type. @@ -18,7 +18,7 @@ export type PropsType = Readonly<{ totalBytes?: number; }>; -export function BackupImportScreen({ +export function InstallScreenBackupImportStep({ i18n, currentBytes, totalBytes, diff --git a/ts/components/installScreen/InstallScreenChoosingDeviceNameStep.tsx b/ts/components/installScreen/InstallScreenChoosingDeviceNameStep.tsx index aa63ad6ad517..b1a216290e81 100644 --- a/ts/components/installScreen/InstallScreenChoosingDeviceNameStep.tsx +++ b/ts/components/installScreen/InstallScreenChoosingDeviceNameStep.tsx @@ -5,6 +5,7 @@ import type { ReactElement } from 'react'; import React, { useRef } from 'react'; import type { LocalizerType } from '../../types/Util'; +import { MAX_DEVICE_NAME_LENGTH } from '../../types/InstallScreen'; import { normalizeDeviceName } from '../../util/normalizeDeviceName'; import { getEnvironment, Environment } from '../../environment'; @@ -12,11 +13,6 @@ import { Button, ButtonVariant } from '../Button'; import { TitlebarDragArea } from '../TitlebarDragArea'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; -// This is the string's `.length`, which is the number of UTF-16 code points. Instead, we -// want this to be either 50 graphemes or 256 encrypted bytes, whichever is smaller. See -// DESKTOP-2844. -export const MAX_DEVICE_NAME_LENGTH = 50; - export type PropsType = { deviceName: string; i18n: LocalizerType; diff --git a/ts/components/installScreen/InstallScreenErrorStep.stories.tsx b/ts/components/installScreen/InstallScreenErrorStep.stories.tsx index be060db3dd6e..c044524da82c 100644 --- a/ts/components/installScreen/InstallScreenErrorStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenErrorStep.stories.tsx @@ -5,9 +5,10 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import { setupI18n } from '../../util/setupI18n'; +import { InstallScreenError } from '../../types/InstallScreen'; import enMessages from '../../../_locales/en/messages.json'; import type { Props } from './InstallScreenErrorStep'; -import { InstallScreenErrorStep, InstallError } from './InstallScreenErrorStep'; +import { InstallScreenErrorStep } from './InstallScreenErrorStep'; const i18n = setupI18n('en', enMessages); @@ -24,21 +25,21 @@ const defaultProps = { export const _TooManyDevices = (): JSX.Element => ( ); export const _TooOld = (): JSX.Element => ( - + ); export const __TooOld = (): JSX.Element => ( - + ); export const _ConnectionFailed = (): JSX.Element => ( ); diff --git a/ts/components/installScreen/InstallScreenErrorStep.tsx b/ts/components/installScreen/InstallScreenErrorStep.tsx index a2e4b9df1cf4..b8b3981a91de 100644 --- a/ts/components/installScreen/InstallScreenErrorStep.tsx +++ b/ts/components/installScreen/InstallScreenErrorStep.tsx @@ -10,16 +10,10 @@ import { Button, ButtonVariant } from '../Button'; import { TitlebarDragArea } from '../TitlebarDragArea'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; import { LINK_SIGNAL_DESKTOP } from '../../types/support'; - -export enum InstallError { - TooManyDevices, - TooOld, - ConnectionFailed, - QRCodeFailed, -} +import { InstallScreenError } from '../../types/InstallScreen'; export type Props = Readonly<{ - error: InstallError; + error: InstallScreenError; i18n: LocalizerType; quit: () => unknown; tryAgain: () => unknown; @@ -37,10 +31,10 @@ export function InstallScreenErrorStep({ let shouldShowQuitButton = false; switch (error) { - case InstallError.TooManyDevices: + case InstallScreenError.TooManyDevices: errorMessage = i18n('icu:installTooManyDevices'); break; - case InstallError.TooOld: + case InstallScreenError.TooOld: errorMessage = i18n('icu:installTooOld'); buttonText = i18n('icu:upgrade'); onClickButton = () => { @@ -48,10 +42,10 @@ export function InstallScreenErrorStep({ }; shouldShowQuitButton = true; break; - case InstallError.ConnectionFailed: + case InstallScreenError.ConnectionFailed: errorMessage = i18n('icu:installConnectionFailed'); break; - case InstallError.QRCodeFailed: + case InstallScreenError.QRCodeFailed: buttonText = i18n('icu:Install__learn-more'); errorMessage = i18n('icu:installUnknownError'); onClickButton = () => { diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx index 1da4400e07ee..9396eeb96e6d 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.stories.tsx @@ -6,14 +6,12 @@ import { action } from '@storybook/addon-actions'; import type { Meta, StoryFn } from '@storybook/react'; import { setupI18n } from '../../util/setupI18n'; import { DialogType } from '../../types/Dialogs'; +import { InstallScreenQRCodeError } from '../../types/InstallScreen'; import enMessages from '../../../_locales/en/messages.json'; import type { Loadable } from '../../util/loadable'; import { LoadingState } from '../../util/loadable'; import type { PropsType } from './InstallScreenQrCodeNotScannedStep'; -import { - InstallScreenQrCodeNotScannedStep, - LoadError, -} from './InstallScreenQrCodeNotScannedStep'; +import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScannedStep'; const i18n = setupI18n('en', enMessages); @@ -40,10 +38,10 @@ export default { function Simulation({ finalResult, }: { - finalResult: Loadable; + finalResult: Loadable; }) { const [provisioningUrl, setProvisioningUrl] = useState< - Loadable + Loadable >({ loadingState: LoadingState.Loading, }); @@ -92,7 +90,7 @@ export function QrCodeFailedToLoad(): JSX.Element { i18n={i18n} provisioningUrl={{ loadingState: LoadingState.LoadFailed, - error: LoadError.Unknown, + error: InstallScreenQRCodeError.Unknown, }} updates={DEFAULT_UPDATES} OS="macOS" @@ -126,7 +124,7 @@ export function SimulatedUnknownError(): JSX.Element { ); @@ -137,7 +135,7 @@ export function SimulatedNetworkIssue(): JSX.Element { ); @@ -148,7 +146,7 @@ export function SimulatedTimeout(): JSX.Element { ); diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx index ec412f74ad25..6105c6d340ad 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx @@ -6,6 +6,7 @@ import React, { useCallback } from 'react'; import classNames from 'classnames'; import type { LocalizerType } from '../../types/Util'; +import { InstallScreenQRCodeError } from '../../types/InstallScreen'; import { missingCaseError } from '../../util/missingCaseError'; import type { Loadable } from '../../util/loadable'; import { LoadingState } from '../../util/loadable'; @@ -20,18 +21,12 @@ import { getClassNamesFor } from '../../util/getClassNamesFor'; import type { UpdatesStateType } from '../../state/ducks/updates'; import { Environment, getEnvironment } from '../../environment'; -export enum LoadError { - Timeout = 'Timeout', - Unknown = 'Unknown', - NetworkIssue = 'NetworkIssue', -} - // We can't always use destructuring assignment because of the complexity of this props // type. export type PropsType = Readonly<{ i18n: LocalizerType; - provisioningUrl: Loadable; + provisioningUrl: Loadable; hasExpired?: boolean; updates: UpdatesStateType; currentVersion: string; @@ -121,7 +116,7 @@ export function InstallScreenQrCodeNotScannedStep({ } function InstallScreenQrCode( - props: Loadable & { + props: Loadable & { i18n: LocalizerType; retryGetQrCode: () => void; } @@ -135,7 +130,7 @@ function InstallScreenQrCode( break; case LoadingState.LoadFailed: switch (props.error) { - case LoadError.Timeout: + case InstallScreenQRCodeError.Timeout: contents = ( <> ); break; - case LoadError.Unknown: + case InstallScreenQRCodeError.Unknown: contents = ( <> ); break; - case LoadError.NetworkIssue: + case InstallScreenQRCodeError.NetworkIssue: contents = ( <> (); private releaseNotesRecipientId: Long | undefined; private releaseNotesChatId: Long | undefined; + private pendingGroupAvatars = new Map(); - constructor() { + private constructor() { super({ objectMode: true }); } + public static async create(): Promise { + await AttachmentDownloadManager.stop(); + await DataWriter.removeAllBackupAttachmentDownloadJobs(); + await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0); + await window.storage.put('backupAttachmentsTotalSizeToDownload', 0); + + return new BackupImportStream(); + } + override async _write( data: Buffer, _enc: BufferEncoding, @@ -359,11 +371,10 @@ export class BackupImportStream extends Writable { // Schedule group avatar download. await pMap( - allConversations.filter(({ attributes: convo }) => { - const { avatar } = convo; - return isGroupV2(convo) && avatar?.url && !avatar.path; - }), - convo => groupAvatarJobQueue.add({ conversationId: convo.id }), + [...this.pendingGroupAvatars.entries()], + ([conversationId, newAvatarUrl]) => { + return groupAvatarJobQueue.add({ conversationId, newAvatarUrl }); + }, { concurrency: MAX_CONCURRENCY } ); @@ -376,6 +387,16 @@ export class BackupImportStream extends Writable { .map(([, id]) => id) ); + await loadAll(); + reinitializeRedux(getParametersForRedux()); + + await window.storage.put( + 'backupAttachmentsTotalSizeToDownload', + await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs() + ); + + await AttachmentDownloadManager.start(); + done(); } catch (error) { done(error); @@ -843,12 +864,6 @@ export class BackupImportStream extends Writable { // Snapshot name: dropNull(title?.title), description: dropNull(description?.descriptionText), - avatar: avatarUrl - ? { - url: avatarUrl, - path: '', - } - : undefined, expireTimer: expirationTimerS ? DurationInSeconds.fromSeconds(expirationTimerS) : undefined, @@ -937,6 +952,9 @@ export class BackupImportStream extends Writable { : undefined, announcementsOnly: dropNull(announcementsOnly), }; + if (avatarUrl) { + this.pendingGroupAvatars.set(attrs.id, avatarUrl); + } return attrs; } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 831308fce01a..ff5cf354d770 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -31,7 +31,6 @@ import * as Errors from '../../types/errors'; import { HTTPError } from '../../textsecure/Errors'; import { constantTimeEqual } from '../../Crypto'; import { measureSize } from '../../AttachmentCrypto'; -import { reinitializeRedux } from '../../state/reinitializeRedux'; import { isTestOrMockEnvironment } from '../../environment'; import { BackupExportStream } from './export'; import { BackupImportStream } from './import'; @@ -39,8 +38,6 @@ import { getKeyMaterial } from './crypto'; import { BackupCredentials } from './credentials'; import { BackupAPI, type DownloadOptionsType } from './api'; import { validateBackup } from './validator'; -import { getParametersForRedux, loadAll } from '../allLoaders'; -import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager'; const IV_LENGTH = 16; @@ -151,7 +148,7 @@ export class BackupsService { public async download( downloadPath: string, { onProgress }: Omit - ): Promise { + ): Promise { let downloadOffset = 0; try { ({ size: downloadOffset } = await stat(downloadPath)); @@ -187,7 +184,7 @@ export class BackupsService { } catch (error) { // No backup on the server if (error instanceof HTTPError && error.code === 404) { - return; + return false; } try { @@ -197,6 +194,8 @@ export class BackupsService { } throw error; } + + return true; } public async importBackup( @@ -209,6 +208,7 @@ export class BackupsService { this.isRunning = true; try { + const importStream = await BackupImportStream.create(); if (backupType === BackupType.Ciphertext) { const { aesKey, macKey } = getKeyMaterial(); @@ -237,15 +237,13 @@ export class BackupsService { // Second pass - decrypt (but still check the mac at the end) hmac = createHmac(HashType.size256, macKey); - await this.prepareForImport(); - await pipeline( createBackupStream(), getMacAndUpdateHmac(hmac, noop), getIvAndDecipher(aesKey), createGunzip(), new DelimitedStream(), - new BackupImportStream() + importStream ); strictAssert( @@ -260,14 +258,12 @@ export class BackupsService { await pipeline( createBackupStream(), new DelimitedStream(), - new BackupImportStream() + importStream ); } else { throw missingCaseError(backupType); } - await this.resetStateAfterImport(); - log.info('importBackup: finished...'); } catch (error) { log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`); @@ -281,27 +277,6 @@ export class BackupsService { } } - public async prepareForImport(): Promise { - await AttachmentDownloadManager.stop(); - await DataWriter.removeAllBackupAttachmentDownloadJobs(); - await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0); - await window.storage.put('backupAttachmentsTotalSizeToDownload', 0); - } - - public async resetStateAfterImport(): Promise { - window.ConversationController.reset(); - await window.ConversationController.load(); - await loadAll(); - reinitializeRedux(getParametersForRedux()); - - await window.storage.put( - 'backupAttachmentsTotalSizeToDownload', - await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs() - ); - - await AttachmentDownloadManager.start(); - } - public async fetchAndSaveBackupCdnObjectMetadata(): Promise { log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata'); await DataWriter.clearAllBackupCdnObjectMetadata(); diff --git a/ts/state/actions.ts b/ts/state/actions.ts index defe4f9ad55b..eb6fde5e478c 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -15,6 +15,7 @@ import { actions as emojis } from './ducks/emojis'; import { actions as expiration } from './ducks/expiration'; import { actions as globalModals } from './ducks/globalModals'; import { actions as inbox } from './ducks/inbox'; +import { actions as installer } from './ducks/installer'; import { actions as items } from './ducks/items'; import { actions as lightbox } from './ducks/lightbox'; import { actions as linkPreviews } from './ducks/linkPreviews'; @@ -46,6 +47,7 @@ export const actionCreators: ReduxActions = { expiration, globalModals, inbox, + installer, items, lightbox, linkPreviews, diff --git a/ts/state/ducks/app.ts b/ts/state/ducks/app.ts index ae5e64c6843a..33d0391b965a 100644 --- a/ts/state/ducks/app.ts +++ b/ts/state/ducks/app.ts @@ -7,6 +7,12 @@ import type { StateType as RootStateType } from '../reducer'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; import * as log from '../../logging/log'; +import { + START_INSTALLER, + type StartInstallerActionType, + SHOW_BACKUP_IMPORT, + type ShowBackupImportActionType, +} from './installer'; // State @@ -15,41 +21,19 @@ export enum AppViewType { Inbox = 'Inbox', Installer = 'Installer', Standalone = 'Standalone', - BackupImport = 'BackupImport', } -export type AppStateType = ReadonlyDeep< - { - hasInitialLoadCompleted: boolean; - } & ( - | { - appView: AppViewType.Blank; - } - | { - appView: AppViewType.Inbox; - } - | { - appView: AppViewType.Installer; - } - | { - appView: AppViewType.Standalone; - } - | { - appView: AppViewType.BackupImport; - currentBytes?: number; - totalBytes?: number; - } - ) ->; +export type AppStateType = ReadonlyDeep<{ + hasInitialLoadCompleted: boolean; + appView: AppViewType; +}>; // Actions const INITIAL_LOAD_COMPLETE = 'app/INITIAL_LOAD_COMPLETE'; const OPEN_INBOX = 'app/OPEN_INBOX'; -const OPEN_INSTALLER = 'app/OPEN_INSTALLER'; +export const OPEN_INSTALLER = 'app/OPEN_INSTALLER'; const OPEN_STANDALONE = 'app/OPEN_STANDALONE'; -const OPEN_BACKUP_IMPORT = 'app/OPEN_BACKUP_IMPORT'; -const UPDATE_BACKUP_IMPORT_PROGRESS = 'app/UPDATE_BACKUP_IMPORT_PROGRESS'; type InitialLoadCompleteActionType = ReadonlyDeep<{ type: typeof INITIAL_LOAD_COMPLETE; @@ -59,42 +43,18 @@ type OpenInboxActionType = ReadonlyDeep<{ type: typeof OPEN_INBOX; }>; -type OpenInstallerActionType = ReadonlyDeep<{ - type: typeof OPEN_INSTALLER; -}>; - type OpenStandaloneActionType = ReadonlyDeep<{ type: typeof OPEN_STANDALONE; }>; -type OpenBackupImportActionType = ReadonlyDeep<{ - type: typeof OPEN_BACKUP_IMPORT; -}>; - -type UpdateBackupImportProgressActionType = ReadonlyDeep<{ - type: typeof UPDATE_BACKUP_IMPORT_PROGRESS; - payload: { - currentBytes: number; - totalBytes: number; - }; -}>; - export type AppActionType = ReadonlyDeep< - | InitialLoadCompleteActionType - | OpenInboxActionType - | OpenInstallerActionType - | OpenStandaloneActionType - | OpenBackupImportActionType - | UpdateBackupImportProgressActionType + InitialLoadCompleteActionType | OpenInboxActionType | OpenStandaloneActionType >; export const actions = { initialLoadComplete, openInbox, - openInstaller, openStandalone, - openBackupImport, - updateBackupImportProgress, }; export const useAppActions = (): BoundActionCreatorsMapObject => @@ -123,21 +83,6 @@ function openInbox(): ThunkAction< }; } -function openInstaller(): ThunkAction< - void, - RootStateType, - unknown, - OpenInstallerActionType -> { - return dispatch => { - window.IPC.addSetupMenuItems(); - - dispatch({ - type: OPEN_INSTALLER, - }); - }; -} - function openStandalone(): ThunkAction< void, RootStateType, @@ -156,16 +101,6 @@ function openStandalone(): ThunkAction< }; } -function openBackupImport(): OpenBackupImportActionType { - return { type: OPEN_BACKUP_IMPORT }; -} - -function updateBackupImportProgress( - payload: UpdateBackupImportProgressActionType['payload'] -): UpdateBackupImportProgressActionType { - return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload }; -} - // Reducer export function getEmptyState(): AppStateType { @@ -177,7 +112,9 @@ export function getEmptyState(): AppStateType { export function reducer( state: Readonly = getEmptyState(), - action: Readonly + action: Readonly< + AppActionType | StartInstallerActionType | ShowBackupImportActionType + > ): AppStateType { if (action.type === OPEN_INBOX) { return { @@ -193,13 +130,6 @@ export function reducer( }; } - if (action.type === OPEN_INSTALLER) { - return { - ...state, - appView: AppViewType.Installer, - }; - } - if (action.type === OPEN_STANDALONE) { return { ...state, @@ -207,22 +137,11 @@ export function reducer( }; } - if (action.type === OPEN_BACKUP_IMPORT) { + // Foreign action + if (action.type === START_INSTALLER || action.type === SHOW_BACKUP_IMPORT) { return { ...state, - appView: AppViewType.BackupImport, - }; - } - - if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) { - if (state.appView !== AppViewType.BackupImport) { - return state; - } - - return { - ...state, - currentBytes: action.payload.currentBytes, - totalBytes: action.payload.totalBytes, + appView: AppViewType.Installer, }; } diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts new file mode 100644 index 000000000000..0567bdd8ad57 --- /dev/null +++ b/ts/state/ducks/installer.ts @@ -0,0 +1,558 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ThunkAction } from 'redux-thunk'; +import type { ReadonlyDeep } from 'type-fest'; +import pTimeout, { TimeoutError } from 'p-timeout'; + +import type { StateType as RootStateType } from '../reducer'; +import { + InstallScreenStep, + InstallScreenError, + InstallScreenQRCodeError, +} from '../../types/InstallScreen'; +import * as Errors from '../../types/errors'; +import { type Loadable, LoadingState } from '../../util/loadable'; +import { isRecord } from '../../util/isRecord'; +import { strictAssert } from '../../util/assert'; +import { SECOND } from '../../util/durations'; +import * as Registration from '../../util/registration'; +import { isBackupEnabled } from '../../util/isBackupEnabled'; +import { HTTPError } from '../../textsecure/Errors'; +import { + Provisioner, + type PrepareLinkDataOptionsType, +} from '../../textsecure/Provisioner'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import { useBoundActions } from '../../hooks/useBoundActions'; +import * as log from '../../logging/log'; + +const SLEEP_ERROR = new TimeoutError(); + +const QR_CODE_TIMEOUTS = [10 * SECOND, 20 * SECOND, 30 * SECOND, 60 * SECOND]; + +export type BatonType = ReadonlyDeep<{ __installer_baton: never }>; + +const controllerByBaton = new WeakMap(); +const provisionerByBaton = new WeakMap(); + +export type InstallerStateType = ReadonlyDeep< + | { + step: InstallScreenStep.NotStarted; + } + | { + step: InstallScreenStep.QrCodeNotScanned; + provisioningUrl: Loadable; + baton: BatonType; + attemptCount: number; + } + | { + step: InstallScreenStep.ChoosingDeviceName; + deviceName: string; + backupFile?: File; + baton: BatonType; + } + | { + step: InstallScreenStep.Error; + error: InstallScreenError; + } + | { + step: InstallScreenStep.LinkInProgress; + } + | { + step: InstallScreenStep.BackupImport; + currentBytes?: number; + totalBytes?: number; + } +>; + +export const START_INSTALLER = 'installer/START_INSTALLER'; +const SET_PROVISIONING_URL = 'installer/SET_PROVISIONING_URL'; +const SET_QR_CODE_ERROR = 'installer/SET_QR_CODE_ERROR'; +const SET_ERROR = 'installer/SET_ERROR'; +const QR_CODE_SCANNED = 'installer/QR_CODE_SCANNED'; +const SHOW_LINK_IN_PROGRESS = 'installer/SHOW_LINK_IN_PROGRESS'; +export const SHOW_BACKUP_IMPORT = 'installer/SHOW_BACKUP_IMPORT'; +const UPDATE_BACKUP_IMPORT_PROGRESS = 'installer/UPDATE_BACKUP_IMPORT_PROGRESS'; + +export type StartInstallerActionType = ReadonlyDeep<{ + type: typeof START_INSTALLER; + payload: BatonType; +}>; + +type SetProvisioningUrlActionType = ReadonlyDeep<{ + type: typeof SET_PROVISIONING_URL; + payload: string; +}>; + +type SetQRCodeErrorActionType = ReadonlyDeep<{ + type: typeof SET_QR_CODE_ERROR; + payload: InstallScreenQRCodeError; +}>; + +type SetErrorActionType = ReadonlyDeep<{ + type: typeof SET_ERROR; + payload: InstallScreenError; +}>; + +type QRCodeScannedActionType = ReadonlyDeep<{ + type: typeof QR_CODE_SCANNED; + payload: { + deviceName: string; + baton: BatonType; + }; +}>; + +type ShowLinkInProgressActionType = ReadonlyDeep<{ + type: typeof SHOW_LINK_IN_PROGRESS; +}>; + +export type ShowBackupImportActionType = ReadonlyDeep<{ + type: typeof SHOW_BACKUP_IMPORT; +}>; + +type UpdateBackupImportProgressActionType = ReadonlyDeep<{ + type: typeof UPDATE_BACKUP_IMPORT_PROGRESS; + payload: { + currentBytes: number; + totalBytes: number; + }; +}>; + +export type InstallerActionType = ReadonlyDeep< + | StartInstallerActionType + | SetProvisioningUrlActionType + | SetQRCodeErrorActionType + | SetErrorActionType + | QRCodeScannedActionType + | ShowLinkInProgressActionType + | ShowBackupImportActionType + | UpdateBackupImportProgressActionType +>; + +export const actions = { + startInstaller, + finishInstall, + updateBackupImportProgress, + showBackupImport, + showLinkInProgress, +}; + +export const useInstallerActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + +function startInstaller(): ThunkAction< + void, + RootStateType, + unknown, + InstallerActionType +> { + return async (dispatch, getState) => { + // WeakMap key + const baton = {} as BatonType; + + window.IPC.addSetupMenuItems(); + + dispatch({ + type: START_INSTALLER, + payload: baton, + }); + const { installer: state } = getState(); + strictAssert( + state.step === InstallScreenStep.QrCodeNotScanned, + 'Unexpected step after START_INSTALLER' + ); + const { attemptCount } = state; + + // Can't retry past attempt count + if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) { + log.error('InstallScreen/getQRCode: too many tries'); + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.QRCodeFailed, + }); + return; + } + + const { server } = window.textsecure; + strictAssert(server, 'Expected a server'); + + const provisioner = new Provisioner(server); + + const abortController = new AbortController(); + const { signal } = abortController; + signal.addEventListener('abort', () => { + provisioner.close(); + }); + + controllerByBaton.set(baton, abortController); + + // Wait to get QR code + try { + const qrCodePromise = provisioner.getURL(); + const sleepMs = QR_CODE_TIMEOUTS[attemptCount]; + log.info(`installer/getQRCode: race to ${sleepMs}ms`); + + const url = await pTimeout(qrCodePromise, sleepMs, SLEEP_ERROR); + if (signal.aborted) { + return; + } + + window.IPC.removeSetupMenuItems(); + dispatch({ + type: SET_PROVISIONING_URL, + payload: url, + }); + } catch (error) { + provisioner.close(); + + if (signal.aborted) { + return; + } + + log.error( + 'installer: got an error while waiting for QR code', + Errors.toLogFormat(error) + ); + + // Too many attempts, there is probably some issue + if (attemptCount >= QR_CODE_TIMEOUTS.length - 1) { + log.error('InstallScreen/getQRCode: too many tries'); + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.QRCodeFailed, + }); + return; + } + + // Timed out, let user retry + if (error === SLEEP_ERROR) { + dispatch({ + type: SET_QR_CODE_ERROR, + payload: InstallScreenQRCodeError.Timeout, + }); + return; + } + + if (error instanceof HTTPError && error.code === -1) { + if ( + isRecord(error.cause) && + error.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN' + ) { + dispatch({ + type: SET_QR_CODE_ERROR, + payload: InstallScreenQRCodeError.NetworkIssue, + }); + return; + } + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.ConnectionFailed, + }); + return; + } + + dispatch({ + type: SET_QR_CODE_ERROR, + payload: InstallScreenQRCodeError.Unknown, + }); + return; + } + + if (signal.aborted) { + log.warn('installer/startInstaller: aborted'); + return; + } + + // Wait for primary device to scan QR code and get back to us + + try { + await provisioner.waitForEnvelope(); + } catch (error) { + if (signal.aborted) { + return; + } + log.error( + 'installer: got an error while waiting for envelope code', + Errors.toLogFormat(error) + ); + + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.ConnectionFailed, + }); + return; + } + + if (signal.aborted) { + return; + } + provisionerByBaton.set(baton, provisioner); + + // Switch to next UI phase + dispatch({ + type: QR_CODE_SCANNED, + payload: { + deviceName: + window.textsecure.storage.user.getDeviceName() || + window.getHostName() || + '', + baton, + }, + }); + + // And feed it the CI data if present + const { SignalCI } = window; + if (SignalCI != null) { + dispatch( + finishInstall({ + deviceName: SignalCI.deviceName, + backupFile: SignalCI.backupData, + isPlaintextBackup: SignalCI.isPlaintextBackup, + }) + ); + } + }; +} + +function finishInstall( + options: PrepareLinkDataOptionsType +): ThunkAction< + void, + RootStateType, + unknown, + | SetQRCodeErrorActionType + | SetErrorActionType + | ShowLinkInProgressActionType + | ShowBackupImportActionType +> { + return async (dispatch, getState) => { + const state = getState(); + strictAssert( + state.installer.step === InstallScreenStep.ChoosingDeviceName, + 'Not choosing device name' + ); + + const { baton } = state.installer; + const provisioner = provisionerByBaton.get(baton); + strictAssert( + provisioner != null, + 'Provisioner is not waiting for device info' + ); + + // Cleanup + controllerByBaton.delete(baton); + provisionerByBaton.delete(baton); + + const accountManager = window.getAccountManager(); + strictAssert(accountManager, 'Expected an account manager'); + + if (isBackupEnabled()) { + dispatch({ type: SHOW_BACKUP_IMPORT }); + } else { + dispatch({ type: SHOW_LINK_IN_PROGRESS }); + } + + try { + const data = provisioner.prepareLinkData(options); + await accountManager.registerSecondDevice(data); + } catch (error) { + if (error instanceof HTTPError) { + switch (error.code) { + case 409: + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.TooOld, + }); + return; + case 411: + dispatch({ + type: SET_ERROR, + payload: InstallScreenError.TooManyDevices, + }); + return; + default: + break; + } + } + + dispatch({ + type: SET_QR_CODE_ERROR, + payload: InstallScreenQRCodeError.Unknown, + }); + return; + } + + // Delete all data from the database unless we're in the middle of a re-link. + // Without this, the app restarts at certain times and can cause weird things to + // happen, like data from a previous light import showing up after a new install. + const shouldRetainData = Registration.everDone(); + if (!shouldRetainData) { + try { + await window.textsecure.storage.protocol.removeAllData(); + } catch (error) { + log.error( + 'installer/finishInstall: error clearing database', + Errors.toLogFormat(error) + ); + } + } + }; +} + +function showBackupImport(): ShowBackupImportActionType { + return { type: SHOW_BACKUP_IMPORT }; +} + +function showLinkInProgress(): ShowLinkInProgressActionType { + return { type: SHOW_LINK_IN_PROGRESS }; +} + +function updateBackupImportProgress( + payload: UpdateBackupImportProgressActionType['payload'] +): UpdateBackupImportProgressActionType { + return { type: UPDATE_BACKUP_IMPORT_PROGRESS, payload }; +} + +// Reducer + +export function getEmptyState(): InstallerStateType { + return { + step: InstallScreenStep.NotStarted, + }; +} + +export function reducer( + state: Readonly = getEmptyState(), + action: Readonly +): InstallerStateType { + if (action.type === START_INSTALLER) { + // Abort previous install + if (state.step === InstallScreenStep.QrCodeNotScanned) { + const controller = controllerByBaton.get(state.baton); + controller?.abort(); + } + + return { + step: InstallScreenStep.QrCodeNotScanned, + provisioningUrl: { + loadingState: LoadingState.Loading, + }, + baton: action.payload, + attemptCount: + state.step === InstallScreenStep.QrCodeNotScanned + ? state.attemptCount + 1 + : 0, + }; + } + + if (action.type === SET_PROVISIONING_URL) { + if ( + state.step !== InstallScreenStep.QrCodeNotScanned || + state.provisioningUrl.loadingState !== LoadingState.Loading + ) { + log.warn('ducks/installer: not setting provisioning url', state.step); + return state; + } + + return { + ...state, + provisioningUrl: { + loadingState: LoadingState.Loaded, + value: action.payload, + }, + }; + } + + if (action.type === SET_QR_CODE_ERROR) { + if ( + state.step !== InstallScreenStep.QrCodeNotScanned || + state.provisioningUrl.loadingState !== LoadingState.Loading + ) { + log.warn('ducks/installer: not setting qr code error', state.step); + return state; + } + + return { + ...state, + provisioningUrl: { + loadingState: LoadingState.LoadFailed, + error: action.payload, + }, + }; + } + + if (action.type === SET_ERROR) { + return { + step: InstallScreenStep.Error, + error: action.payload, + }; + } + + if (action.type === QR_CODE_SCANNED) { + if ( + state.step !== InstallScreenStep.QrCodeNotScanned || + state.provisioningUrl.loadingState !== LoadingState.Loaded + ) { + log.warn('ducks/installer: not setting qr code scanned', state.step); + return state; + } + + return { + step: InstallScreenStep.ChoosingDeviceName, + deviceName: action.payload.deviceName, + baton: action.payload.baton, + }; + } + + if (action.type === SHOW_LINK_IN_PROGRESS) { + if ( + // Backups not supported + state.step !== InstallScreenStep.ChoosingDeviceName && + // No backup available + state.step !== InstallScreenStep.BackupImport + ) { + log.warn('ducks/installer: not setting link in progress', state.step); + return state; + } + + return { + step: InstallScreenStep.LinkInProgress, + }; + } + + if (action.type === SHOW_BACKUP_IMPORT) { + if ( + // Downloading backup after linking + state.step !== InstallScreenStep.ChoosingDeviceName && + // Restarting backup download on startup + state.step !== InstallScreenStep.NotStarted + ) { + log.warn('ducks/installer: not setting backup import', state.step); + return state; + } + + return { + step: InstallScreenStep.BackupImport, + }; + } + + if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) { + if (state.step !== InstallScreenStep.BackupImport) { + log.warn( + 'ducks/installer: not updating backup import progress', + state.step + ); + return state; + } + + return { + ...state, + currentBytes: action.payload.currentBytes, + totalBytes: action.payload.totalBytes, + }; + } + + return state; +} diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index b50ab14917eb..863112298559 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -15,6 +15,7 @@ import { getEmptyState as emojiEmptyState } from './ducks/emojis'; import { getEmptyState as expirationEmptyState } from './ducks/expiration'; import { getEmptyState as globalModalsEmptyState } from './ducks/globalModals'; import { getEmptyState as inboxEmptyState } from './ducks/inbox'; +import { getEmptyState as installerEmptyState } from './ducks/installer'; import { getEmptyState as itemsEmptyState } from './ducks/items'; import { getEmptyState as lightboxEmptyState } from './ducks/lightbox'; import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews'; @@ -133,6 +134,7 @@ function getEmptyState(): StateType { expiration: expirationEmptyState(), globalModals: globalModalsEmptyState(), inbox: inboxEmptyState(), + installer: installerEmptyState(), items: itemsEmptyState(), lightbox: lightboxEmptyState(), linkPreviews: linkPreviewsEmptyState(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index 778c878d723f..2f8b8d96439c 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -42,6 +42,7 @@ export function initializeRedux(data: ReduxInitData): void { window.reduxActions = { accounts: bindActionCreators(actionCreators.accounts, store.dispatch), app: bindActionCreators(actionCreators.app, store.dispatch), + installer: bindActionCreators(actionCreators.installer, store.dispatch), audioPlayer: bindActionCreators(actionCreators.audioPlayer, store.dispatch), audioRecorder: bindActionCreators( actionCreators.audioRecorder, diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 477c82f1b7b8..56be010ab0d8 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -17,6 +17,7 @@ import { reducer as emojis } from './ducks/emojis'; import { reducer as expiration } from './ducks/expiration'; import { reducer as globalModals } from './ducks/globalModals'; import { reducer as inbox } from './ducks/inbox'; +import { reducer as installer } from './ducks/installer'; import { reducer as items } from './ducks/items'; import { reducer as lightbox } from './ducks/lightbox'; import { reducer as linkPreviews } from './ducks/linkPreviews'; @@ -49,6 +50,7 @@ export const reducer = combineReducers({ expiration, globalModals, inbox, + installer, items, lightbox, linkPreviews, diff --git a/ts/state/selectors/installer.ts b/ts/state/selectors/installer.ts new file mode 100644 index 000000000000..6e9f82d2566f --- /dev/null +++ b/ts/state/selectors/installer.ts @@ -0,0 +1,8 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { StateType } from '../reducer'; +import type { InstallerStateType } from '../ducks/installer'; + +export const getInstallerState = (state: StateType): InstallerStateType => + state.installer; diff --git a/ts/state/smart/App.tsx b/ts/state/smart/App.tsx index f53143495779..2a792b6f158c 100644 --- a/ts/state/smart/App.tsx +++ b/ts/state/smart/App.tsx @@ -18,7 +18,6 @@ import { getIsMainWindowMaximized, getIsMainWindowFullScreen, getTheme, - getIntl, } from '../selectors/user'; import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories'; import { useAppActions } from '../ducks/app'; @@ -111,7 +110,6 @@ async function uploadProfile({ } export const SmartApp = memo(function SmartApp() { - const i18n = useSelector(getIntl); const state = useSelector(getApp); const isMaximized = useSelector(getIsMainWindowMaximized); const isFullScreen = useSelector(getIsMainWindowFullScreen); @@ -126,7 +124,6 @@ export const SmartApp = memo(function SmartApp() { return ( ; -type StateType = - | { - step: InstallScreenStep.Error; - error: InstallError; - } - | { - step: InstallScreenStep.QrCodeNotScanned; - provisioningUrl: Loadable; - } - | { - step: InstallScreenStep.ChoosingDeviceName; - deviceName: string; - backupFile?: File; - } - | { - step: InstallScreenStep.LinkInProgress; - }; - -const INITIAL_STATE: StateType = { - step: InstallScreenStep.QrCodeNotScanned, - provisioningUrl: { loadingState: LoadingState.Loading }, -}; - -const qrCodeBackOff = new BackOff([ - 10 * SECOND, - 20 * SECOND, - 30 * SECOND, - 60 * SECOND, -]); - -function classifyError( - err: unknown -): { installError: InstallError } | { loadError: LoadError } { - if (err instanceof HTTPError) { - switch (err.code) { - case -1: - if ( - isRecord(err.cause) && - err.cause.code === 'SELF_SIGNED_CERT_IN_CHAIN' - ) { - return { loadError: LoadError.NetworkIssue }; - } - return { installError: InstallError.ConnectionFailed }; - case 409: - return { installError: InstallError.TooOld }; - case 411: - return { installError: InstallError.TooManyDevices }; - default: - return { loadError: LoadError.Unknown }; - } - } - // AccountManager.registerSecondDevice uses this specific "websocket closed" - // error message. - if (isRecord(err) && err.message === 'websocket closed') { - return { installError: InstallError.ConnectionFailed }; - } - return { loadError: LoadError.Unknown }; -} - export const SmartInstallScreen = memo(function SmartInstallScreen() { const i18n = useSelector(getIntl); + const installerState = useSelector(getInstallerState); const updates = useSelector(getUpdatesState); + const { startInstaller, finishInstall } = useInstallerActions(); const { startUpdate } = useUpdatesActions(); const hasExpired = useSelector(hasExpiredSelector); - const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise()); - const chooseBackupFilePromiseWrapperRef = - useRef(explodePromise()); + const [deviceName, setDeviceName] = useState(''); + const [backupFile, setBackupFile] = useState(); - const [state, setState] = useState(INITIAL_STATE); - const [retryCounter, setRetryCounter] = useState(0); - - const setProvisioningUrl = useCallback( - (value: string) => { - setState(currentState => { - if (currentState.step !== InstallScreenStep.QrCodeNotScanned) { - return currentState; - } - return { - ...currentState, - provisioningUrl: { - loadingState: LoadingState.Loaded, - value, - }, - }; - }); - }, - [setState] - ); - - const onQrCodeScanned = useCallback(() => { - setState(currentState => { - if (currentState.step !== InstallScreenStep.QrCodeNotScanned) { - return currentState; - } - - return { - step: InstallScreenStep.ChoosingDeviceName, - deviceName: normalizeDeviceName( - window.textsecure.storage.user.getDeviceName() || - window.getHostName() || - '' - ).slice(0, MAX_DEVICE_NAME_LENGTH), - }; - }); - }, [setState]); - - const setDeviceName = useCallback( - (deviceName: string) => { - setState(currentState => { - if (currentState.step !== InstallScreenStep.ChoosingDeviceName) { - return currentState; - } - return { - ...currentState, - deviceName, - }; - }); - }, - [setState] - ); - - const setBackupFile = useCallback( - (backupFile: File) => { - setState(currentState => { - if (currentState.step !== InstallScreenStep.ChoosingDeviceName) { - return currentState; - } - return { - ...currentState, - backupFile, - }; - }); - }, - [setState] - ); - - const onSubmitDeviceName = useCallback(() => { - if (state.step !== InstallScreenStep.ChoosingDeviceName) { - return; + const onSubmitDeviceName = useCallback(async () => { + if (backupFile != null) { + // This is only for testing so don't bother catching errors + finishInstall({ deviceName, backupFile: await fileToBytes(backupFile) }); + } else { + finishInstall({ deviceName, backupFile: undefined }); } + }, [backupFile, deviceName, finishInstall]); - let deviceName: string = normalizeDeviceName(state.deviceName); - if (!deviceName.length) { - // This should be impossible, but we have it here just in case. - assertDev( - false, - 'Unexpected empty device name. Falling back to placeholder value' - ); - deviceName = i18n('icu:Install__choose-device-name__placeholder'); - } - chooseDeviceNamePromiseWrapperRef.current.resolve(deviceName); - chooseBackupFilePromiseWrapperRef.current.resolve(state.backupFile); - - setState({ step: InstallScreenStep.LinkInProgress }); - }, [state, i18n]); + const suggestedDeviceName = + installerState.step === InstallScreenStep.ChoosingDeviceName + ? installerState.deviceName + : undefined; useEffect(() => { - let hasCleanedUp = false; - - const { server } = window.textsecure; - strictAssert(server, 'Expected a server'); - - let provisioner = new Provisioner(server); - const accountManager = window.getAccountManager(); - strictAssert(accountManager, 'Expected an account manager'); - - async function getQRCode(): Promise { - const sleepError = new TimeoutError(); - try { - const qrCodePromise = provisioner.getURL(); - const sleepMs = qrCodeBackOff.getAndIncrement(); - log.info(`InstallScreen/getQRCode: race to ${sleepMs}ms`); - - const url = await pTimeout(qrCodePromise, sleepMs, sleepError); - if (hasCleanedUp) { - return; - } - - window.IPC.removeSetupMenuItems(); - setProvisioningUrl(url); - - await provisioner.waitForEnvelope(); - onQrCodeScanned(); - - let deviceName: string; - let backupFileData: Uint8Array | undefined; - let isPlaintextBackup = false; - if (window.SignalCI) { - ({ - deviceName, - backupData: backupFileData, - isPlaintextBackup = false, - } = window.SignalCI); - } else { - deviceName = await chooseDeviceNamePromiseWrapperRef.current.promise; - const backupFile = - await chooseBackupFilePromiseWrapperRef.current.promise; - - backupFileData = backupFile - ? await fileToBytes(backupFile) - : undefined; - } - - if (hasCleanedUp) { - throw new Error('Cannot confirm number; the component was unmounted'); - } - - // Delete all data from the database unless we're in the middle of a - // re-link. Without this, the app restarts at certain times and can - // cause weird things to happen, like data from a previous light - // import showing up after a new install. - const shouldRetainData = Registration.everDone(); - if (!shouldRetainData) { - try { - await window.textsecure.storage.protocol.removeAllData(); - } catch (error) { - log.error( - 'confirmNumber: error clearing database', - Errors.toLogFormat(error) - ); - } - } - - if (hasCleanedUp) { - throw new Error('Cannot confirm number; the component was unmounted'); - } - - const data = provisioner.prepareLinkData({ - deviceName, - backupFile: backupFileData, - isPlaintextBackup, - }); - await accountManager.registerSecondDevice(data); - } catch (error) { - provisioner.close(); - strictAssert(server, 'Expected a server'); - provisioner = new Provisioner(server); - - log.error( - 'account.registerSecondDevice: got an error', - Errors.toLogFormat(error) - ); - if (hasCleanedUp) { - return; - } - - if (qrCodeBackOff.isFull()) { - log.error('InstallScreen/getQRCode: too many tries'); - setState({ - step: InstallScreenStep.Error, - error: InstallError.QRCodeFailed, - }); - return; - } - - if (error === sleepError) { - setState({ - step: InstallScreenStep.QrCodeNotScanned, - provisioningUrl: { - loadingState: LoadingState.LoadFailed, - error: LoadError.Timeout, - }, - }); - return; - } - const classifiedError = classifyError(error); - if ('installError' in classifiedError) { - setState({ - step: InstallScreenStep.Error, - error: classifiedError.installError, - }); - } else { - setState({ - step: InstallScreenStep.QrCodeNotScanned, - provisioningUrl: { - loadingState: LoadingState.LoadFailed, - error: classifiedError.loadError, - }, - }); - } - } - } - - drop(getQRCode()); - - return () => { - hasCleanedUp = true; - }; - }, [setProvisioningUrl, retryCounter, onQrCodeScanned]); + setDeviceName(suggestedDeviceName ?? ''); + }, [suggestedDeviceName]); let props: PropsType; - switch (state.step) { - case InstallScreenStep.Error: - props = { - step: InstallScreenStep.Error, - screenSpecificProps: { - i18n, - error: state.error, - quit: () => window.IPC.shutdown(), - tryAgain: () => { - setRetryCounter(count => count + 1); - setState(INITIAL_STATE); - }, - }, - }; - break; + switch (installerState.step) { + case InstallScreenStep.NotStarted: + log.error('InstallScreen: Installer not started'); + return null; + case InstallScreenStep.QrCodeNotScanned: props = { step: InstallScreenStep.QrCodeNotScanned, screenSpecificProps: { i18n, - provisioningUrl: state.provisioningUrl, + provisioningUrl: installerState.provisioningUrl, hasExpired, updates, currentVersion: window.getVersion(), startUpdate, - retryGetQrCode: () => { - setRetryCounter(count => count + 1); - setState(INITIAL_STATE); - }, + retryGetQrCode: startInstaller, OS: OS.getName(), }, }; @@ -369,7 +78,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { step: InstallScreenStep.ChoosingDeviceName, screenSpecificProps: { i18n, - deviceName: state.deviceName, + deviceName, setDeviceName, setBackupFile, onSubmit: onSubmitDeviceName, @@ -382,8 +91,29 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { screenSpecificProps: { i18n }, }; break; + case InstallScreenStep.BackupImport: + props = { + step: InstallScreenStep.BackupImport, + screenSpecificProps: { + i18n, + currentBytes: installerState.currentBytes, + totalBytes: installerState.totalBytes, + }, + }; + break; + case InstallScreenStep.Error: + props = { + step: InstallScreenStep.Error, + screenSpecificProps: { + i18n, + error: installerState.error, + quit: () => window.IPC.shutdown(), + tryAgain: startInstaller, + }, + }; + break; default: - throw missingCaseError(state); + throw missingCaseError(installerState); } return ( diff --git a/ts/state/types.ts b/ts/state/types.ts index b5f11a5ff1a2..e629b1792915 100644 --- a/ts/state/types.ts +++ b/ts/state/types.ts @@ -15,6 +15,7 @@ import type { actions as emojis } from './ducks/emojis'; import type { actions as expiration } from './ducks/expiration'; import type { actions as globalModals } from './ducks/globalModals'; import type { actions as inbox } from './ducks/inbox'; +import type { actions as installer } from './ducks/installer'; import type { actions as items } from './ducks/items'; import type { actions as lightbox } from './ducks/lightbox'; import type { actions as linkPreviews } from './ducks/linkPreviews'; @@ -45,6 +46,7 @@ export type ReduxActions = { expiration: typeof expiration; globalModals: typeof globalModals; inbox: typeof inbox; + installer: typeof installer; items: typeof items; lightbox: typeof lightbox; linkPreviews: typeof linkPreviews; diff --git a/ts/textsecure/Provisioner.ts b/ts/textsecure/Provisioner.ts index 05c6b868e4cc..f0549a6aaeeb 100644 --- a/ts/textsecure/Provisioner.ts +++ b/ts/textsecure/Provisioner.ts @@ -8,12 +8,14 @@ import { import { linkDeviceRoute } from '../util/signalRoutes'; import { strictAssert } from '../util/assert'; import { normalizeAci } from '../util/normalizeAci'; +import { normalizeDeviceName } from '../util/normalizeDeviceName'; +import { MAX_DEVICE_NAME_LENGTH } from '../types/InstallScreen'; +import * as Errors from '../types/errors'; import { isUntaggedPniString, normalizePni, toTaggedPni, } from '../types/ServiceId'; -import * as Errors from '../types/errors'; import { SignalService as Proto } from '../protobuf'; import * as Bytes from '../Bytes'; import * as log from '../logging/log'; @@ -204,7 +206,10 @@ export class Provisioner { aciKeyPair, pniKeyPair, profileKey, - deviceName, + deviceName: normalizeDeviceName(deviceName).slice( + 0, + MAX_DEVICE_NAME_LENGTH + ), backupFile, isPlaintextBackup, userAgent, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index b0eda50f83e8..0e99308cf024 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -3669,6 +3669,7 @@ export function initialize({ currentBytes += chunk.byteLength; onProgress(currentBytes, totalBytes); }); + onProgress(0, totalBytes); } return combinedStream; diff --git a/ts/types/InstallScreen.ts b/ts/types/InstallScreen.ts new file mode 100644 index 000000000000..dba48c872385 --- /dev/null +++ b/ts/types/InstallScreen.ts @@ -0,0 +1,31 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export enum InstallScreenStep { + NotStarted = 'NotStarted', + QrCodeNotScanned = 'QrCodeNotScanned', + ChoosingDeviceName = 'ChoosingDeviceName', + Error = 'Error', + + // Either of these two is the final state + LinkInProgress = 'LinkInProgress', + BackupImport = 'BackupImport', +} + +export enum InstallScreenError { + TooManyDevices = 'TooManyDevices', + TooOld = 'TooOld', + ConnectionFailed = 'ConnectionFailed', + QRCodeFailed = 'QRCodeFailed', +} + +export enum InstallScreenQRCodeError { + Timeout = 'Timeout', + Unknown = 'Unknown', + NetworkIssue = 'NetworkIssue', +} + +// This is the string's `.length`, which is the number of UTF-16 code points. Instead, we +// want this to be either 50 graphemes or 256 encrypted bytes, whichever is smaller. See +// DESKTOP-2844. +export const MAX_DEVICE_NAME_LENGTH = 50; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index dfb41f5f2139..cae84b0733e0 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -3088,21 +3088,6 @@ "reasonCategory": "usageTrusted", "updated": "2023-08-20T22:14:52.008Z" }, - { - "rule": "React-useRef", - "path": "ts/state/smart/InstallScreen.tsx", - "line": " const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise());", - "reasonCategory": "testCode", - "updated": "2023-11-16T23:39:21.322Z" - }, - { - "rule": "React-useRef", - "path": "ts/state/smart/InstallScreen.tsx", - "line": " useRef(explodePromise());", - "reasonCategory": "usageTrusted", - "updated": "2021-12-06T23:07:28.947Z", - "reasonDetail": "Doesn't touch the DOM." - }, { "rule": "DOM-innerHTML", "path": "ts/windows/loading/start.ts",