Make backup import UI part of install

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-09-03 23:56:33 -05:00 committed by GitHub
parent 1ae77a32db
commit 438091b33a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 829 additions and 596 deletions

View file

@ -125,6 +125,7 @@ import type { SendStateByConversationId } from './messages/MessageSendState';
import { SendStatus } from './messages/MessageSendState'; import { SendStatus } from './messages/MessageSendState';
import * as Stickers from './types/Stickers'; import * as Stickers from './types/Stickers';
import * as Errors from './types/errors'; import * as Errors from './types/errors';
import { InstallScreenStep } from './types/InstallScreen';
import { SignalService as Proto } from './protobuf'; import { SignalService as Proto } from './protobuf';
import { import {
onRetryRequest, onRetryRequest,
@ -1242,7 +1243,7 @@ export async function startApp(): Promise<void> {
window.Whisper.events.on('setupAsNewDevice', () => { window.Whisper.events.on('setupAsNewDevice', () => {
window.IPC.readyForUpdates(); window.IPC.readyForUpdates();
window.reduxActions.app.openInstaller(); window.reduxActions.installer.startInstaller();
}); });
window.Whisper.events.on('setupAsStandalone', () => { window.Whisper.events.on('setupAsStandalone', () => {
@ -1461,13 +1462,13 @@ export async function startApp(): Promise<void> {
if (isCoreDataValid && Registration.everDone()) { if (isCoreDataValid && Registration.everDone()) {
drop(connect()); drop(connect());
if (window.storage.get('backupDownloadPath')) { if (window.storage.get('backupDownloadPath')) {
window.reduxActions.app.openBackupImport(); window.reduxActions.installer.showBackupImport();
} else { } else {
window.reduxActions.app.openInbox(); window.reduxActions.app.openInbox();
} }
} else { } else {
window.IPC.readyForUpdates(); window.IPC.readyForUpdates();
window.reduxActions.app.openInstaller(); window.reduxActions.installer.startInstaller();
} }
const { activeWindowService } = window.SignalContext; const { activeWindowService } = window.SignalContext;
@ -1518,6 +1519,8 @@ export async function startApp(): Promise<void> {
afterStart(); afterStart();
} }
const backupReady = explodePromise<void>();
function afterStart() { function afterStart() {
strictAssert(messageReceiver, 'messageReceiver must be initialized'); strictAssert(messageReceiver, 'messageReceiver must be initialized');
strictAssert(server, 'server must be initialized'); strictAssert(server, 'server must be initialized');
@ -1553,13 +1556,17 @@ export async function startApp(): Promise<void> {
drop(messageReceiver?.drain()); drop(messageReceiver?.drain());
if (hasAppEverBeenRegistered) { if (hasAppEverBeenRegistered) {
if ( const state = window.reduxStore.getState();
window.reduxStore.getState().app.appView === AppViewType.Installer if (state.app.appView === AppViewType.Installer) {
) { if (state.installer.step === InstallScreenStep.LinkInProgress) {
log.info( log.info(
'background: offline, but app has been registered before; opening inbox' 'background: offline, but app has been registered before; opening inbox'
); );
window.reduxActions.app.openInbox(); 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) { if (!hasInitialLoadCompleted) {
@ -1580,44 +1587,43 @@ export async function startApp(): Promise<void> {
onOffline(); onOffline();
} }
if (window.storage.get('backupDownloadPath')) { drop(downloadBackup());
log.info(
'background: not running storage service while downloading backup'
);
drop(downloadBackup());
return;
}
server.registerRequestHandler(messageReceiver);
} }
async function downloadBackup() { async function downloadBackup() {
strictAssert(server != null, 'server must be initialized');
strictAssert(
messageReceiver != null,
'MessageReceiver must be initialized'
);
const backupDownloadPath = window.storage.get('backupDownloadPath'); const backupDownloadPath = window.storage.get('backupDownloadPath');
if (!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; return;
} }
const absoluteDownloadPath = const absoluteDownloadPath =
window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath); window.Signal.Migrations.getAbsoluteDownloadsPath(backupDownloadPath);
log.info('downloadBackup: downloading to', absoluteDownloadPath); log.info('downloadBackup: downloading...');
await backupsService.download(absoluteDownloadPath, { const hasBackup = await backupsService.download(absoluteDownloadPath, {
onProgress: (currentBytes, totalBytes) => { onProgress: (currentBytes, totalBytes) => {
window.reduxActions.app.updateBackupImportProgress({ window.reduxActions.installer.updateBackupImportProgress({
currentBytes, currentBytes,
totalBytes, totalBytes,
}); });
}, },
}); });
await window.storage.remove('backupDownloadPath'); 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'); log.info('downloadBackup: processing websocket messages, storage service');
strictAssert(server != null, 'server must be initialized'); backupReady.resolve();
strictAssert(
messageReceiver != null,
'MessageReceiver must be initialized'
);
server.registerRequestHandler(messageReceiver); server.registerRequestHandler(messageReceiver);
drop(runStorageService()); drop(runStorageService());
} }
@ -1718,6 +1724,8 @@ export async function startApp(): Promise<void> {
strictAssert(server !== undefined, 'WebAPI not connected'); strictAssert(server !== undefined, 'WebAPI not connected');
await backupReady.promise;
try { try {
connectPromise = explodePromise(); connectPromise = explodePromise();
// Reset the flag and update it below if needed // Reset the flag and update it below if needed
@ -1923,9 +1931,8 @@ export async function startApp(): Promise<void> {
setIsInitialSync(false); setIsInitialSync(false);
// Switch to inbox view even if contact sync is still running // Switch to inbox view even if contact sync is still running
if ( const state = window.reduxStore.getState();
window.reduxStore.getState().app.appView === AppViewType.Installer if (state.app.appView === AppViewType.Installer) {
) {
log.info('firstRun: opening inbox'); log.info('firstRun: opening inbox');
window.reduxActions.app.openInbox(); window.reduxActions.app.openInbox();
} else { } else {
@ -1961,6 +1968,17 @@ export async function startApp(): Promise<void> {
} }
log.info('firstRun: done'); 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 () => { window.storage.onready(async () => {

View file

@ -8,17 +8,14 @@ import classNames from 'classnames';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import type { VerificationTransport } from '../types/VerificationTransport'; import type { VerificationTransport } from '../types/VerificationTransport';
import { ThemeType } from '../types/Util'; import { ThemeType } from '../types/Util';
import type { LocalizerType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { type AppStateType, AppViewType } from '../state/ducks/app'; import { type AppStateType, AppViewType } from '../state/ducks/app';
import { SmartInstallScreen } from '../state/smart/InstallScreen'; import { SmartInstallScreen } from '../state/smart/InstallScreen';
import { StandaloneRegistration } from './StandaloneRegistration'; import { StandaloneRegistration } from './StandaloneRegistration';
import { BackupImportScreen } from './BackupImportScreen';
import { usePageVisibility } from '../hooks/usePageVisibility'; import { usePageVisibility } from '../hooks/usePageVisibility';
import { useReducedMotion } from '../hooks/useReducedMotion'; import { useReducedMotion } from '../hooks/useReducedMotion';
type PropsType = { type PropsType = {
i18n: LocalizerType;
state: AppStateType; state: AppStateType;
openInbox: () => void; openInbox: () => void;
getCaptchaToken: () => Promise<string>; getCaptchaToken: () => Promise<string>;
@ -53,7 +50,6 @@ type PropsType = {
}; };
export function App({ export function App({
i18n,
state, state,
getCaptchaToken, getCaptchaToken,
hasSelectedStoryData, hasSelectedStoryData,
@ -96,10 +92,8 @@ export function App({
contents = renderInbox(); contents = renderInbox();
} else if (state.appView === AppViewType.Blank) { } else if (state.appView === AppViewType.Blank) {
contents = undefined; contents = undefined;
} else if (state.appView === AppViewType.BackupImport) {
contents = <BackupImportScreen i18n={i18n} {...state} />;
} else { } else {
throw missingCaseError(state); throw missingCaseError(state.appView);
} }
// This are here so that themes are properly applied to anything that is // This are here so that themes are properly applied to anything that is

View file

@ -5,26 +5,17 @@ import type { ComponentProps, ReactElement } from 'react';
import React from 'react'; import React from 'react';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { InstallScreenStep } from '../types/InstallScreen';
import { InstallScreenErrorStep } from './installScreen/InstallScreenErrorStep'; import { InstallScreenErrorStep } from './installScreen/InstallScreenErrorStep';
import { InstallScreenChoosingDeviceNameStep } from './installScreen/InstallScreenChoosingDeviceNameStep'; import { InstallScreenChoosingDeviceNameStep } from './installScreen/InstallScreenChoosingDeviceNameStep';
import { InstallScreenLinkInProgressStep } from './installScreen/InstallScreenLinkInProgressStep'; import { InstallScreenLinkInProgressStep } from './installScreen/InstallScreenLinkInProgressStep';
import { InstallScreenQrCodeNotScannedStep } from './installScreen/InstallScreenQrCodeNotScannedStep'; import { InstallScreenQrCodeNotScannedStep } from './installScreen/InstallScreenQrCodeNotScannedStep';
import { InstallScreenBackupImportStep } from './installScreen/InstallScreenBackupImportStep';
export enum InstallScreenStep {
Error,
QrCodeNotScanned,
ChoosingDeviceName,
LinkInProgress,
}
// We can't always use destructuring assignment because of the complexity of this props // We can't always use destructuring assignment because of the complexity of this props
// type. // type.
type PropsType = type PropsType =
| {
step: InstallScreenStep.Error;
screenSpecificProps: ComponentProps<typeof InstallScreenErrorStep>;
}
| { | {
step: InstallScreenStep.QrCodeNotScanned; step: InstallScreenStep.QrCodeNotScanned;
screenSpecificProps: ComponentProps< screenSpecificProps: ComponentProps<
@ -42,6 +33,14 @@ type PropsType =
screenSpecificProps: ComponentProps< screenSpecificProps: ComponentProps<
typeof InstallScreenLinkInProgressStep typeof InstallScreenLinkInProgressStep
>; >;
}
| {
step: InstallScreenStep.BackupImport;
screenSpecificProps: ComponentProps<typeof InstallScreenBackupImportStep>;
}
| {
step: InstallScreenStep.Error;
screenSpecificProps: ComponentProps<typeof InstallScreenErrorStep>;
}; };
export function InstallScreen(props: Readonly<PropsType>): ReactElement { export function InstallScreen(props: Readonly<PropsType>): ReactElement {
@ -58,6 +57,8 @@ export function InstallScreen(props: Readonly<PropsType>): ReactElement {
); );
case InstallScreenStep.LinkInProgress: case InstallScreenStep.LinkInProgress:
return <InstallScreenLinkInProgressStep {...props.screenSpecificProps} />; return <InstallScreenLinkInProgressStep {...props.screenSpecificProps} />;
case InstallScreenStep.BackupImport:
return <InstallScreenBackupImportStep {...props.screenSpecificProps} />;
default: default:
throw missingCaseError(props); throw missingCaseError(props);
} }

View file

@ -3,20 +3,20 @@
import React from 'react'; import React from 'react';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { PropsType } from './BackupImportScreen'; import type { PropsType } from './InstallScreenBackupImportStep';
import { BackupImportScreen } from './BackupImportScreen'; import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
export default { export default {
title: 'Components/BackupImportScreen', title: 'Components/InstallScreenBackupImportStep',
} satisfies Meta<PropsType>; } satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition // eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = (args: PropsType) => ( const Template: StoryFn<PropsType> = (args: PropsType) => (
<BackupImportScreen {...args} i18n={i18n} /> <InstallScreenBackupImportStep {...args} i18n={i18n} />
); );
export const NoBytes = Template.bind({}); export const NoBytes = Template.bind({});

View file

@ -3,11 +3,11 @@
import React from 'react'; import React from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../../types/Util';
import { formatFileSize } from '../util/formatFileSize'; import { formatFileSize } from '../../util/formatFileSize';
import { TitlebarDragArea } from './TitlebarDragArea'; import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './installScreen/InstallScreenSignalLogo'; import { ProgressBar } from '../ProgressBar';
import { ProgressBar } from './ProgressBar'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
// We can't always use destructuring assignment because of the complexity of this props // We can't always use destructuring assignment because of the complexity of this props
// type. // type.
@ -18,7 +18,7 @@ export type PropsType = Readonly<{
totalBytes?: number; totalBytes?: number;
}>; }>;
export function BackupImportScreen({ export function InstallScreenBackupImportStep({
i18n, i18n,
currentBytes, currentBytes,
totalBytes, totalBytes,

View file

@ -5,6 +5,7 @@ import type { ReactElement } from 'react';
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { MAX_DEVICE_NAME_LENGTH } from '../../types/InstallScreen';
import { normalizeDeviceName } from '../../util/normalizeDeviceName'; import { normalizeDeviceName } from '../../util/normalizeDeviceName';
import { getEnvironment, Environment } from '../../environment'; import { getEnvironment, Environment } from '../../environment';
@ -12,11 +13,6 @@ import { Button, ButtonVariant } from '../Button';
import { TitlebarDragArea } from '../TitlebarDragArea'; import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; 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 = { export type PropsType = {
deviceName: string; deviceName: string;
i18n: LocalizerType; i18n: LocalizerType;

View file

@ -5,9 +5,10 @@ import React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import { InstallScreenError } from '../../types/InstallScreen';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { Props } from './InstallScreenErrorStep'; import type { Props } from './InstallScreenErrorStep';
import { InstallScreenErrorStep, InstallError } from './InstallScreenErrorStep'; import { InstallScreenErrorStep } from './InstallScreenErrorStep';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -24,21 +25,21 @@ const defaultProps = {
export const _TooManyDevices = (): JSX.Element => ( export const _TooManyDevices = (): JSX.Element => (
<InstallScreenErrorStep <InstallScreenErrorStep
{...defaultProps} {...defaultProps}
error={InstallError.TooManyDevices} error={InstallScreenError.TooManyDevices}
/> />
); );
export const _TooOld = (): JSX.Element => ( export const _TooOld = (): JSX.Element => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} /> <InstallScreenErrorStep {...defaultProps} error={InstallScreenError.TooOld} />
); );
export const __TooOld = (): JSX.Element => ( export const __TooOld = (): JSX.Element => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} /> <InstallScreenErrorStep {...defaultProps} error={InstallScreenError.TooOld} />
); );
export const _ConnectionFailed = (): JSX.Element => ( export const _ConnectionFailed = (): JSX.Element => (
<InstallScreenErrorStep <InstallScreenErrorStep
{...defaultProps} {...defaultProps}
error={InstallError.ConnectionFailed} error={InstallScreenError.ConnectionFailed}
/> />
); );

View file

@ -10,16 +10,10 @@ import { Button, ButtonVariant } from '../Button';
import { TitlebarDragArea } from '../TitlebarDragArea'; import { TitlebarDragArea } from '../TitlebarDragArea';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
import { LINK_SIGNAL_DESKTOP } from '../../types/support'; import { LINK_SIGNAL_DESKTOP } from '../../types/support';
import { InstallScreenError } from '../../types/InstallScreen';
export enum InstallError {
TooManyDevices,
TooOld,
ConnectionFailed,
QRCodeFailed,
}
export type Props = Readonly<{ export type Props = Readonly<{
error: InstallError; error: InstallScreenError;
i18n: LocalizerType; i18n: LocalizerType;
quit: () => unknown; quit: () => unknown;
tryAgain: () => unknown; tryAgain: () => unknown;
@ -37,10 +31,10 @@ export function InstallScreenErrorStep({
let shouldShowQuitButton = false; let shouldShowQuitButton = false;
switch (error) { switch (error) {
case InstallError.TooManyDevices: case InstallScreenError.TooManyDevices:
errorMessage = i18n('icu:installTooManyDevices'); errorMessage = i18n('icu:installTooManyDevices');
break; break;
case InstallError.TooOld: case InstallScreenError.TooOld:
errorMessage = i18n('icu:installTooOld'); errorMessage = i18n('icu:installTooOld');
buttonText = i18n('icu:upgrade'); buttonText = i18n('icu:upgrade');
onClickButton = () => { onClickButton = () => {
@ -48,10 +42,10 @@ export function InstallScreenErrorStep({
}; };
shouldShowQuitButton = true; shouldShowQuitButton = true;
break; break;
case InstallError.ConnectionFailed: case InstallScreenError.ConnectionFailed:
errorMessage = i18n('icu:installConnectionFailed'); errorMessage = i18n('icu:installConnectionFailed');
break; break;
case InstallError.QRCodeFailed: case InstallScreenError.QRCodeFailed:
buttonText = i18n('icu:Install__learn-more'); buttonText = i18n('icu:Install__learn-more');
errorMessage = i18n('icu:installUnknownError'); errorMessage = i18n('icu:installUnknownError');
onClickButton = () => { onClickButton = () => {

View file

@ -6,14 +6,12 @@ import { action } from '@storybook/addon-actions';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import { DialogType } from '../../types/Dialogs'; import { DialogType } from '../../types/Dialogs';
import { InstallScreenQRCodeError } from '../../types/InstallScreen';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { Loadable } from '../../util/loadable'; import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable'; import { LoadingState } from '../../util/loadable';
import type { PropsType } from './InstallScreenQrCodeNotScannedStep'; import type { PropsType } from './InstallScreenQrCodeNotScannedStep';
import { import { InstallScreenQrCodeNotScannedStep } from './InstallScreenQrCodeNotScannedStep';
InstallScreenQrCodeNotScannedStep,
LoadError,
} from './InstallScreenQrCodeNotScannedStep';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -40,10 +38,10 @@ export default {
function Simulation({ function Simulation({
finalResult, finalResult,
}: { }: {
finalResult: Loadable<string, LoadError>; finalResult: Loadable<string, InstallScreenQRCodeError>;
}) { }) {
const [provisioningUrl, setProvisioningUrl] = useState< const [provisioningUrl, setProvisioningUrl] = useState<
Loadable<string, LoadError> Loadable<string, InstallScreenQRCodeError>
>({ >({
loadingState: LoadingState.Loading, loadingState: LoadingState.Loading,
}); });
@ -92,7 +90,7 @@ export function QrCodeFailedToLoad(): JSX.Element {
i18n={i18n} i18n={i18n}
provisioningUrl={{ provisioningUrl={{
loadingState: LoadingState.LoadFailed, loadingState: LoadingState.LoadFailed,
error: LoadError.Unknown, error: InstallScreenQRCodeError.Unknown,
}} }}
updates={DEFAULT_UPDATES} updates={DEFAULT_UPDATES}
OS="macOS" OS="macOS"
@ -126,7 +124,7 @@ export function SimulatedUnknownError(): JSX.Element {
<Simulation <Simulation
finalResult={{ finalResult={{
loadingState: LoadingState.LoadFailed, loadingState: LoadingState.LoadFailed,
error: LoadError.Unknown, error: InstallScreenQRCodeError.Unknown,
}} }}
/> />
); );
@ -137,7 +135,7 @@ export function SimulatedNetworkIssue(): JSX.Element {
<Simulation <Simulation
finalResult={{ finalResult={{
loadingState: LoadingState.LoadFailed, loadingState: LoadingState.LoadFailed,
error: LoadError.NetworkIssue, error: InstallScreenQRCodeError.NetworkIssue,
}} }}
/> />
); );
@ -148,7 +146,7 @@ export function SimulatedTimeout(): JSX.Element {
<Simulation <Simulation
finalResult={{ finalResult={{
loadingState: LoadingState.LoadFailed, loadingState: LoadingState.LoadFailed,
error: LoadError.Timeout, error: InstallScreenQRCodeError.Timeout,
}} }}
/> />
); );

View file

@ -6,6 +6,7 @@ import React, { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { InstallScreenQRCodeError } from '../../types/InstallScreen';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import type { Loadable } from '../../util/loadable'; import type { Loadable } from '../../util/loadable';
import { LoadingState } 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 type { UpdatesStateType } from '../../state/ducks/updates';
import { Environment, getEnvironment } from '../../environment'; 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 // We can't always use destructuring assignment because of the complexity of this props
// type. // type.
export type PropsType = Readonly<{ export type PropsType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
provisioningUrl: Loadable<string, LoadError>; provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
hasExpired?: boolean; hasExpired?: boolean;
updates: UpdatesStateType; updates: UpdatesStateType;
currentVersion: string; currentVersion: string;
@ -121,7 +116,7 @@ export function InstallScreenQrCodeNotScannedStep({
} }
function InstallScreenQrCode( function InstallScreenQrCode(
props: Loadable<string, LoadError> & { props: Loadable<string, InstallScreenQRCodeError> & {
i18n: LocalizerType; i18n: LocalizerType;
retryGetQrCode: () => void; retryGetQrCode: () => void;
} }
@ -135,7 +130,7 @@ function InstallScreenQrCode(
break; break;
case LoadingState.LoadFailed: case LoadingState.LoadFailed:
switch (props.error) { switch (props.error) {
case LoadError.Timeout: case InstallScreenQRCodeError.Timeout:
contents = ( contents = (
<> <>
<span <span
@ -147,7 +142,7 @@ function InstallScreenQrCode(
</> </>
); );
break; break;
case LoadError.Unknown: case InstallScreenQRCodeError.Unknown:
contents = ( contents = (
<> <>
<span <span
@ -163,7 +158,7 @@ function InstallScreenQrCode(
</> </>
); );
break; break;
case LoadError.NetworkIssue: case InstallScreenQRCodeError.NetworkIssue:
contents = ( contents = (
<> <>
<span <span

View file

@ -10,7 +10,7 @@ import { isNumber } from 'lodash';
import { CallLinkRootKey } from '@signalapp/ringrtc'; import { CallLinkRootKey } from '@signalapp/ringrtc';
import { Backups, SignalService } from '../../protobuf'; import { Backups, SignalService } from '../../protobuf';
import { DataWriter } from '../../sql/Client'; import { DataReader, DataWriter } from '../../sql/Client';
import { import {
AttachmentDownloadSource, AttachmentDownloadSource,
type StoryDistributionWithMembersType, type StoryDistributionWithMembersType,
@ -78,7 +78,7 @@ import type { GroupV2ChangeDetailType } from '../../groups';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { drop } from '../../util/drop'; import { drop } from '../../util/drop';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation'; import { isGroup } from '../../util/whatTypeOfConversation';
import { rgbToHSL } from '../../util/rgbToHSL'; import { rgbToHSL } from '../../util/rgbToHSL';
import { import {
convertBackupMessageAttachmentToAttachment, convertBackupMessageAttachmentToAttachment,
@ -88,6 +88,7 @@ import { filterAndClean } from '../../types/BodyRange';
import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME'; import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME';
import { copyFromQuotedMessage } from '../../messages/copyQuote'; import { copyFromQuotedMessage } from '../../messages/copyQuote';
import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue'; import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue';
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
import { import {
AdhocCallStatus, AdhocCallStatus,
CallDirection, CallDirection,
@ -99,9 +100,10 @@ import {
import type { CallHistoryDetails } from '../../types/CallDisposition'; import type { CallHistoryDetails } from '../../types/CallDisposition';
import { CallLinkRestrictions } from '../../types/CallLink'; import { CallLinkRestrictions } from '../../types/CallLink';
import type { CallLinkType } from '../../types/CallLink'; import type { CallLinkType } from '../../types/CallLink';
import { fromAdminKeyBytes } from '../../util/callLinks'; import { fromAdminKeyBytes } from '../../util/callLinks';
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc'; import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
import { reinitializeRedux } from '../../state/reinitializeRedux';
import { getParametersForRedux, loadAll } from '../allLoaders';
const MAX_CONCURRENCY = 10; const MAX_CONCURRENCY = 10;
@ -283,11 +285,21 @@ export class BackupImportStream extends Writable {
private customColorById = new Map<number, CustomColorDataType>(); private customColorById = new Map<number, CustomColorDataType>();
private releaseNotesRecipientId: Long | undefined; private releaseNotesRecipientId: Long | undefined;
private releaseNotesChatId: Long | undefined; private releaseNotesChatId: Long | undefined;
private pendingGroupAvatars = new Map<string, string>();
constructor() { private constructor() {
super({ objectMode: true }); super({ objectMode: true });
} }
public static async create(): Promise<BackupImportStream> {
await AttachmentDownloadManager.stop();
await DataWriter.removeAllBackupAttachmentDownloadJobs();
await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0);
await window.storage.put('backupAttachmentsTotalSizeToDownload', 0);
return new BackupImportStream();
}
override async _write( override async _write(
data: Buffer, data: Buffer,
_enc: BufferEncoding, _enc: BufferEncoding,
@ -359,11 +371,10 @@ export class BackupImportStream extends Writable {
// Schedule group avatar download. // Schedule group avatar download.
await pMap( await pMap(
allConversations.filter(({ attributes: convo }) => { [...this.pendingGroupAvatars.entries()],
const { avatar } = convo; ([conversationId, newAvatarUrl]) => {
return isGroupV2(convo) && avatar?.url && !avatar.path; return groupAvatarJobQueue.add({ conversationId, newAvatarUrl });
}), },
convo => groupAvatarJobQueue.add({ conversationId: convo.id }),
{ concurrency: MAX_CONCURRENCY } { concurrency: MAX_CONCURRENCY }
); );
@ -376,6 +387,16 @@ export class BackupImportStream extends Writable {
.map(([, id]) => id) .map(([, id]) => id)
); );
await loadAll();
reinitializeRedux(getParametersForRedux());
await window.storage.put(
'backupAttachmentsTotalSizeToDownload',
await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs()
);
await AttachmentDownloadManager.start();
done(); done();
} catch (error) { } catch (error) {
done(error); done(error);
@ -843,12 +864,6 @@ export class BackupImportStream extends Writable {
// Snapshot // Snapshot
name: dropNull(title?.title), name: dropNull(title?.title),
description: dropNull(description?.descriptionText), description: dropNull(description?.descriptionText),
avatar: avatarUrl
? {
url: avatarUrl,
path: '',
}
: undefined,
expireTimer: expirationTimerS expireTimer: expirationTimerS
? DurationInSeconds.fromSeconds(expirationTimerS) ? DurationInSeconds.fromSeconds(expirationTimerS)
: undefined, : undefined,
@ -937,6 +952,9 @@ export class BackupImportStream extends Writable {
: undefined, : undefined,
announcementsOnly: dropNull(announcementsOnly), announcementsOnly: dropNull(announcementsOnly),
}; };
if (avatarUrl) {
this.pendingGroupAvatars.set(attrs.id, avatarUrl);
}
return attrs; return attrs;
} }

View file

@ -31,7 +31,6 @@ import * as Errors from '../../types/errors';
import { HTTPError } from '../../textsecure/Errors'; import { HTTPError } from '../../textsecure/Errors';
import { constantTimeEqual } from '../../Crypto'; import { constantTimeEqual } from '../../Crypto';
import { measureSize } from '../../AttachmentCrypto'; import { measureSize } from '../../AttachmentCrypto';
import { reinitializeRedux } from '../../state/reinitializeRedux';
import { isTestOrMockEnvironment } from '../../environment'; import { isTestOrMockEnvironment } from '../../environment';
import { BackupExportStream } from './export'; import { BackupExportStream } from './export';
import { BackupImportStream } from './import'; import { BackupImportStream } from './import';
@ -39,8 +38,6 @@ import { getKeyMaterial } from './crypto';
import { BackupCredentials } from './credentials'; import { BackupCredentials } from './credentials';
import { BackupAPI, type DownloadOptionsType } from './api'; import { BackupAPI, type DownloadOptionsType } from './api';
import { validateBackup } from './validator'; import { validateBackup } from './validator';
import { getParametersForRedux, loadAll } from '../allLoaders';
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
const IV_LENGTH = 16; const IV_LENGTH = 16;
@ -151,7 +148,7 @@ export class BackupsService {
public async download( public async download(
downloadPath: string, downloadPath: string,
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'> { onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
): Promise<void> { ): Promise<boolean> {
let downloadOffset = 0; let downloadOffset = 0;
try { try {
({ size: downloadOffset } = await stat(downloadPath)); ({ size: downloadOffset } = await stat(downloadPath));
@ -187,7 +184,7 @@ export class BackupsService {
} catch (error) { } catch (error) {
// No backup on the server // No backup on the server
if (error instanceof HTTPError && error.code === 404) { if (error instanceof HTTPError && error.code === 404) {
return; return false;
} }
try { try {
@ -197,6 +194,8 @@ export class BackupsService {
} }
throw error; throw error;
} }
return true;
} }
public async importBackup( public async importBackup(
@ -209,6 +208,7 @@ export class BackupsService {
this.isRunning = true; this.isRunning = true;
try { try {
const importStream = await BackupImportStream.create();
if (backupType === BackupType.Ciphertext) { if (backupType === BackupType.Ciphertext) {
const { aesKey, macKey } = getKeyMaterial(); const { aesKey, macKey } = getKeyMaterial();
@ -237,15 +237,13 @@ export class BackupsService {
// Second pass - decrypt (but still check the mac at the end) // Second pass - decrypt (but still check the mac at the end)
hmac = createHmac(HashType.size256, macKey); hmac = createHmac(HashType.size256, macKey);
await this.prepareForImport();
await pipeline( await pipeline(
createBackupStream(), createBackupStream(),
getMacAndUpdateHmac(hmac, noop), getMacAndUpdateHmac(hmac, noop),
getIvAndDecipher(aesKey), getIvAndDecipher(aesKey),
createGunzip(), createGunzip(),
new DelimitedStream(), new DelimitedStream(),
new BackupImportStream() importStream
); );
strictAssert( strictAssert(
@ -260,14 +258,12 @@ export class BackupsService {
await pipeline( await pipeline(
createBackupStream(), createBackupStream(),
new DelimitedStream(), new DelimitedStream(),
new BackupImportStream() importStream
); );
} else { } else {
throw missingCaseError(backupType); throw missingCaseError(backupType);
} }
await this.resetStateAfterImport();
log.info('importBackup: finished...'); log.info('importBackup: finished...');
} catch (error) { } catch (error) {
log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`); log.info(`importBackup: failed, error: ${Errors.toLogFormat(error)}`);
@ -281,27 +277,6 @@ export class BackupsService {
} }
} }
public async prepareForImport(): Promise<void> {
await AttachmentDownloadManager.stop();
await DataWriter.removeAllBackupAttachmentDownloadJobs();
await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0);
await window.storage.put('backupAttachmentsTotalSizeToDownload', 0);
}
public async resetStateAfterImport(): Promise<void> {
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<void> { public async fetchAndSaveBackupCdnObjectMetadata(): Promise<void> {
log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata'); log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
await DataWriter.clearAllBackupCdnObjectMetadata(); await DataWriter.clearAllBackupCdnObjectMetadata();

View file

@ -15,6 +15,7 @@ import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration'; import { actions as expiration } from './ducks/expiration';
import { actions as globalModals } from './ducks/globalModals'; import { actions as globalModals } from './ducks/globalModals';
import { actions as inbox } from './ducks/inbox'; import { actions as inbox } from './ducks/inbox';
import { actions as installer } from './ducks/installer';
import { actions as items } from './ducks/items'; import { actions as items } from './ducks/items';
import { actions as lightbox } from './ducks/lightbox'; import { actions as lightbox } from './ducks/lightbox';
import { actions as linkPreviews } from './ducks/linkPreviews'; import { actions as linkPreviews } from './ducks/linkPreviews';
@ -46,6 +47,7 @@ export const actionCreators: ReduxActions = {
expiration, expiration,
globalModals, globalModals,
inbox, inbox,
installer,
items, items,
lightbox, lightbox,
linkPreviews, linkPreviews,

View file

@ -7,6 +7,12 @@ import type { StateType as RootStateType } from '../reducer';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import {
START_INSTALLER,
type StartInstallerActionType,
SHOW_BACKUP_IMPORT,
type ShowBackupImportActionType,
} from './installer';
// State // State
@ -15,41 +21,19 @@ export enum AppViewType {
Inbox = 'Inbox', Inbox = 'Inbox',
Installer = 'Installer', Installer = 'Installer',
Standalone = 'Standalone', Standalone = 'Standalone',
BackupImport = 'BackupImport',
} }
export type AppStateType = ReadonlyDeep< export type AppStateType = ReadonlyDeep<{
{ hasInitialLoadCompleted: boolean;
hasInitialLoadCompleted: boolean; appView: AppViewType;
} & ( }>;
| {
appView: AppViewType.Blank;
}
| {
appView: AppViewType.Inbox;
}
| {
appView: AppViewType.Installer;
}
| {
appView: AppViewType.Standalone;
}
| {
appView: AppViewType.BackupImport;
currentBytes?: number;
totalBytes?: number;
}
)
>;
// Actions // Actions
const INITIAL_LOAD_COMPLETE = 'app/INITIAL_LOAD_COMPLETE'; const INITIAL_LOAD_COMPLETE = 'app/INITIAL_LOAD_COMPLETE';
const OPEN_INBOX = 'app/OPEN_INBOX'; 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_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 InitialLoadCompleteActionType = ReadonlyDeep<{
type: typeof INITIAL_LOAD_COMPLETE; type: typeof INITIAL_LOAD_COMPLETE;
@ -59,42 +43,18 @@ type OpenInboxActionType = ReadonlyDeep<{
type: typeof OPEN_INBOX; type: typeof OPEN_INBOX;
}>; }>;
type OpenInstallerActionType = ReadonlyDeep<{
type: typeof OPEN_INSTALLER;
}>;
type OpenStandaloneActionType = ReadonlyDeep<{ type OpenStandaloneActionType = ReadonlyDeep<{
type: typeof OPEN_STANDALONE; 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< export type AppActionType = ReadonlyDeep<
| InitialLoadCompleteActionType InitialLoadCompleteActionType | OpenInboxActionType | OpenStandaloneActionType
| OpenInboxActionType
| OpenInstallerActionType
| OpenStandaloneActionType
| OpenBackupImportActionType
| UpdateBackupImportProgressActionType
>; >;
export const actions = { export const actions = {
initialLoadComplete, initialLoadComplete,
openInbox, openInbox,
openInstaller,
openStandalone, openStandalone,
openBackupImport,
updateBackupImportProgress,
}; };
export const useAppActions = (): BoundActionCreatorsMapObject<typeof actions> => export const useAppActions = (): BoundActionCreatorsMapObject<typeof actions> =>
@ -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< function openStandalone(): ThunkAction<
void, void,
RootStateType, 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 // Reducer
export function getEmptyState(): AppStateType { export function getEmptyState(): AppStateType {
@ -177,7 +112,9 @@ export function getEmptyState(): AppStateType {
export function reducer( export function reducer(
state: Readonly<AppStateType> = getEmptyState(), state: Readonly<AppStateType> = getEmptyState(),
action: Readonly<AppActionType> action: Readonly<
AppActionType | StartInstallerActionType | ShowBackupImportActionType
>
): AppStateType { ): AppStateType {
if (action.type === OPEN_INBOX) { if (action.type === OPEN_INBOX) {
return { return {
@ -193,13 +130,6 @@ export function reducer(
}; };
} }
if (action.type === OPEN_INSTALLER) {
return {
...state,
appView: AppViewType.Installer,
};
}
if (action.type === OPEN_STANDALONE) { if (action.type === OPEN_STANDALONE) {
return { return {
...state, ...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 { return {
...state, ...state,
appView: AppViewType.BackupImport, appView: AppViewType.Installer,
};
}
if (action.type === UPDATE_BACKUP_IMPORT_PROGRESS) {
if (state.appView !== AppViewType.BackupImport) {
return state;
}
return {
...state,
currentBytes: action.payload.currentBytes,
totalBytes: action.payload.totalBytes,
}; };
} }

558
ts/state/ducks/installer.ts Normal file
View file

@ -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<BatonType, AbortController>();
const provisionerByBaton = new WeakMap<BatonType, Provisioner>();
export type InstallerStateType = ReadonlyDeep<
| {
step: InstallScreenStep.NotStarted;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
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<InstallerStateType> = getEmptyState(),
action: Readonly<InstallerActionType>
): 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;
}

View file

@ -15,6 +15,7 @@ import { getEmptyState as emojiEmptyState } from './ducks/emojis';
import { getEmptyState as expirationEmptyState } from './ducks/expiration'; import { getEmptyState as expirationEmptyState } from './ducks/expiration';
import { getEmptyState as globalModalsEmptyState } from './ducks/globalModals'; import { getEmptyState as globalModalsEmptyState } from './ducks/globalModals';
import { getEmptyState as inboxEmptyState } from './ducks/inbox'; import { getEmptyState as inboxEmptyState } from './ducks/inbox';
import { getEmptyState as installerEmptyState } from './ducks/installer';
import { getEmptyState as itemsEmptyState } from './ducks/items'; import { getEmptyState as itemsEmptyState } from './ducks/items';
import { getEmptyState as lightboxEmptyState } from './ducks/lightbox'; import { getEmptyState as lightboxEmptyState } from './ducks/lightbox';
import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews'; import { getEmptyState as linkPreviewsEmptyState } from './ducks/linkPreviews';
@ -133,6 +134,7 @@ function getEmptyState(): StateType {
expiration: expirationEmptyState(), expiration: expirationEmptyState(),
globalModals: globalModalsEmptyState(), globalModals: globalModalsEmptyState(),
inbox: inboxEmptyState(), inbox: inboxEmptyState(),
installer: installerEmptyState(),
items: itemsEmptyState(), items: itemsEmptyState(),
lightbox: lightboxEmptyState(), lightbox: lightboxEmptyState(),
linkPreviews: linkPreviewsEmptyState(), linkPreviews: linkPreviewsEmptyState(),

View file

@ -42,6 +42,7 @@ export function initializeRedux(data: ReduxInitData): void {
window.reduxActions = { window.reduxActions = {
accounts: bindActionCreators(actionCreators.accounts, store.dispatch), accounts: bindActionCreators(actionCreators.accounts, store.dispatch),
app: bindActionCreators(actionCreators.app, store.dispatch), app: bindActionCreators(actionCreators.app, store.dispatch),
installer: bindActionCreators(actionCreators.installer, store.dispatch),
audioPlayer: bindActionCreators(actionCreators.audioPlayer, store.dispatch), audioPlayer: bindActionCreators(actionCreators.audioPlayer, store.dispatch),
audioRecorder: bindActionCreators( audioRecorder: bindActionCreators(
actionCreators.audioRecorder, actionCreators.audioRecorder,

View file

@ -17,6 +17,7 @@ import { reducer as emojis } from './ducks/emojis';
import { reducer as expiration } from './ducks/expiration'; import { reducer as expiration } from './ducks/expiration';
import { reducer as globalModals } from './ducks/globalModals'; import { reducer as globalModals } from './ducks/globalModals';
import { reducer as inbox } from './ducks/inbox'; import { reducer as inbox } from './ducks/inbox';
import { reducer as installer } from './ducks/installer';
import { reducer as items } from './ducks/items'; import { reducer as items } from './ducks/items';
import { reducer as lightbox } from './ducks/lightbox'; import { reducer as lightbox } from './ducks/lightbox';
import { reducer as linkPreviews } from './ducks/linkPreviews'; import { reducer as linkPreviews } from './ducks/linkPreviews';
@ -49,6 +50,7 @@ export const reducer = combineReducers({
expiration, expiration,
globalModals, globalModals,
inbox, inbox,
installer,
items, items,
lightbox, lightbox,
linkPreviews, linkPreviews,

View file

@ -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;

View file

@ -18,7 +18,6 @@ import {
getIsMainWindowMaximized, getIsMainWindowMaximized,
getIsMainWindowFullScreen, getIsMainWindowFullScreen,
getTheme, getTheme,
getIntl,
} from '../selectors/user'; } from '../selectors/user';
import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories'; import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories';
import { useAppActions } from '../ducks/app'; import { useAppActions } from '../ducks/app';
@ -111,7 +110,6 @@ async function uploadProfile({
} }
export const SmartApp = memo(function SmartApp() { export const SmartApp = memo(function SmartApp() {
const i18n = useSelector(getIntl);
const state = useSelector(getApp); const state = useSelector(getApp);
const isMaximized = useSelector(getIsMainWindowMaximized); const isMaximized = useSelector(getIsMainWindowMaximized);
const isFullScreen = useSelector(getIsMainWindowFullScreen); const isFullScreen = useSelector(getIsMainWindowFullScreen);
@ -126,7 +124,6 @@ export const SmartApp = memo(function SmartApp() {
return ( return (
<App <App
i18n={i18n}
state={state} state={state}
isMaximized={isMaximized} isMaximized={isMaximized}
isFullScreen={isFullScreen} isFullScreen={isFullScreen}

View file

@ -2,364 +2,73 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import React, { memo, useCallback, useState, useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import pTimeout, { TimeoutError } from 'p-timeout';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getUpdatesState } from '../selectors/updates'; import { getUpdatesState } from '../selectors/updates';
import { getInstallerState } from '../selectors/installer';
import { useInstallerActions } from '../ducks/installer';
import { useUpdatesActions } from '../ducks/updates'; import { useUpdatesActions } from '../ducks/updates';
import { hasExpired as hasExpiredSelector } from '../selectors/expiration'; import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
import * as log from '../../logging/log';
import type { Loadable } from '../../util/loadable';
import { LoadingState } from '../../util/loadable';
import { assertDev, strictAssert } from '../../util/assert';
import { explodePromise } from '../../util/explodePromise';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import * as Registration from '../../util/registration'; import { InstallScreen } from '../../components/InstallScreen';
import {
InstallScreen,
InstallScreenStep,
} from '../../components/InstallScreen';
import { InstallError } from '../../components/installScreen/InstallScreenErrorStep';
import { LoadError } from '../../components/installScreen/InstallScreenQrCodeNotScannedStep';
import { MAX_DEVICE_NAME_LENGTH } from '../../components/installScreen/InstallScreenChoosingDeviceNameStep';
import { WidthBreakpoint } from '../../components/_util'; import { WidthBreakpoint } from '../../components/_util';
import { HTTPError } from '../../textsecure/Errors'; import { InstallScreenStep } from '../../types/InstallScreen';
import { isRecord } from '../../util/isRecord';
import { Provisioner } from '../../textsecure/Provisioner';
import * as Errors from '../../types/errors';
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
import OS from '../../util/os/osMain'; import OS from '../../util/os/osMain';
import { SECOND } from '../../util/durations';
import { BackOff } from '../../util/BackOff';
import { drop } from '../../util/drop';
import { SmartToastManager } from './ToastManager';
import { fileToBytes } from '../../util/fileToBytes'; import { fileToBytes } from '../../util/fileToBytes';
import * as log from '../../logging/log';
import { SmartToastManager } from './ToastManager';
type PropsType = ComponentProps<typeof InstallScreen>; type PropsType = ComponentProps<typeof InstallScreen>;
type StateType =
| {
step: InstallScreenStep.Error;
error: InstallError;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
provisioningUrl: Loadable<string, LoadError>;
}
| {
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() { export const SmartInstallScreen = memo(function SmartInstallScreen() {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const installerState = useSelector(getInstallerState);
const updates = useSelector(getUpdatesState); const updates = useSelector(getUpdatesState);
const { startInstaller, finishInstall } = useInstallerActions();
const { startUpdate } = useUpdatesActions(); const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector); const hasExpired = useSelector(hasExpiredSelector);
const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>()); const [deviceName, setDeviceName] = useState<string>('');
const chooseBackupFilePromiseWrapperRef = const [backupFile, setBackupFile] = useState<File | undefined>();
useRef(explodePromise<File | undefined>());
const [state, setState] = useState<StateType>(INITIAL_STATE); const onSubmitDeviceName = useCallback(async () => {
const [retryCounter, setRetryCounter] = useState(0); if (backupFile != null) {
// This is only for testing so don't bother catching errors
const setProvisioningUrl = useCallback( finishInstall({ deviceName, backupFile: await fileToBytes(backupFile) });
(value: string) => { } else {
setState(currentState => { finishInstall({ deviceName, backupFile: undefined });
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;
} }
}, [backupFile, deviceName, finishInstall]);
let deviceName: string = normalizeDeviceName(state.deviceName); const suggestedDeviceName =
if (!deviceName.length) { installerState.step === InstallScreenStep.ChoosingDeviceName
// This should be impossible, but we have it here just in case. ? installerState.deviceName
assertDev( : undefined;
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]);
useEffect(() => { useEffect(() => {
let hasCleanedUp = false; setDeviceName(suggestedDeviceName ?? '');
}, [suggestedDeviceName]);
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<void> {
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]);
let props: PropsType; let props: PropsType;
switch (state.step) { switch (installerState.step) {
case InstallScreenStep.Error: case InstallScreenStep.NotStarted:
props = { log.error('InstallScreen: Installer not started');
step: InstallScreenStep.Error, return null;
screenSpecificProps: {
i18n,
error: state.error,
quit: () => window.IPC.shutdown(),
tryAgain: () => {
setRetryCounter(count => count + 1);
setState(INITIAL_STATE);
},
},
};
break;
case InstallScreenStep.QrCodeNotScanned: case InstallScreenStep.QrCodeNotScanned:
props = { props = {
step: InstallScreenStep.QrCodeNotScanned, step: InstallScreenStep.QrCodeNotScanned,
screenSpecificProps: { screenSpecificProps: {
i18n, i18n,
provisioningUrl: state.provisioningUrl, provisioningUrl: installerState.provisioningUrl,
hasExpired, hasExpired,
updates, updates,
currentVersion: window.getVersion(), currentVersion: window.getVersion(),
startUpdate, startUpdate,
retryGetQrCode: () => { retryGetQrCode: startInstaller,
setRetryCounter(count => count + 1);
setState(INITIAL_STATE);
},
OS: OS.getName(), OS: OS.getName(),
}, },
}; };
@ -369,7 +78,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
step: InstallScreenStep.ChoosingDeviceName, step: InstallScreenStep.ChoosingDeviceName,
screenSpecificProps: { screenSpecificProps: {
i18n, i18n,
deviceName: state.deviceName, deviceName,
setDeviceName, setDeviceName,
setBackupFile, setBackupFile,
onSubmit: onSubmitDeviceName, onSubmit: onSubmitDeviceName,
@ -382,8 +91,29 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
screenSpecificProps: { i18n }, screenSpecificProps: { i18n },
}; };
break; 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: default:
throw missingCaseError(state); throw missingCaseError(installerState);
} }
return ( return (

View file

@ -15,6 +15,7 @@ import type { actions as emojis } from './ducks/emojis';
import type { actions as expiration } from './ducks/expiration'; import type { actions as expiration } from './ducks/expiration';
import type { actions as globalModals } from './ducks/globalModals'; import type { actions as globalModals } from './ducks/globalModals';
import type { actions as inbox } from './ducks/inbox'; 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 items } from './ducks/items';
import type { actions as lightbox } from './ducks/lightbox'; import type { actions as lightbox } from './ducks/lightbox';
import type { actions as linkPreviews } from './ducks/linkPreviews'; import type { actions as linkPreviews } from './ducks/linkPreviews';
@ -45,6 +46,7 @@ export type ReduxActions = {
expiration: typeof expiration; expiration: typeof expiration;
globalModals: typeof globalModals; globalModals: typeof globalModals;
inbox: typeof inbox; inbox: typeof inbox;
installer: typeof installer;
items: typeof items; items: typeof items;
lightbox: typeof lightbox; lightbox: typeof lightbox;
linkPreviews: typeof linkPreviews; linkPreviews: typeof linkPreviews;

View file

@ -8,12 +8,14 @@ import {
import { linkDeviceRoute } from '../util/signalRoutes'; import { linkDeviceRoute } from '../util/signalRoutes';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { normalizeAci } from '../util/normalizeAci'; 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 { import {
isUntaggedPniString, isUntaggedPniString,
normalizePni, normalizePni,
toTaggedPni, toTaggedPni,
} from '../types/ServiceId'; } from '../types/ServiceId';
import * as Errors from '../types/errors';
import { SignalService as Proto } from '../protobuf'; import { SignalService as Proto } from '../protobuf';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -204,7 +206,10 @@ export class Provisioner {
aciKeyPair, aciKeyPair,
pniKeyPair, pniKeyPair,
profileKey, profileKey,
deviceName, deviceName: normalizeDeviceName(deviceName).slice(
0,
MAX_DEVICE_NAME_LENGTH
),
backupFile, backupFile,
isPlaintextBackup, isPlaintextBackup,
userAgent, userAgent,

View file

@ -3669,6 +3669,7 @@ export function initialize({
currentBytes += chunk.byteLength; currentBytes += chunk.byteLength;
onProgress(currentBytes, totalBytes); onProgress(currentBytes, totalBytes);
}); });
onProgress(0, totalBytes);
} }
return combinedStream; return combinedStream;

31
ts/types/InstallScreen.ts Normal file
View file

@ -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;

View file

@ -3088,21 +3088,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2023-08-20T22:14:52.008Z" "updated": "2023-08-20T22:14:52.008Z"
}, },
{
"rule": "React-useRef",
"path": "ts/state/smart/InstallScreen.tsx",
"line": " const chooseDeviceNamePromiseWrapperRef = useRef(explodePromise<string>());",
"reasonCategory": "testCode",
"updated": "2023-11-16T23:39:21.322Z"
},
{
"rule": "React-useRef",
"path": "ts/state/smart/InstallScreen.tsx",
"line": " useRef(explodePromise<File | undefined>());",
"reasonCategory": "usageTrusted",
"updated": "2021-12-06T23:07:28.947Z",
"reasonDetail": "Doesn't touch the DOM."
},
{ {
"rule": "DOM-innerHTML", "rule": "DOM-innerHTML",
"path": "ts/windows/loading/start.ts", "path": "ts/windows/loading/start.ts",