- {(hasExpired || updates.dialogType === DialogType.Downloading) && (
-
;
+}
+export function _2IsCheckingForUpdates(): ReactNode {
return (
-
+ );
+}
+
+export function _3ErrorUnsupportedOS(): ReactNode {
+ return (
+
);
}
-export function UpdateAvailableNotReady(): ReactNode {
+export function _3ErrorMASUpdate(): ReactNode {
return (
-
);
}
-export function UpdateAvailableAndReady(): ReactNode {
+export function _3DownloadReady(): ReactNode {
return (
-
);
}
-export function UpdateDownloading(): ReactNode {
- return
;
-}
-
-export function UpdateDownloaded(): ReactNode {
+export function _3FullDownloadReady(): ReactNode {
return (
-
);
}
-export function UnsupportedOS(): ReactNode {
+export function _3AutoUpdate(): ReactNode {
return (
-
- );
-}
-
-export function CannotUpdate(): ReactNode {
- return (
-
);
}
-export function CannotUpdateMacOsReadOnly(): ReactNode {
+export function _4Downloading(): ReactNode {
return (
-
+
+ );
+}
+
+export function _5DownloadedUpdate(): ReactNode {
+ return (
+
+ );
+}
+
+export function _6ErrorCannotUpdate(): ReactNode {
+ return (
+
+ );
+}
+
+export function _6ErrorCannot_Update_Require_Manual(): ReactNode {
+ return (
+
+ );
+}
+
+export function _6ErrorMacosReadOnly(): ReactNode {
+ return (
+
+ );
+}
+
+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 (
+
);
}
diff --git a/ts/components/installScreen/InstallScreenUpdateDialog.dom.tsx b/ts/components/installScreen/InstallScreenUpdateDialog.dom.tsx
index 5a71515151..27dd7e0d1e 100644
--- a/ts/components/installScreen/InstallScreenUpdateDialog.dom.tsx
+++ b/ts/components/installScreen/InstallScreenUpdateDialog.dom.tsx
@@ -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
;
- }
+ let modal: ReactNode | undefined = undefined;
+ let inlineElement: ReactNode | undefined = undefined;
- return (
+ if (dialogType === DialogType.None) {
+ if (isCheckingForUpdates) {
+ inlineElement = (
+
+
+
+ {i18n('icu:InstallScreenUpdateDialog--checking-for-updates')}
+
+
+
+
+ );
+ } else {
+ modal = (
);
}
-
- return null;
- }
-
- if (dialogType === DialogType.UnsupportedOS) {
- return
;
- }
-
- if (dialogType === DialogType.MASUpdate) {
- return (
+ } else if (dialogType === DialogType.UnsupportedOS) {
+ modal =
;
+ } else if (dialogType === DialogType.MASUpdate) {
+ modal = (
);
- }
-
- if (dialogType === DialogType.DownloadedUpdate) {
- return (
+ } else if (dialogType === DialogType.DownloadedUpdate) {
+ modal = (
);
- }
-
- if (
+ } else if (
dialogType === DialogType.AutoUpdate ||
// Manual update with an action button
dialogType === DialogType.DownloadReady ||
dialogType === DialogType.FullDownloadReady
) {
- return (
+ modal = (
);
- }
-
- 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 = (
+
+
+ {i18n('icu:InstallScreenUpdateDialog--updating-signal')}
+
+
+ {isNumber(fractionComplete) ? (
+
+ {i18n('icu:InstallScreenUpdateDialog--download-progress', {
+ currentBytes: formatFileSize(downloadedSize ?? 0),
+ totalBytes: formatFileSize(downloadSize ?? 1),
+ percentage: fractionComplete,
+ })}
+
+ ) : undefined}
+
);
- return (
-
- );
- }
-
- if (
+ } else if (
dialogType === DialogType.Cannot_Update ||
dialogType === DialogType.Cannot_Update_Require_Manual
) {
- return (
+ modal = (
);
+ } else if (dialogType === DialogType.MacOS_Read_Only) {
+ modal =
;
+ } else {
+ throw missingCaseError(dialogType);
}
- if (dialogType === DialogType.MacOS_Read_Only) {
- return
;
- }
-
- throw missingCaseError(dialogType);
+ return (
+
+
+
+ {modal}
+ {inlineElement}
+
+ );
}
-/** @testexport */
-export function UpdateRequiredModal(props: {
+function CenteredElement({ children }: { children: ReactNode }): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
+
+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'
+ )}
);
}
-/** @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 (
-
-
-
-
- {i18n('icu:DialogUpdate__downloading')}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-/** @testexport */
-export function UpdateDownloadedModal(props: {
+function UpdateDownloadedModal(props: {
i18n: LocalizerType;
onClose: () => void;
onStartUpdate: () => void;
@@ -275,8 +291,7 @@ const learnMoreLink = (parts: Array
) => (
);
-/** @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 {
diff --git a/ts/components/standaloneRegistration/StandaloneRegistration.dom.stories.tsx b/ts/components/standaloneRegistration/StandaloneRegistration.dom.stories.tsx
index a1cb8c7978..193482d16a 100644
--- a/ts/components/standaloneRegistration/StandaloneRegistration.dom.stories.tsx
+++ b/ts/components/standaloneRegistration/StandaloneRegistration.dom.stories.tsx
@@ -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({
stage: RegistrationStage.PHONE_NUMBER,
diff --git a/ts/components/standaloneRegistration/StandaloneRegistration.dom.tsx b/ts/components/standaloneRegistration/StandaloneRegistration.dom.tsx
index 35f2534936..8fbd73975e 100644
--- a/ts/components/standaloneRegistration/StandaloneRegistration.dom.tsx
+++ b/ts/components/standaloneRegistration/StandaloneRegistration.dom.tsx
@@ -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;
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 = (
);
+ } else if (workflow.stage === RegistrationStage.UPDATE_REQUIRED) {
+ return (
+
+ );
} else {
throw missingCaseError(workflow);
}
@@ -297,9 +326,7 @@ export function StandaloneRegistration({
{
- // TODO: kick off update process - how to represent this during updates?
- }}
+ onClick={() => kickOffForcedUpgrade()}
>
{i18n('icu:StandaloneRegistration--UpdateRequired--update')}
diff --git a/ts/shims/updateIpc.preload.ts b/ts/shims/updateIpc.preload.ts
index 17be8d90cf..7edc974f3e 100644
--- a/ts/shims/updateIpc.preload.ts
+++ b/ts/shims/updateIpc.preload.ts
@@ -7,6 +7,6 @@ export function startUpdate(): Promise {
return ipcRenderer.invoke('start-update');
}
-export function forceUpdate(): Promise {
- return ipcRenderer.invoke('updater/force-update');
+export function forceCheck(): Promise {
+ return ipcRenderer.invoke('updater/force-check');
}
diff --git a/ts/state/ducks/standaloneInstaller.preload.ts b/ts/state/ducks/standaloneInstaller.preload.ts
index 5db5643867..53efcccafb 100644
--- a/ts/state/ducks/standaloneInstaller.preload.ts
+++ b/ts/state/ducks/standaloneInstaller.preload.ts
@@ -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 ?? ''} 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 ?? ''} 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,
};
}
diff --git a/ts/state/ducks/updates.preload.ts b/ts/state/ducks/updates.preload.ts
index 3f99696e80..8073d6ea10 100644
--- a/ts/state/ducks/updates.preload.ts
+++ b/ts/state/ducks/updates.preload.ts
@@ -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<
diff --git a/ts/state/smart/InstallScreen.preload.tsx b/ts/state/smart/InstallScreen.preload.tsx
index 04da86e1a2..69c8ed7c75 100644
--- a/ts/state/smart/InstallScreen.preload.tsx
+++ b/ts/state/smart/InstallScreen.preload.tsx
@@ -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(),
},
diff --git a/ts/state/smart/StandaloneRegistration.preload.tsx b/ts/state/smart/StandaloneRegistration.preload.tsx
index 8309ba50ba..161900541d 100644
--- a/ts/state/smart/StandaloneRegistration.preload.tsx
+++ b/ts/state/smart/StandaloneRegistration.preload.tsx
@@ -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
diff --git a/ts/types/StandaloneRegistration.std.ts b/ts/types/StandaloneRegistration.std.ts
index 57979cda23..be91dc4967 100644
--- a/ts/types/StandaloneRegistration.std.ts
+++ b/ts/types/StandaloneRegistration.std.ts
@@ -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 = {
@@ -37,6 +38,7 @@ export const StageOrder: Record = {
[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 = {
// 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
@@ -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;
diff --git a/ts/updater/common.main.ts b/ts/updater/common.main.ts
index dcaa2e69f8..748b6d9a8c 100644
--- a/ts/updater/common.main.ts
+++ b/ts/updater/common.main.ts
@@ -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 {
+ 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>;
// 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 {
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 {
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 {
+ 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 {
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 {
+ 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;
diff --git a/ts/updater/linuxAppImage.main.ts b/ts/updater/linuxAppImage.main.ts
index ac7bb7e96c..f639ddaf80 100644
--- a/ts/updater/linuxAppImage.main.ts
+++ b/ts/updater/linuxAppImage.main.ts
@@ -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> {
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;
diff --git a/ts/updater/macos.main.ts b/ts/updater/macos.main.ts
index aa18306ff8..f149155e6d 100644
--- a/ts/updater/macos.main.ts
+++ b/ts/updater/macos.main.ts
@@ -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> {
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
diff --git a/ts/updater/windows.main.ts b/ts/updater/windows.main.ts
index d223774b6c..4b1a42b01d 100644
--- a/ts/updater/windows.main.ts
+++ b/ts/updater/windows.main.ts
@@ -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> {
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;
}