Standalone Registration: Full screen update flow and fixes

This commit is contained in:
Scott Nonnenberg
2026-08-27 07:26:48 +10:00
committed by GitHub
parent a5a41f62a3
commit 06ba84cfaa
21 changed files with 612 additions and 363 deletions
+22 -6
View File
@@ -9192,13 +9192,29 @@
"messageformat": "Update Required",
"description": "The title of update dialog on install screen when app update is required before proceeding with backup import"
},
"icu:InstallScreenUpdateDialog--checking-for-updates": {
"messageformat": "Checking for updates",
"description": "Text shown with an indeterminate spinner while Signal Desktop checks the internet for newer versions"
},
"icu:InstallScreenUpdateDialog--updating-signal": {
"messageformat": "Updating Signal",
"description": "Text shown with a progress bar as Signal Desktop is downloading an updated version"
},
"icu:InstallScreenUpdateDialog--download-progress": {
"messageformat": "Downloading {currentBytes} of {totalBytes} ({percentage, number, percent})...",
"description": "Shown below progress bar to provide the details of download. currentBytes and totalBytes will be like 5 GB or 10 GB."
},
"icu:InstallScreenUpdateDialog--update-required__body": {
"messageformat": "To complete syncing your messages, update Signal desktop now.",
"description": "The body of update dialog on install screen when app update is required before proceeding with backup import"
"messageformat": "To continue, update Signal Desktop.",
"description": "The body of update dialog on install screen when app update is required before proceeding"
},
"icu:InstallScreenUpdateDialog--update-required__action-update": {
"messageformat": "Update",
"description": "The update action of update dialog on install screen when app update is required before proceeding with backup import"
"description": "The update action of update dialog on install screen when app update is required"
},
"icu:InstallScreenUpdateDialog--update-required__action-update__mas": {
"messageformat": "Go to the App Store",
"description": "The update action of update dialog on install screen when update is required, but app was installed via Mac App Store"
},
"icu:InstallScreenUpdateDialog--unsupported-os__title": {
"messageformat": "Update Required",
@@ -10835,15 +10851,15 @@
"description": "Toast message when Signal PIN reminder was completed successfully."
},
"icu:UnsupportedOSWarningDialog__body": {
"messageformat": "Signal desktop will no longer support your computers version of {OS} soon. To keep using Signal, update your computers operating system by {expirationDate}. <learnMoreLink>Learn more</learnMoreLink>",
"messageformat": "Signal Desktop will no longer support your computers version of {OS} soon. To keep using Signal, update your computers operating system by {expirationDate}. <learnMoreLink>Learn more</learnMoreLink>",
"description": "Body of a dialog displayed on unsupported operating systems"
},
"icu:UnsupportedOSErrorDialog__body": {
"messageformat": "Signal desktop no longer works on this computer. To use Signal desktop again, update your computers version of {OS}. <learnMoreLink>Learn more</learnMoreLink>",
"messageformat": "Signal Desktop no longer works on this computer. To use Signal Desktop again, update your computers version of {OS}. <learnMoreLink>Learn more</learnMoreLink>",
"description": "Body of a dialog displayed on unsupported operating systems"
},
"icu:UnsupportedOSErrorToast": {
"messageformat": "Signal desktop no longer works on this computer. To use Signal desktop again, update your computers version of {OS}.",
"messageformat": "Signal Desktop no longer works on this computer. To use Signal Desktop again, update your computers version of {OS}.",
"description": "Body of a dialog displayed on unsupported operating systems"
},
"icu:MessageMetadata__edited": {
@@ -1,50 +1,20 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
@use '../variables';
.InstallScreenUpdateDialog {
position: relative;
display: flex;
width: 100%;
height: 100vh;
flex-direction: column;
align-items: center;
&__download-size {
font-weight: 400;
}
&__progress {
&--container {
@include mixins.light-theme() {
background-color: variables.$color-gray-15;
}
@include mixins.dark-theme() {
background-color: variables.$color-gray-65;
}
& {
border-radius: 2px;
height: 4px;
overflow: hidden;
width: 100%;
margin-block: 16px;
margin-inline: 0;
}
}
&--bar {
background-color: variables.$color-ultramarine;
border-radius: 2px;
display: block;
height: 100%;
width: 100%;
&:dir(ltr) {
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
transform: translateX(-100%);
}
&:dir(rtl) {
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
transform: translateX(100%);
}
transition: transform 500ms ease-out;
}
}
a {
// Prevent breaking the text
display: inline-block;
+6
View File
@@ -1623,11 +1623,17 @@ async function startApp(): Promise<void> {
if (
registrationPartialState === PartialRegistrationType.EXISTING__PIN
) {
StorageService.disableStorageService(
'EXISTING__PIN: Need to get PIN before we turn it on'
);
window.reduxActions.standaloneInstaller.goToVerifyPINStage();
window.reduxActions.app.openStandalone(startFromBeginning);
} else if (
registrationPartialState === PartialRegistrationType.EXISTING__PROFILE
) {
StorageService.disableStorageService(
'EXISTING__PROFILE: Need to get PIN before we turn it on'
);
const hasPin = true;
window.reduxActions.standaloneInstaller.goToProfileEntryStage(
hasPin,
@@ -1,10 +1,9 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useState, useCallback, useEffect, type JSX } from 'react';
import { useState, useEffect, type JSX } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { sleep } from '../../util/sleep.std.ts';
import {
InstallScreenBackupStep,
InstallScreenBackupError,
@@ -15,46 +14,18 @@ import { InstallScreenBackupImportStep } from './InstallScreenBackupImportStep.d
const { i18n } = window.SignalContext;
const DEFAULT_UPDATES = {
const updates = {
dialogType: DialogType.None,
didSnooze: false,
isCheckingForUpdates: false,
showEventsCount: 0,
downloadSize: 42 * 1024 * 1024,
};
export default {
title: 'Components/InstallScreenBackupImportStep',
title: 'Components/InstallScreen/InstallScreenBackupImportStep',
} satisfies Meta<PropsType>;
const Template: StoryFn<PropsType> = (args: PropsType) => {
const [updates, setUpdates] = useState(DEFAULT_UPDATES);
const forceUpdate = useCallback(async () => {
setUpdates(state => ({
...state,
isCheckingForUpdates: true,
}));
await sleep(500);
setUpdates(state => ({
...state,
isCheckingForUpdates: false,
dialogType: DialogType.Downloading,
downloadSize: 100,
downloadedSize: 0,
version: 'v7.7.7',
}));
await sleep(500);
setUpdates(state => ({
...state,
downloadedSize: 50,
}));
await sleep(500);
setUpdates(state => ({
...state,
downloadedSize: 100,
}));
}, [setUpdates]);
return (
<InstallScreenBackupImportStep
{...args}
@@ -63,7 +34,7 @@ const Template: StoryFn<PropsType> = (args: PropsType) => {
currentVersion="v6.0.0"
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={forceUpdate}
forceCheck={action('forceCheck')}
onCancel={action('onCancel')}
onRetry={action('onRetry')}
/>
@@ -94,11 +65,11 @@ export function FullFlow(): JSX.Element {
return (
<InstallScreenBackupImportStep
i18n={i18n}
updates={DEFAULT_UPDATES}
updates={updates}
currentVersion="v6.0.0"
OS="macOS"
startUpdate={action('startUpdate')}
forceUpdate={action('forceUpdate')}
forceCheck={action('forceCheck')}
onCancel={action('onCancel')}
onRetry={action('onRetry')}
currentBytes={currentBytes}
@@ -6,7 +6,6 @@ import { useState, useCallback, type JSX } from 'react';
import type { LocalizerType } from '../../types/Util.std.ts';
import type { UpdatesStateType } from '../../state/ducks/updates.preload.ts';
import {
InstallScreenStep,
InstallScreenBackupStep,
InstallScreenBackupError,
} from '../../types/InstallScreen.std.ts';
@@ -37,7 +36,7 @@ export type PropsType = Readonly<
currentVersion: string;
OS: string;
startUpdate: () => void;
forceUpdate: () => void;
forceCheck: () => void;
} & (
| {
backupStep: InstallScreenBackupStep.WaitForBackup;
@@ -63,7 +62,7 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
currentVersion,
OS,
startUpdate,
forceUpdate,
forceCheck,
} = props;
const [isConfirmingCancel, setIsConfirmingCancel] = useState(false);
@@ -92,13 +91,12 @@ export function InstallScreenBackupImportStep(props: PropsType): JSX.Element {
if (error == null || error === InstallScreenBackupError.Canceled) {
// no-op
} else if (error === InstallScreenBackupError.UnsupportedVersion) {
errorElem = (
return (
<InstallScreenUpdateDialog
i18n={i18n}
{...updates}
step={InstallScreenStep.BackupImport}
startUpdate={startUpdate}
forceUpdate={forceUpdate}
forceCheck={forceCheck}
currentVersion={currentVersion}
onClose={confirmCancel}
OS={OS}
@@ -35,7 +35,7 @@ const DEFAULT_PROPS: Omit<PropsType, 'provisioningUrl'> = {
updates: DEFAULT_UPDATES,
OS: 'macOS',
startUpdate: action('startUpdate'),
forceUpdate: action('forceUpdate'),
forceCheck: action('forceCheck'),
currentVersion: 'v6.0.0',
retryGetQrCode: action('retryGetQrCode'),
isConfirmingDataDeletion: false,
@@ -7,10 +7,7 @@ import classNames from 'classnames';
import lodash from 'lodash';
import type { LocalizerType } from '../../types/Util.std.ts';
import {
InstallScreenStep,
InstallScreenQRCodeError,
} from '../../types/InstallScreen.std.ts';
import { InstallScreenQRCodeError } from '../../types/InstallScreen.std.ts';
import { DialogType } from '../../types/Dialogs.std.ts';
import { missingCaseError } from '../../util/missingCaseError.std.ts';
import type { Loadable } from '../../util/loadable.std.ts';
@@ -43,7 +40,7 @@ export type PropsType = Readonly<{
isStaging: boolean;
retryGetQrCode: () => void;
startUpdate: () => void;
forceUpdate: () => void;
forceCheck: () => void;
isConfirmingDataDeletion: boolean;
continueInstallWithDataDeletion: () => void;
restartInstall: () => void;
@@ -65,30 +62,31 @@ export function InstallScreenQrCodeNotScannedStep({
provisioningUrl,
retryGetQrCode,
startUpdate,
forceUpdate,
forceCheck,
isConfirmingDataDeletion,
restartInstall,
continueInstallWithDataDeletion,
updates,
}: Readonly<PropsType>): ReactElement {
if (hasExpired || updates.dialogType === DialogType.Downloading) {
return (
<InstallScreenUpdateDialog
i18n={i18n}
{...updates}
startUpdate={startUpdate}
forceCheck={forceCheck}
currentVersion={currentVersion}
OS={OS}
/>
);
}
return (
<div className="module-InstallScreenQrCodeNotScannedStep">
<TitlebarDragArea />
<InstallScreenSignalLogo />
{(hasExpired || updates.dialogType === DialogType.Downloading) && (
<InstallScreenUpdateDialog
i18n={i18n}
{...updates}
step={InstallScreenStep.QrCodeNotScanned}
startUpdate={startUpdate}
forceUpdate={forceUpdate}
currentVersion={currentVersion}
OS={OS}
/>
)}
<div className="module-InstallScreenQrCodeNotScannedStep__contents">
<InstallScreenQrCode
i18n={i18n}
@@ -1,17 +1,15 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import {
CannotUpdateMacOsReadOnlyModal,
UpdateDownloadingModal,
UnsupportedOSModal,
UpdateRequiredModal,
CannotUpdateModal,
UpdateAvailableModal,
UpdateDownloadedModal,
} from './InstallScreenUpdateDialog.dom.tsx';
import type { ReactNode } from 'react';
import { InstallScreenUpdateDialog } from './InstallScreenUpdateDialog.dom.tsx';
import { action } from '@storybook/addon-actions';
import { DialogType } from '../../types/Dialogs.std.ts';
import type { PropsType } from './InstallScreenUpdateDialog.dom.tsx';
import { useCallback, useState, type ReactNode } from 'react';
import { sleep } from '../../util/sleep.std.ts';
const { i18n } = window.SignalContext;
@@ -19,73 +17,184 @@ export default {
title: 'Components/InstallScreen/InstallScreenUpdateDialog',
} satisfies Meta;
export function UpdateRequired(): ReactNode {
function getDefaultProps(): PropsType {
return {
dialogType: DialogType.None,
didSnooze: false,
showEventsCount: 0,
isCheckingForUpdates: false,
i18n,
forceCheck: action('forceCheck'),
startUpdate: action('startUpdate'),
currentVersion: 'v1.0.0',
OS: 'macos',
};
}
export function _1NoDialogNotCheckingForUpdates(): ReactNode {
return <InstallScreenUpdateDialog {...getDefaultProps()} />;
}
export function _2IsCheckingForUpdates(): ReactNode {
return (
<UpdateRequiredModal
i18n={i18n}
onClose={action('onClose')}
onAction={action('onAction')}
<InstallScreenUpdateDialog {...getDefaultProps()} isCheckingForUpdates />
);
}
export function _3ErrorUnsupportedOS(): ReactNode {
return (
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.UnsupportedOS}
/>
);
}
export function UpdateAvailableNotReady(): ReactNode {
export function _3ErrorMASUpdate(): ReactNode {
return (
<UpdateAvailableModal
i18n={i18n}
onClose={action('onClose')}
onStartUpdate={action('onStartUpdate')}
downloadReady={false}
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.MASUpdate}
/>
);
}
export function UpdateAvailableAndReady(): ReactNode {
export function _3DownloadReady(): ReactNode {
return (
<UpdateAvailableModal
i18n={i18n}
onClose={action('onClose')}
onStartUpdate={action('onStartUpdate')}
downloadSize={100}
downloadReady
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.DownloadReady}
downloadSize={100_000}
/>
);
}
export function UpdateDownloading(): ReactNode {
return <UpdateDownloadingModal i18n={i18n} progress={25} />;
}
export function UpdateDownloaded(): ReactNode {
export function _3FullDownloadReady(): ReactNode {
return (
<UpdateDownloadedModal
i18n={i18n}
onClose={action('onClose')}
onStartUpdate={action('onStartUpdate')}
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.FullDownloadReady}
downloadSize={100_000_000}
/>
);
}
export function UnsupportedOS(): ReactNode {
export function _3AutoUpdate(): ReactNode {
return (
<UnsupportedOSModal i18n={i18n} onClose={action('onClose')} OS="macOS" />
);
}
export function CannotUpdate(): ReactNode {
return (
<CannotUpdateModal
i18n={i18n}
onClose={action('onClose')}
currentVersion="0.0.0"
needsManualUpdate
onStartUpdate={action('onStartUpdate')}
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.AutoUpdate}
/>
);
}
export function CannotUpdateMacOsReadOnly(): ReactNode {
export function _4Downloading(): ReactNode {
return (
<CannotUpdateMacOsReadOnlyModal i18n={i18n} onClose={action('onClose')} />
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.Downloading}
downloadedSize={50_000_000}
downloadSize={100_000_000}
/>
);
}
export function _5DownloadedUpdate(): ReactNode {
return (
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.DownloadedUpdate}
/>
);
}
export function _6ErrorCannotUpdate(): ReactNode {
return (
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.Cannot_Update}
/>
);
}
export function _6ErrorCannot_Update_Require_Manual(): ReactNode {
return (
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.Cannot_Update_Require_Manual}
/>
);
}
export function _6ErrorMacosReadOnly(): ReactNode {
return (
<InstallScreenUpdateDialog
{...getDefaultProps()}
dialogType={DialogType.MacOS_Read_Only}
/>
);
}
export function Flow(): ReactNode {
const [updates, setUpdates] = useState({
dialogType: DialogType.None,
didSnooze: false,
isCheckingForUpdates: false,
showEventsCount: 0,
downloadSize: 42 * 1024 * 1024,
});
const forceCheck = useCallback(async () => {
setUpdates(state => ({
...state,
isCheckingForUpdates: true,
}));
await sleep(2000);
setUpdates(state => ({
...state,
isCheckingForUpdates: false,
dialogType: DialogType.DownloadReady,
downloadSize: 30_000_000,
downloadedSize: 0,
version: 'v7.7.7',
}));
}, [setUpdates]);
const startUpdate = useCallback(async () => {
if (updates.dialogType === DialogType.DownloadedUpdate) {
// oxlint-disable-next-line no-console
console.log('Restarting!');
return;
}
setUpdates(state => ({
...state,
dialogType: DialogType.Downloading,
downloadSize: 30_000_000,
downloadedSize: 0,
version: 'v7.7.7',
}));
await sleep(1000);
setUpdates(state => ({
...state,
downloadedSize: 15_000_000,
}));
await sleep(1000);
setUpdates(state => ({
...state,
downloadedSize: 30_000_000,
}));
await sleep(500);
setUpdates(state => ({
...state,
dialogType: DialogType.DownloadedUpdate,
}));
}, [updates, setUpdates]);
return (
<InstallScreenUpdateDialog
{...getDefaultProps()}
{...updates}
forceCheck={forceCheck}
startUpdate={startUpdate}
/>
);
}
@@ -2,8 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { JSX, ReactNode } from 'react';
import { isNumber } from 'lodash';
import { DialogType } from '../../types/Dialogs.std.ts';
import { InstallScreenStep } from '../../types/InstallScreen.std.ts';
import type { LocalizerType } from '../../types/Util.std.ts';
import {
PRODUCTION_DOWNLOAD_URL,
@@ -17,14 +18,15 @@ import { roundFractionForProgressBar } from '../../util/numbers.std.ts';
import { I18n } from '../I18n.dom.tsx';
import { formatFileSize } from '../../util/formatFileSize.std.ts';
import { AxoConfirmDialog } from '../../axo/AxoConfirmDialog.dom.tsx';
import { AxoDialog } from '../../axo/AxoDialog.dom.tsx';
import { tw } from '../../axo/tw.dom.tsx';
import { TitlebarDragArea } from '../TitlebarDragArea.dom.tsx';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo.dom.tsx';
import { ProgressBar } from '../ProgressBar.dom.tsx';
export type PropsType = UpdatesStateType &
Readonly<{
i18n: LocalizerType;
step: InstallScreenStep;
forceUpdate: () => void;
forceCheck: () => void;
startUpdate: () => void;
currentVersion: string;
OS: string;
@@ -33,66 +35,72 @@ export type PropsType = UpdatesStateType &
export function InstallScreenUpdateDialog({
i18n,
step,
dialogType,
isCheckingForUpdates,
downloadSize,
downloadedSize,
forceUpdate,
forceCheck,
startUpdate,
currentVersion,
OS,
onClose = () => null,
}: PropsType): JSX.Element | null {
if (dialogType === DialogType.None) {
if (step === InstallScreenStep.BackupImport) {
if (isCheckingForUpdates) {
return <UpdateDownloadingModal i18n={i18n} progress={0} />;
}
let modal: ReactNode | undefined = undefined;
let inlineElement: ReactNode | undefined = undefined;
return (
if (dialogType === DialogType.None) {
if (isCheckingForUpdates) {
inlineElement = (
<CenteredElement>
<div className={tw('mb-[17px] w-82')}>
<div
className={tw('mb-4 text-center type-title-medium font-semibold')}
>
{i18n('icu:InstallScreenUpdateDialog--checking-for-updates')}
</div>
<ProgressBar
fractionComplete={null}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
</div>
</CenteredElement>
);
} else {
modal = (
<UpdateRequiredModal
isMas={false}
i18n={i18n}
onClose={onClose}
onAction={forceUpdate}
onAction={forceCheck}
/>
);
}
return null;
}
if (dialogType === DialogType.UnsupportedOS) {
return <UnsupportedOSModal i18n={i18n} onClose={onClose} OS={OS} />;
}
if (dialogType === DialogType.MASUpdate) {
return (
} else if (dialogType === DialogType.UnsupportedOS) {
modal = <UnsupportedOSModal i18n={i18n} onClose={onClose} OS={OS} />;
} else if (dialogType === DialogType.MASUpdate) {
modal = (
<UpdateRequiredModal
isMas
i18n={i18n}
onClose={onClose}
onAction={startUpdate}
/>
);
}
if (dialogType === DialogType.DownloadedUpdate) {
return (
} else if (dialogType === DialogType.DownloadedUpdate) {
modal = (
<UpdateDownloadedModal
i18n={i18n}
onClose={onClose}
onStartUpdate={startUpdate}
/>
);
}
if (
} else if (
dialogType === DialogType.AutoUpdate ||
// Manual update with an action button
dialogType === DialogType.DownloadReady ||
dialogType === DialogType.FullDownloadReady
) {
return (
modal = (
<UpdateAvailableModal
i18n={i18n}
onClose={onClose}
@@ -104,22 +112,37 @@ export function InstallScreenUpdateDialog({
}
/>
);
}
if (dialogType === DialogType.Downloading) {
const fractionComplete = roundFractionForProgressBar(
(downloadedSize || 0) / (downloadSize || 1)
} else if (dialogType === DialogType.Downloading) {
const fractionComplete = downloadSize
? roundFractionForProgressBar((downloadedSize || 0) / downloadSize)
: null;
inlineElement = (
<CenteredElement>
<div className={tw('mb-4 text-center type-title-medium font-semibold')}>
{i18n('icu:InstallScreenUpdateDialog--updating-signal')}
</div>
<div className={tw('mb-[17px] w-82')}>
<ProgressBar
fractionComplete={fractionComplete}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
</div>
{isNumber(fractionComplete) ? (
<div className={tw('mb-1.5 text-center type-caption font-medium')}>
{i18n('icu:InstallScreenUpdateDialog--download-progress', {
currentBytes: formatFileSize(downloadedSize ?? 0),
totalBytes: formatFileSize(downloadSize ?? 1),
percentage: fractionComplete,
})}
</div>
) : undefined}
</CenteredElement>
);
return (
<UpdateDownloadingModal i18n={i18n} progress={fractionComplete * 100} />
);
}
if (
} else if (
dialogType === DialogType.Cannot_Update ||
dialogType === DialogType.Cannot_Update_Require_Manual
) {
return (
modal = (
<CannotUpdateModal
i18n={i18n}
onClose={onClose}
@@ -130,17 +153,36 @@ export function InstallScreenUpdateDialog({
onStartUpdate={startUpdate}
/>
);
} else if (dialogType === DialogType.MacOS_Read_Only) {
modal = <CannotUpdateMacOsReadOnlyModal i18n={i18n} onClose={onClose} />;
} else {
throw missingCaseError(dialogType);
}
if (dialogType === DialogType.MacOS_Read_Only) {
return <CannotUpdateMacOsReadOnlyModal i18n={i18n} onClose={onClose} />;
}
throw missingCaseError(dialogType);
return (
<div className="InstallScreenUpdateDialog">
<TitlebarDragArea />
<InstallScreenSignalLogo />
{modal}
{inlineElement}
</div>
);
}
/** @testexport */
export function UpdateRequiredModal(props: {
function CenteredElement({ children }: { children: ReactNode }): JSX.Element {
return (
<div
className={tw(
'absolute inset-s-1/2 top-1/2 max-w-[calc(100%-32px)] -translate-1/2'
)}
>
{children}
</div>
);
}
function UpdateRequiredModal(props: {
isMas: boolean;
i18n: LocalizerType;
onClose: () => void;
onAction: () => void;
@@ -157,14 +199,19 @@ export function UpdateRequiredModal(props: {
variant="strong-primary"
onClick={props.onAction}
>
{i18n('icu:InstallScreenUpdateDialog--update-required__action-update')}
{props.isMas
? i18n(
'icu:InstallScreenUpdateDialog--update-required__action-update__mas'
)
: i18n(
'icu:InstallScreenUpdateDialog--update-required__action-update'
)}
</AxoConfirmDialog.Action>
</AxoConfirmDialog.Root>
);
}
/** @testexport */
export function UpdateAvailableModal(props: {
function UpdateAvailableModal(props: {
i18n: LocalizerType;
onClose: () => void;
onStartUpdate: () => void;
@@ -206,38 +253,7 @@ export function UpdateAvailableModal(props: {
);
}
/** @testexport */
export function UpdateDownloadingModal(props: {
i18n: LocalizerType;
progress: number;
}): ReactNode {
const { i18n } = props;
return (
<AxoDialog.Root open>
<AxoDialog.Content size="sm" escape="cancel-is-destructive">
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:DialogUpdate__downloading')}
</AxoDialog.Title>
</AxoDialog.Header>
<AxoDialog.Body>
<AxoDialog.Description>
<div className="InstallScreenUpdateDialog__progress--container">
<div
className="InstallScreenUpdateDialog__progress--bar"
style={{ transform: `translateX(${props.progress - 100}%)` }}
/>
</div>
</AxoDialog.Description>
</AxoDialog.Body>
<AxoDialog.Footer />
</AxoDialog.Content>
</AxoDialog.Root>
);
}
/** @testexport */
export function UpdateDownloadedModal(props: {
function UpdateDownloadedModal(props: {
i18n: LocalizerType;
onClose: () => void;
onStartUpdate: () => void;
@@ -275,8 +291,7 @@ const learnMoreLink = (parts: Array<string | JSX.Element>) => (
</a>
);
/** @testexport */
export function UnsupportedOSModal(props: {
function UnsupportedOSModal(props: {
i18n: LocalizerType;
OS: string;
onClose: () => void;
@@ -301,8 +316,7 @@ export function UnsupportedOSModal(props: {
);
}
/** @testexport */
export function CannotUpdateModal(props: {
function CannotUpdateModal(props: {
i18n: LocalizerType;
onClose: () => void;
currentVersion: string;
@@ -354,8 +368,7 @@ export function CannotUpdateModal(props: {
);
}
/** @testexport */
export function CannotUpdateMacOsReadOnlyModal(props: {
function CannotUpdateMacOsReadOnlyModal(props: {
i18n: LocalizerType;
onClose: () => void;
}): ReactNode {
@@ -26,6 +26,7 @@ import { getDefaultAvatars } from '../../types/Avatar.std.ts';
import { MINUTE, SECOND } from '../../util/durations/constants.std.ts';
import { VerificationTransport } from '../../types/VerificationTransport.std.ts';
import { PhoneNumberDiscoverability } from '../../util/phoneNumberDiscoverability.std.ts';
import { DialogType } from '../../types/Dialogs.std.ts';
const i18n = setupI18n('en', messages);
@@ -88,6 +89,19 @@ export const StartingScreen: Story = {
},
},
// Updates
currentVersion: 'v1.0.0',
forceCheck: action('forceCheck'),
kickOffForcedUpgrade: action('kickOffForcedUpgrade'),
OS: 'windows',
startUpdate: action('startUpdate'),
updates: {
dialogType: DialogType.None,
didSnooze: false,
showEventsCount: 0,
isCheckingForUpdates: false,
},
// AvatarEditor support
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
replaceAvatar: action('replaceAvatar'),
@@ -779,6 +793,21 @@ export const AccountLocked: Story = {
},
};
export const UpdateRequiredWithDownloadingState: Story = {
args: {
...StartingScreen.args,
workflow: {
stage: RegistrationStage.UPDATE_REQUIRED,
},
updates: {
...StartingScreen.args.updates,
dialogType: DialogType.Downloading,
downloadedSize: 50_000_000,
downloadSize: 60_000_000,
},
},
};
export function ProgressionWithVerify(): React.JSX.Element {
const [workflow, setWorkflow] = useState<RegistrationWorkflow | undefined>({
stage: RegistrationStage.PHONE_NUMBER,
@@ -25,6 +25,7 @@ import { AccountLockedScreen } from './stages/AccountLocked.dom.tsx';
import { Spacer } from './util/StepComponents.dom.tsx';
import { CONTACT_SUPPORT_URL } from '../../util/contactSupport.dom.tsx';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser.dom.ts';
import { InstallScreenUpdateDialog } from '../installScreen/InstallScreenUpdateDialog.dom.tsx';
import type { LocalizerType } from '../../types/I18N.std.ts';
import type { ActionCreator } from '../../state/types.std.ts';
@@ -54,6 +55,7 @@ import type {
verifyPIN as doVerifyPIN,
} from '../../state/ducks/standaloneInstaller.preload.ts';
import type { CountryDataType } from '../../util/getCountryData.dom.ts';
import type { UpdatesStateType } from '../../state/ducks/updates.preload.ts';
const SPRING = {
type: 'spring' as const,
@@ -89,6 +91,14 @@ export type PropsType = Readonly<{
verifyPIN: ActionCreator<typeof doVerifyPIN>;
workflow: RegistrationWorkflow;
// Updates
currentVersion: string;
forceCheck: () => unknown;
kickOffForcedUpgrade: () => unknown;
OS: string;
startUpdate: () => unknown;
updates: UpdatesStateType;
// AvatarEditor support
deleteAvatarFromDisk: DeleteAvatarFromDiskActionType;
replaceAvatar: ReplaceAvatarActionType;
@@ -122,6 +132,14 @@ export function StandaloneRegistration({
verifyPIN,
workflow,
// Updates
currentVersion,
forceCheck,
kickOffForcedUpgrade,
OS,
startUpdate,
updates,
// AvatarEditor support
deleteAvatarFromDisk,
replaceAvatar,
@@ -236,6 +254,17 @@ export function StandaloneRegistration({
body = (
<AccountLockedScreen i18n={i18n} startRegistration={startRegistration} />
);
} else if (workflow.stage === RegistrationStage.UPDATE_REQUIRED) {
return (
<InstallScreenUpdateDialog
i18n={i18n}
currentVersion={currentVersion}
forceCheck={forceCheck}
OS={OS}
startUpdate={startUpdate}
{...updates}
/>
);
} else {
throw missingCaseError(workflow);
}
@@ -297,9 +326,7 @@ export function StandaloneRegistration({
</AxoConfirmDialog.Action>
<AxoConfirmDialog.Action
variant="strong-primary"
onClick={() => {
// TODO: kick off update process - how to represent this during updates?
}}
onClick={() => kickOffForcedUpgrade()}
>
{i18n('icu:StandaloneRegistration--UpdateRequired--update')}
</AxoConfirmDialog.Action>
+2 -2
View File
@@ -7,6 +7,6 @@ export function startUpdate(): Promise<void> {
return ipcRenderer.invoke('start-update');
}
export function forceUpdate(): Promise<void> {
return ipcRenderer.invoke('updater/force-update');
export function forceCheck(): Promise<void> {
return ipcRenderer.invoke('updater/force-check');
}
+67 -52
View File
@@ -69,6 +69,7 @@ import type {
RawTimings,
RegistrationWorkflow,
Timings,
UpdateRequiredStage,
VerificationCodeStage,
VerifyPINStage,
} from '../../types/StandaloneRegistration.std.ts';
@@ -277,6 +278,14 @@ export function moveToVerificationStage({
reason: 'standalone registration',
});
const afterCaptchaWorkflow = getState().standaloneInstaller.workflow;
if (afterCaptchaWorkflow?.stage !== RegistrationStage.CAPTCHA) {
log.warn(
`${logId}: Captcha challenge returned, but workflow is now at stage ${afterCaptchaWorkflow?.stage}`
);
return;
}
try {
workflow = {
...workflow,
@@ -324,7 +333,7 @@ export function moveToVerificationStage({
if (
error instanceof LibSignalErrorBase &&
(error.is(ErrorCode.RegistrationRequestRejected) ||
error.is(ErrorCode.RegistrationRequestRejected))
error.is(ErrorCode.RegistrationRequestInvalid))
) {
workflow = {
...workflow,
@@ -667,8 +676,7 @@ export function submitVerificationCode({
PartialRegistrationType.EXISTING__PROFILE
);
} else {
// We own storage service; no prior data there. We just turn it on!
enableStorageService();
enableStorageService(); // submitVerificationCode: no prior data there, just turn it on
await itemStorage.put(
'standaloneRegistrationPartialState',
PartialRegistrationType.NEW_ACCOUNT__PROFILE
@@ -702,9 +710,8 @@ export function submitVerificationCode({
},
avatars: undefined,
};
}
if (!accountState) {
} else {
enableStorageService(); // submitVerificationCode: Got a random error back from account creation
workflow = {
...workflow,
status: {
@@ -917,12 +924,12 @@ export function verifyPIN({
// Something has really gone wrong - we're in reglock, but SVR has nothing for us
dispatch(updateWorkflow(workflow, FatalErrorType.UNEXPECTED));
} else {
// No reglock and nothing in SVR - let's allow the user to create a new PIN
enableStorageService();
enableStorageService(); // verifyPIN: nothing in SVR; will start afresh with new key
await itemStorage.put(
'standaloneRegistrationPartialState',
PartialRegistrationType.NEW_ACCOUNT__PIN
);
// No reglock and nothing in SVR - let's allow the user to create a new PIN
dispatch(goToCreatePINStage());
}
} else {
@@ -959,7 +966,7 @@ export function verifyPIN({
toBase64(masterKey)
);
await itemStorage.put('standaloneRegistrationPartialState', undefined);
enableStorageService();
enableStorageService(); // verifyPIN: No reglock, got temporary master key
} catch (error) {
log.error(
`${logId}: error saving data after creating account`,
@@ -1014,7 +1021,7 @@ export function verifyPIN({
try {
disableStorageService(
'standaloneInstaller/verifyPIN, about to create account'
'standaloneInstaller/verifyPIN: Reglock, about to create account'
);
await accountManager.registerAsPrimaryDevice({
number: phoneNumber,
@@ -1028,6 +1035,12 @@ export function verifyPIN({
toLogFormat(error)
);
workflow = {
...workflow,
status: {
type: 'ready',
},
};
dispatch({
type: UPDATE_WORKFLOW,
payload: {
@@ -1057,20 +1070,14 @@ export function verifyPIN({
ourConversation.set({ avatars });
await DataWriter.updateConversation(ourConversation.attributes);
}
enableStorageService();
} catch (error) {
// The account is created, so we should let the user go to the inbox
log.error(
`${logId}: error saving data after creating account`,
toLogFormat(error)
);
dispatch({
type: UPDATE_WORKFLOW,
payload: {
workflow,
fatalError: FatalErrorType.UNEXPECTED,
},
});
} finally {
enableStorageService(); // verifyPIN: Created account with reglock, tried to set things up
}
try {
@@ -1092,17 +1099,11 @@ export function verifyPIN({
: undefined,
});
} catch (error) {
// The account is created, so we should let the user go to the inbox
log.error(
`${logId}: error queueing important jobs after creating account`,
toLogFormat(error)
);
dispatch({
type: UPDATE_WORKFLOW,
payload: {
workflow,
fatalError: FatalErrorType.UNEXPECTED,
},
});
}
await completeRegistration({ workflow: previousWorkflow })(
@@ -1209,6 +1210,16 @@ export function goToAccountLockedStage(): UpdateWorkflowActionType {
return updateWorkflow(workflow);
}
function goToUpdateRequiredStage(): UpdateWorkflowActionType {
const logId = 'goToUpdateRequiredStage';
log.info(logId);
const workflow: UpdateRequiredStage = {
stage: RegistrationStage.UPDATE_REQUIRED,
};
return updateWorkflow(workflow);
}
export function completeRegistration({
workflow: previousWorkflow,
}: {
@@ -1409,6 +1420,7 @@ export const actions = {
goToAccountLockedStage,
goToCreatePINStage,
goToProfileEntryStage,
goToUpdateRequiredStage,
goToVerifyPINStage,
moveToCaptchaStage,
moveToVerificationStage,
@@ -1431,12 +1443,12 @@ export const useStandaloneInstallerActions = (): BoundActionCreatorsMapObject<
// Utilities
function analyzeError(error: Error): FatalErrorType {
// TODO: might want to do something here for rate limit errors
if (error instanceof LibSignalErrorBase) {
if (
error.is(ErrorCode.ChatServiceInactive) ||
error.is(ErrorCode.IoError) ||
error.is(ErrorCode.PossibleCaptiveNetwork)
error.is(ErrorCode.PossibleCaptiveNetwork) ||
error.is(ErrorCode.RateLimitedError)
) {
return FatalErrorType.OFFLINE;
}
@@ -1496,19 +1508,35 @@ export function reducer(
return getEmptyState();
}
if (newWorkflow.stage === RegistrationStage.PHONE_NUMBER) {
log.info(
`UPDATE_WORKFLOW: Transitioning from ${previousWorkflow?.stage ?? '<none>'} to ${newWorkflow.stage}`
);
const previousOrder = previousWorkflow?.stage
? StageOrder[previousWorkflow.stage]
: StageOrder[RegistrationStage.PHONE_NUMBER] - 1;
const currentOrder = StageOrder[newWorkflow.stage];
const direction =
currentOrder >= previousOrder ? Direction.FORWARD : Direction.BACKWARD;
if (previousWorkflow?.stage === newWorkflow.stage) {
return {
...state,
workflow: newWorkflow,
fatalError,
direction:
previousWorkflow &&
previousWorkflow.stage !== RegistrationStage.PHONE_NUMBER
? Direction.BACKWARD
: Direction.FORWARD,
direction,
};
}
if (
newWorkflow.stage === RegistrationStage.PHONE_NUMBER ||
newWorkflow.stage === RegistrationStage.UPDATE_REQUIRED
) {
log.info(
`UPDATE_WORKFLOW: Transitioning from ${previousWorkflow?.stage ?? '<none>'} to ${newWorkflow.stage}`
);
return {
...state,
workflow: newWorkflow,
fatalError,
direction,
};
}
@@ -1525,7 +1553,7 @@ export function reducer(
...state,
workflow: newWorkflow,
fatalError,
direction: Direction.FORWARD,
direction,
};
}
@@ -1538,15 +1566,6 @@ export function reducer(
};
}
if (previousWorkflow.stage === newWorkflow.stage) {
return {
...state,
workflow: newWorkflow,
fatalError,
direction: Direction.FORWARD,
};
}
const validNextStages = ValidNextStages[previousWorkflow.stage];
if (!validNextStages.has(newWorkflow.stage)) {
log.error(
@@ -1562,15 +1581,11 @@ export function reducer(
`UPDATE_WORKFLOW: Transitioning from ${previousWorkflow.stage} to ${newWorkflow.stage}`
);
const previousOrder = StageOrder[previousWorkflow.stage];
const currentOrder = StageOrder[previousWorkflow.stage];
return {
...state,
workflow: newWorkflow,
fatalError,
direction:
currentOrder >= previousOrder ? Direction.FORWARD : Direction.BACKWARD,
direction,
};
}
+3 -3
View File
@@ -148,7 +148,7 @@ function startUpdate(): ThunkAction<
};
}
function forceUpdate(): ThunkAction<
function forceCheck(): ThunkAction<
void,
RootStateType,
unknown,
@@ -162,7 +162,7 @@ function forceUpdate(): ThunkAction<
});
try {
await updateIpc.forceUpdate();
await updateIpc.forceCheck();
} catch {
dispatch({
type: SHOW_UPDATE_DIALOG,
@@ -184,7 +184,7 @@ export const actions = {
showUpdateDialog,
snoozeUpdate,
startUpdate,
forceUpdate,
forceCheck,
};
export const useUpdatesActions = (): BoundActionCreatorsMapObject<
+3 -3
View File
@@ -32,7 +32,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
const updates = useSelector(getUpdatesState);
const { continueInstallWithDataDeletion, startInstaller, retryBackupImport } =
useInstallerActions();
const { startUpdate, forceUpdate } = useUpdatesActions();
const { startUpdate, forceCheck } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector);
const onCancelBackupImport = useCallback((): void => {
@@ -56,7 +56,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
updates,
currentVersion: window.getVersion(),
startUpdate,
forceUpdate,
forceCheck,
retryGetQrCode: startInstaller,
isConfirmingDataDeletion: installerState.isConfirmingDataDeletion,
restartInstall: startInstaller,
@@ -82,7 +82,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
onRetry: retryBackupImport,
updates,
currentVersion: window.getVersion(),
forceUpdate,
forceCheck,
startUpdate,
OS: OS.getName(),
},
@@ -4,6 +4,7 @@
import { memo } from 'react';
import { useSelector } from 'react-redux';
import OS from '../../util/os/osMain.node.ts';
import { getDefaultAvatars } from '../../types/Avatar.std.ts';
import { useConversationsActions } from '../ducks/conversations.preload.ts';
import { useStandaloneInstallerActions } from '../ducks/standaloneInstaller.preload.ts';
@@ -18,6 +19,8 @@ import { StandaloneRegistration } from '../../components/standaloneRegistration/
import { trigger } from '../../shims/events.dom.ts';
import { getCountryDataForLocale } from '../../util/getCountryData.dom.ts';
import { RegistrationStage } from '../../types/StandaloneRegistration.std.ts';
import { getUpdatesState } from '../selectors/updates.std.ts';
import { useUpdatesActions } from '../ducks/updates.preload.ts';
export const SmartStandaloneRegistration = memo(
function SmartStandaloneRegistration() {
@@ -29,6 +32,7 @@ export const SmartStandaloneRegistration = memo(
finishProfileEntryStage,
goToAccountLockedStage,
goToCreatePINStage,
goToUpdateRequiredStage,
moveToCaptchaStage,
moveToVerificationStage,
openBrowserForCaptcha,
@@ -42,6 +46,14 @@ export const SmartStandaloneRegistration = memo(
replaceAvatar: cachedReplaceAvatar,
saveAvatarToDisk: cachedSaveAvatarToDisk,
} = useStandaloneInstallerActions();
const updates = useSelector(getUpdatesState);
const { forceCheck, startUpdate } = useUpdatesActions();
const currentVersion = window.getVersion();
const kickOffForcedUpgrade = () => {
forceCheck();
goToUpdateRequiredStage();
};
const i18n = useSelector(getIntl);
const countries = getCountryDataForLocale(i18n.getLocale());
@@ -92,6 +104,13 @@ export const SmartStandaloneRegistration = memo(
submitVerificationCode={submitVerificationCode}
verifyPIN={verifyPIN}
workflow={workflow}
// Updates
currentVersion={currentVersion}
forceCheck={forceCheck}
kickOffForcedUpgrade={kickOffForcedUpgrade}
OS={OS.getName()}
startUpdate={startUpdate}
updates={updates}
// AvatarEditor support
deleteAvatarFromDisk={
useCachedAvatarFunctions
+18 -1
View File
@@ -26,6 +26,7 @@ export enum RegistrationStage {
CREATE_PIN = 'CREATE_PIN',
CREATE_PIN_CONFIRM = 'CREATE_PIN_CONFIRM',
ACCOUNT_LOCKED = 'ACCOUNT_LOCKED',
UPDATE_REQUIRED = 'UPDATE_REQUIRED',
}
export const StageOrder: Record<RegistrationStage, number> = {
@@ -37,6 +38,7 @@ export const StageOrder: Record<RegistrationStage, number> = {
[RegistrationStage.CREATE_PIN]: 5,
[RegistrationStage.CREATE_PIN_CONFIRM]: 6,
[RegistrationStage.ACCOUNT_LOCKED]: 7,
[RegistrationStage.UPDATE_REQUIRED]: 8,
};
// A few special-cases are always allowed:
@@ -44,6 +46,7 @@ export const StageOrder: Record<RegistrationStage, number> = {
// 2. You can start from the initial PHONE_NUMBER stage, as well as these three
// for fixing partial registrations: PROFILE_ENTRY, VERIFY_PIN, CREATE_PIN
// 3. You can always make an update to the existing stage
// 4. You can always go to the UPDATE_REQUIRED stage
export const ValidNextStages: Record<
RegistrationStage,
Set<RegistrationStage>
@@ -69,6 +72,7 @@ export const ValidNextStages: Record<
RegistrationStage.CREATE_PIN, // so we can go back
]),
[RegistrationStage.ACCOUNT_LOCKED]: new Set([]),
[RegistrationStage.UPDATE_REQUIRED]: new Set([]),
};
// There's no 'complete' stage, just a check we do when we are done
@@ -393,6 +397,18 @@ export type AccountLockedStage = {
stage: RegistrationStage.ACCOUNT_LOCKED;
};
export type UpdateRequiredStage = {
// Server has told us that we need to update Signal Desktop before we move forward.
// Prerequisites:
// - a new version is available to download
// - the user could potentially choose to do this even if not required
// Behaviors:
// - allow user to download the new version and kick off the install
// - show any errors that happen during the process
// - restart the app to finish the installation
stage: RegistrationStage.UPDATE_REQUIRED;
};
export type RegistrationWorkflow =
| PhoneNumberStage
| CaptchaStage
@@ -401,4 +417,5 @@ export type RegistrationWorkflow =
| VerifyPINStage
| CreatePINStage
| CreatePINConfirmStage
| AccountLockedStage;
| AccountLockedStage
| UpdateRequiredStage;
+88 -44
View File
@@ -120,9 +120,10 @@ export type UpdaterOptionsType = Readonly<{
sql: MainSQL;
}>;
enum CheckType {
export enum CheckType {
Normal = 'Normal',
AllowSameVersion = 'AllowSameVersion',
ForceCheck = 'ForceCheck',
ForceDownload = 'ForceDownload',
}
@@ -184,7 +185,7 @@ export abstract class Updater {
50
);
ipcMain.handle('updater/force-update', () => this.force());
ipcMain.handle('updater/force-check', () => this.forceCheck());
}
//
@@ -196,6 +197,11 @@ export abstract class Updater {
return this.#checkForUpdatesMaybeInstall(CheckType.ForceDownload);
}
public async forceCheck(): Promise<void> {
this.#markedCannotUpdate = false;
await this.#checkForUpdatesMaybeInstall(CheckType.ForceCheck);
}
// If the updater was about to restart the app but the user canceled it, show dialog
// to let them retry the restart
public onRestartCanceled(): void {
@@ -228,7 +234,8 @@ export abstract class Updater {
protected abstract installUpdate(
updateFilePath: string,
isSilent: boolean
isSilent: boolean,
checkType: CheckType
): Promise<() => Promise<void>>;
// For Mac App Store
@@ -245,11 +252,15 @@ export abstract class Updater {
ipcMain.handleOnce('start-update', performUpdateCallback);
}
protected checkSystemRequirements(vendor: JSONVendorSchema): boolean {
protected checkSystemRequirements(
vendor: JSONVendorSchema,
checkType: CheckType
): boolean {
if (vendor.requireManualUpdate === 'true') {
this.logger.warn('checkSystemRequirements: manual update required');
this.markCannotUpdate(
new Error('yaml file has requireManualUpdate flag'),
checkType,
DialogType.Cannot_Update_Require_Manual
);
return false;
@@ -262,6 +273,7 @@ export abstract class Updater {
);
this.markCannotUpdate(
new Error('yaml file has unsatisfied minOSVersion value'),
checkType,
DialogType.UnsupportedOS
);
return false;
@@ -272,6 +284,7 @@ export abstract class Updater {
protected markCannotUpdate(
error: Error,
checkType: CheckType,
dialogType = DialogType.Cannot_Update
): void {
if (this.#markedCannotUpdate) {
@@ -296,7 +309,7 @@ export abstract class Updater {
this.logger.info('markCannotUpdate: retrying after user action');
this.#markedCannotUpdate = false;
await this.#checkForUpdatesMaybeInstall(CheckType.Normal);
await this.#checkForUpdatesMaybeInstall(checkType);
});
}
@@ -350,14 +363,19 @@ export abstract class Updater {
async #downloadAndInstall(
updateInfo: UpdateInformationType,
mode: DownloadMode
mode: DownloadMode,
checkType: CheckType
): Promise<boolean> {
if (this.#activeDownload) {
return this.#activeDownload;
}
try {
this.#activeDownload = this.#doDownloadAndInstall(updateInfo, mode);
this.#activeDownload = this.#doDownloadAndInstall(
updateInfo,
mode,
checkType
);
return await this.#activeDownload;
} finally {
@@ -367,7 +385,8 @@ export abstract class Updater {
async #doDownloadAndInstall(
updateInfo: UpdateInformationType,
mode: DownloadMode
mode: DownloadMode,
checkType: CheckType
): Promise<boolean> {
const { logger } = this;
@@ -439,7 +458,11 @@ export abstract class Updater {
updateInfo.vendor?.requireUserConfirmation !== 'true' &&
this.#canRunSilently();
const handler = await this.installUpdate(updateFilePath, isSilent);
const handler = await this.installUpdate(
updateFilePath,
isSilent,
checkType
);
if (isSilent || mode === DownloadMode.ForceUpdate) {
await handler();
} else {
@@ -471,15 +494,16 @@ export abstract class Updater {
logger.error(
`downloadAndInstall: fatal error ${Errors.toLogFormat(error)}`
);
this.markCannotUpdate(error);
this.markCannotUpdate(error, checkType);
throw error;
}
}
async #checkForUpdatesMaybeInstall(checkType: CheckType): Promise<void> {
const logId = `checkForUpdatesMaybeInstall/${checkType}`;
const { logger } = this;
logger.info('checkForUpdatesMaybeInstall: checking for update...');
logger.info(`${logId}: checking for update...`);
const updateInfo = await this.#checkForUpdates(checkType);
if (!updateInfo) {
return;
@@ -488,11 +512,15 @@ export abstract class Updater {
const { version: newVersion } = updateInfo;
if (checkType === CheckType.ForceDownload) {
await this.#downloadAndInstall(updateInfo, DownloadMode.ForceUpdate);
await this.#downloadAndInstall(
updateInfo,
DownloadMode.ForceUpdate,
checkType
);
return;
}
if (checkType === CheckType.Normal) {
if (checkType === CheckType.Normal || checkType === CheckType.ForceCheck) {
// Verify that the downloaded version is greater than downloaded
if (this.version && !gt(newVersion, this.version)) {
return;
@@ -507,8 +535,12 @@ export abstract class Updater {
}
const autoDownloadUpdates = await this.#getAutoDownloadUpdateSetting();
if (autoDownloadUpdates) {
await this.#downloadAndInstall(updateInfo, DownloadMode.Automatic);
if (autoDownloadUpdates && checkType !== CheckType.ForceCheck) {
await this.#downloadAndInstall(
updateInfo,
DownloadMode.Automatic,
checkType
);
return;
}
@@ -517,20 +549,25 @@ export abstract class Updater {
mode = DownloadMode.DifferentialOnly;
}
await this.#offerUpdate(updateInfo, mode, 0);
await this.#offerUpdate(updateInfo, mode, 0, checkType);
}
async #offerUpdate(
updateInfo: UpdateInformationType,
mode: DownloadMode,
attempt: number
attempt: number,
checkType: CheckType
): Promise<void> {
const { logger } = this;
this.setUpdateListener(async () => {
logger.info('offerUpdate: have not downloaded update, going to download');
const didDownload = await this.#downloadAndInstall(updateInfo, mode);
const didDownload = await this.#downloadAndInstall(
updateInfo,
mode,
checkType
);
if (!didDownload && mode === DownloadMode.DifferentialOnly) {
this.logger.warn(
'offerUpdate: Failed to download differential update, offering full'
@@ -539,7 +576,8 @@ export abstract class Updater {
return this.#offerUpdate(
updateInfo,
DownloadMode.FullOnly,
attempt + 1
attempt + 1,
checkType
);
}
@@ -578,10 +616,14 @@ export abstract class Updater {
async #checkForUpdates(
checkType: CheckType
): Promise<UpdateInformationType | undefined> {
const logId = `checkForUpdates/${checkType}`;
if (isNotUpdatable(packageJson.version)) {
this.logger.info(
'checkForUpdates: not checking for updates, this is not an updatable build'
`${logId}: not checking for updates, this is not an updatable build`
);
if (checkType === CheckType.ForceCheck) {
throw new Error(`${logId}: Not an updatabale build!`);
}
return;
}
@@ -589,31 +631,38 @@ export abstract class Updater {
const parsedYaml = parseYaml(yaml);
const { vendor } = parsedYaml;
if (vendor && !this.checkSystemRequirements(vendor)) {
if (vendor && !this.checkSystemRequirements(vendor, checkType)) {
return;
}
const version = getVersion(parsedYaml);
if (!version) {
this.logger.warn(
'checkForUpdates: no version extracted from downloaded yaml'
);
return;
}
if (checkType === CheckType.Normal && !isVersionNewer(version)) {
this.logger.info(
`checkForUpdates: ${version} is not newer than ${packageJson.version}; ` +
'no new update available'
);
this.logger.warn(`${logId}: no version extracted from downloaded yaml`);
if (checkType === CheckType.ForceCheck) {
throw new Error(`${logId}: No version extracted!`);
}
return;
}
if (
checkType === CheckType.Normal &&
(checkType === CheckType.Normal || checkType === CheckType.ForceCheck) &&
!isVersionNewer(version)
) {
this.logger.info(
`${logId}: ${version} is not newer than ${packageJson.version}; ` +
'no new update available'
);
if (checkType === CheckType.ForceCheck) {
throw new Error(`${logId}: No newer version available!`);
}
return;
}
if (
(checkType === CheckType.Normal || checkType === CheckType.ForceCheck) &&
this.handleUpdateFromThirdParty(version)
) {
return;
@@ -639,10 +688,7 @@ export abstract class Updater {
}
}
this.logger.info(
`checkForUpdates: found newer version ${version} ` +
`checkType=${checkType}`
);
this.logger.info(`${logId}: found newer version ${version}`);
const fileName = getUpdateFileName(
parsedYaml,
@@ -659,9 +705,7 @@ export abstract class Updater {
let differentialData: DifferentialDownloadDataType | undefined;
if (latestInstaller) {
this.logger.info(
`checkForUpdates: Found local installer ${latestInstaller}`
);
this.logger.info(`${logId}: Found local installer ${latestInstaller}`);
const diffOptions = {
oldFile: latestInstaller,
@@ -673,7 +717,7 @@ export abstract class Updater {
this.cachedDifferentialData &&
isValidDifferentialData(this.cachedDifferentialData, diffOptions)
) {
this.logger.info('checkForUpdates: using cached differential data');
this.logger.info(`${logId}: using cached differential data`);
differentialData = this.cachedDifferentialData;
} else {
@@ -683,12 +727,12 @@ export abstract class Updater {
this.cachedDifferentialData = differentialData;
this.logger.info(
'checkForUpdates: differential download size',
`${logId}: differential download size`,
differentialData.downloadSize
);
} catch (error) {
this.logger.error(
'checkForUpdates: Failed to prepare differential update',
`${logId}: Failed to prepare differential update`,
Errors.toLogFormat(error)
);
this.cachedDifferentialData = undefined;
+13 -4
View File
@@ -8,12 +8,14 @@ import config from 'config';
import { app } from 'electron';
import { coerce, lt } from 'semver';
import type { JSONVendorSchema } from './common.main.ts';
import { Updater } from './common.main.ts';
import { appRelaunch } from '../util/relaunch.main.ts';
import { hexToBinary } from './signature.node.ts';
import { DialogType } from '../types/Dialogs.std.ts';
import type { JSONVendorSchema } from './common.main.ts';
import type { CheckType } from './common.main.ts';
export class LinuxAppImageUpdater extends Updater {
#installing = false;
@@ -22,7 +24,9 @@ export class LinuxAppImageUpdater extends Updater {
}
protected async installUpdate(
updateFilePath: string
updateFilePath: string,
_isSilent: boolean,
checkType: CheckType
): Promise<() => Promise<void>> {
const { logger } = this;
@@ -32,7 +36,7 @@ export class LinuxAppImageUpdater extends Updater {
await this.#install(updateFilePath);
this.#installing = true;
} catch (error) {
this.markCannotUpdate(error);
this.markCannotUpdate(error, checkType);
throw error;
}
@@ -79,7 +83,10 @@ export class LinuxAppImageUpdater extends Updater {
await chmod(appImageFile, 0o700);
}
override checkSystemRequirements(vendor: JSONVendorSchema): boolean {
override checkSystemRequirements(
vendor: JSONVendorSchema,
checkType: CheckType
): boolean {
const { minGlibcVersion } = vendor;
if (minGlibcVersion) {
const parsedMinGlibcVersion = coerce(minGlibcVersion);
@@ -103,6 +110,7 @@ export class LinuxAppImageUpdater extends Updater {
);
this.markCannotUpdate(
new Error('system glibc version missing or unparseable'),
checkType,
DialogType.UnsupportedOS
);
return false;
@@ -115,6 +123,7 @@ export class LinuxAppImageUpdater extends Updater {
);
this.markCannotUpdate(
new Error('yaml file has unsatisfied minGlibcVersion value'),
checkType,
DialogType.UnsupportedOS
);
return false;
+6 -1
View File
@@ -12,6 +12,8 @@ import { isProduction } from '../util/version.std.ts';
import * as Errors from '../types/errors.std.ts';
import { DialogType } from '../types/Dialogs.std.ts';
import type { CheckType } from './common.main.ts';
const APP_ID = '1230208093';
export class MacOSUpdater extends Updater {
@@ -20,7 +22,9 @@ export class MacOSUpdater extends Updater {
}
protected async installUpdate(
updateFilePath: string
updateFilePath: string,
_isSilent: boolean,
checkType: CheckType
): Promise<() => Promise<void>> {
const { logger } = this;
@@ -32,6 +36,7 @@ export class MacOSUpdater extends Updater {
const message: string = error.message || '';
this.markCannotUpdate(
error,
checkType,
message.includes(readOnly)
? DialogType.MacOS_Read_Only
: DialogType.Cannot_Update
+5 -2
View File
@@ -3,11 +3,13 @@
import { join } from 'node:path';
import type { SpawnOptions } from 'node:child_process';
import { spawn as spawnEmitter } from 'node:child_process';
import { readdir, unlink } from 'node:fs/promises';
import { app } from 'electron';
import { getAppRootDir } from '../util/appRootDir.main.ts';
import { Updater } from './common.main.ts';
import type { CheckType } from './common.main.ts';
const IS_EXE = /\.exe$/i;
@@ -40,7 +42,8 @@ export class WindowsUpdater extends Updater {
}
protected async installUpdate(
updateFilePath: string,
isSilent: boolean
isSilent: boolean,
checkType: CheckType
): Promise<() => Promise<void>> {
const { logger } = this;
@@ -50,7 +53,7 @@ export class WindowsUpdater extends Updater {
await this.#install(updateFilePath, isSilent);
this.#installing = true;
} catch (error) {
this.markCannotUpdate(error);
this.markCannotUpdate(error, checkType);
throw error;
}