Make backup import UI part of install

This commit is contained in:
Fedor Indutny 2024-09-03 19:56:13 -07:00 committed by GitHub
parent 6bd9502f5c
commit ee0090bb84
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 * 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<void> {
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<void> {
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<void> {
afterStart();
}
const backupReady = explodePromise<void>();
function afterStart() {
strictAssert(messageReceiver, 'messageReceiver must be initialized');
strictAssert(server, 'server must be initialized');
@ -1553,13 +1556,17 @@ export async function startApp(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
}
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 () => {

View file

@ -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<string>;
@ -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 = <BackupImportScreen i18n={i18n} {...state} />;
} else {
throw missingCaseError(state);
throw missingCaseError(state.appView);
}
// 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 { 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<typeof InstallScreenErrorStep>;
}
| {
step: InstallScreenStep.QrCodeNotScanned;
screenSpecificProps: ComponentProps<
@ -42,6 +33,14 @@ type PropsType =
screenSpecificProps: ComponentProps<
typeof InstallScreenLinkInProgressStep
>;
}
| {
step: InstallScreenStep.BackupImport;
screenSpecificProps: ComponentProps<typeof InstallScreenBackupImportStep>;
}
| {
step: InstallScreenStep.Error;
screenSpecificProps: ComponentProps<typeof InstallScreenErrorStep>;
};
export function InstallScreen(props: Readonly<PropsType>): ReactElement {
@ -58,6 +57,8 @@ export function InstallScreen(props: Readonly<PropsType>): ReactElement {
);
case InstallScreenStep.LinkInProgress:
return <InstallScreenLinkInProgressStep {...props.screenSpecificProps} />;
case InstallScreenStep.BackupImport:
return <InstallScreenBackupImportStep {...props.screenSpecificProps} />;
default:
throw missingCaseError(props);
}

View file

@ -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<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = (args: PropsType) => (
<BackupImportScreen {...args} i18n={i18n} />
<InstallScreenBackupImportStep {...args} i18n={i18n} />
);
export const NoBytes = Template.bind({});

View file

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

View file

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

View file

@ -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 => (
<InstallScreenErrorStep
{...defaultProps}
error={InstallError.TooManyDevices}
error={InstallScreenError.TooManyDevices}
/>
);
export const _TooOld = (): JSX.Element => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} />
<InstallScreenErrorStep {...defaultProps} error={InstallScreenError.TooOld} />
);
export const __TooOld = (): JSX.Element => (
<InstallScreenErrorStep {...defaultProps} error={InstallError.TooOld} />
<InstallScreenErrorStep {...defaultProps} error={InstallScreenError.TooOld} />
);
export const _ConnectionFailed = (): JSX.Element => (
<InstallScreenErrorStep
{...defaultProps}
error={InstallError.ConnectionFailed}
error={InstallScreenError.ConnectionFailed}
/>
);

View file

@ -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 = () => {

View file

@ -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<string, LoadError>;
finalResult: Loadable<string, InstallScreenQRCodeError>;
}) {
const [provisioningUrl, setProvisioningUrl] = useState<
Loadable<string, LoadError>
Loadable<string, InstallScreenQRCodeError>
>({
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 {
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: LoadError.Unknown,
error: InstallScreenQRCodeError.Unknown,
}}
/>
);
@ -137,7 +135,7 @@ export function SimulatedNetworkIssue(): JSX.Element {
<Simulation
finalResult={{
loadingState: LoadingState.LoadFailed,
error: LoadError.NetworkIssue,
error: InstallScreenQRCodeError.NetworkIssue,
}}
/>
);
@ -148,7 +146,7 @@ export function SimulatedTimeout(): JSX.Element {
<Simulation
finalResult={{
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 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<string, LoadError>;
provisioningUrl: Loadable<string, InstallScreenQRCodeError>;
hasExpired?: boolean;
updates: UpdatesStateType;
currentVersion: string;
@ -121,7 +116,7 @@ export function InstallScreenQrCodeNotScannedStep({
}
function InstallScreenQrCode(
props: Loadable<string, LoadError> & {
props: Loadable<string, InstallScreenQRCodeError> & {
i18n: LocalizerType;
retryGetQrCode: () => void;
}
@ -135,7 +130,7 @@ function InstallScreenQrCode(
break;
case LoadingState.LoadFailed:
switch (props.error) {
case LoadError.Timeout:
case InstallScreenQRCodeError.Timeout:
contents = (
<>
<span
@ -147,7 +142,7 @@ function InstallScreenQrCode(
</>
);
break;
case LoadError.Unknown:
case InstallScreenQRCodeError.Unknown:
contents = (
<>
<span
@ -163,7 +158,7 @@ function InstallScreenQrCode(
</>
);
break;
case LoadError.NetworkIssue:
case InstallScreenQRCodeError.NetworkIssue:
contents = (
<>
<span

View file

@ -10,7 +10,7 @@ import { isNumber } from 'lodash';
import { CallLinkRootKey } from '@signalapp/ringrtc';
import { Backups, SignalService } from '../../protobuf';
import { DataWriter } from '../../sql/Client';
import { DataReader, DataWriter } from '../../sql/Client';
import {
AttachmentDownloadSource,
type StoryDistributionWithMembersType,
@ -78,7 +78,7 @@ import type { GroupV2ChangeDetailType } from '../../groups';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { drop } from '../../util/drop';
import { isNotNil } from '../../util/isNotNil';
import { isGroup, isGroupV2 } from '../../util/whatTypeOfConversation';
import { isGroup } from '../../util/whatTypeOfConversation';
import { rgbToHSL } from '../../util/rgbToHSL';
import {
convertBackupMessageAttachmentToAttachment,
@ -88,6 +88,7 @@ import { filterAndClean } from '../../types/BodyRange';
import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME';
import { copyFromQuotedMessage } from '../../messages/copyQuote';
import { groupAvatarJobQueue } from '../../jobs/groupAvatarJobQueue';
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
import {
AdhocCallStatus,
CallDirection,
@ -99,9 +100,10 @@ import {
import type { CallHistoryDetails } from '../../types/CallDisposition';
import { CallLinkRestrictions } from '../../types/CallLink';
import type { CallLinkType } from '../../types/CallLink';
import { fromAdminKeyBytes } from '../../util/callLinks';
import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc';
import { reinitializeRedux } from '../../state/reinitializeRedux';
import { getParametersForRedux, loadAll } from '../allLoaders';
const MAX_CONCURRENCY = 10;
@ -283,11 +285,21 @@ export class BackupImportStream extends Writable {
private customColorById = new Map<number, CustomColorDataType>();
private releaseNotesRecipientId: Long | undefined;
private releaseNotesChatId: Long | undefined;
private pendingGroupAvatars = new Map<string, string>();
constructor() {
private constructor() {
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(
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;
}

View file

@ -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<DownloadOptionsType, 'downloadOffset'>
): Promise<void> {
): Promise<boolean> {
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<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> {
log.info('fetchAndSaveBackupCdnObjectMetadata: clearing existing metadata');
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 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,

View file

@ -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<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<
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<AppStateType> = getEmptyState(),
action: Readonly<AppActionType>
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,
};
}

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 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(),

View file

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

View file

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

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,
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 (
<App
i18n={i18n}
state={state}
isMaximized={isMaximized}
isFullScreen={isFullScreen}

View file

@ -2,364 +2,73 @@
// SPDX-License-Identifier: AGPL-3.0-only
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 pTimeout, { TimeoutError } from 'p-timeout';
import { getIntl } from '../selectors/user';
import { getUpdatesState } from '../selectors/updates';
import { getInstallerState } from '../selectors/installer';
import { useInstallerActions } from '../ducks/installer';
import { useUpdatesActions } from '../ducks/updates';
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 * as Registration from '../../util/registration';
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 { InstallScreen } from '../../components/InstallScreen';
import { WidthBreakpoint } from '../../components/_util';
import { HTTPError } from '../../textsecure/Errors';
import { isRecord } from '../../util/isRecord';
import { Provisioner } from '../../textsecure/Provisioner';
import * as Errors from '../../types/errors';
import { normalizeDeviceName } from '../../util/normalizeDeviceName';
import { InstallScreenStep } from '../../types/InstallScreen';
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 * as log from '../../logging/log';
import { SmartToastManager } from './ToastManager';
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() {
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<string>());
const chooseBackupFilePromiseWrapperRef =
useRef(explodePromise<File | undefined>());
const [deviceName, setDeviceName] = useState<string>('');
const [backupFile, setBackupFile] = useState<File | undefined>();
const [state, setState] = useState<StateType>(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<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]);
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 (

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

View file

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

View file

@ -3669,6 +3669,7 @@ export function initialize({
currentBytes += chunk.byteLength;
onProgress(currentBytes, totalBytes);
});
onProgress(0, totalBytes);
}
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",
"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",
"path": "ts/windows/loading/start.ts",