mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-13 05:58:29 +09:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffe8f3387 | ||
|
|
4194525933 | ||
|
|
44799dafa2 | ||
|
|
30b32c9a35 | ||
|
|
d1b4b82895 |
No files matched your search
Generated
+3
-3
@@ -49,9 +49,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.2.0"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a"
|
||||
checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
|
||||
dependencies = [
|
||||
"cssparser 0.38.0",
|
||||
"html5ever 0.40.1",
|
||||
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+13
-2
@@ -17,14 +17,25 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selectorRoot {
|
||||
height: 45rem;
|
||||
}
|
||||
|
||||
.selectorContent {
|
||||
--selector-modal-content-height: clamp(20rem, 48vh, 23.75rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(html.mobile-layout) .selectorRoot {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:global(html.mobile-layout) .selectorContent {
|
||||
--selector-modal-content-height: clamp(20rem, 48vh, 23.75rem);
|
||||
height: var(--selector-modal-content-height);
|
||||
min-height: var(--selector-modal-content-height);
|
||||
max-height: var(--selector-modal-content-height);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
|
||||
+27
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {AccountSwitcherAuthShell} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthShell';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import {AccountSwitcherLoginStep} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherLoginStep';
|
||||
import {AccountSwitcherRegisterStep} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherRegisterStep';
|
||||
import forgotStyles from '@app/features/auth/components/pages/ForgotPasswordPage.module.css';
|
||||
import {RecoverAccountForm} from '@app/features/auth/components/pages/RecoverAccountPage';
|
||||
import {shouldRenderInstanceStep} from '@app/features/auth/flow/auth_login_core/AuthLoginInstanceStep';
|
||||
import {ForgotPasswordForm} from '@app/features/auth/flow/ForgotPasswordForm';
|
||||
import MFAScreen from '@app/features/auth/flow/MfaScreen';
|
||||
@@ -102,7 +104,7 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
|
||||
const mfaMethods = Authentication.availableMfaMethods;
|
||||
const hasMfaChallenge = mfaChallengePurpose === purpose && mfaTicket != null && mfaMethods != null;
|
||||
let authStep: AccountSwitcherAuthStep;
|
||||
if (hasMfaChallenge) {
|
||||
if (hasMfaChallenge && selectedAuthStep !== AccountSwitcherAuthStep.FORGOT) {
|
||||
authStep = AccountSwitcherAuthStep.MFA;
|
||||
} else {
|
||||
authStep = selectedAuthStep;
|
||||
@@ -148,6 +150,14 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
|
||||
},
|
||||
[authRuntimeTarget, onLoginComplete, redirectAfterLogin],
|
||||
);
|
||||
const handleAccountRecovered = useCallback(
|
||||
async (payload: LoginSuccessPayload) => {
|
||||
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
|
||||
await AuthenticationCommands.completeLogin({...payload, runtimeSnapshot}, {redirectPath: redirectAfterLogin});
|
||||
await onLoginComplete(payload);
|
||||
},
|
||||
[authRuntimeTarget, onLoginComplete, redirectAfterLogin],
|
||||
);
|
||||
const handleRegisterComplete = useCallback(
|
||||
async (response: AuthenticationCommands.TokenResponse) => {
|
||||
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
|
||||
@@ -168,7 +178,10 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
|
||||
AuthenticationCommands.clearMfaTicket();
|
||||
return;
|
||||
case AccountSwitcherAuthStep.REGISTER:
|
||||
showLoginStep();
|
||||
return;
|
||||
case AccountSwitcherAuthStep.FORGOT:
|
||||
AuthenticationCommands.clearMfaTicket();
|
||||
showLoginStep();
|
||||
return;
|
||||
case AccountSwitcherAuthStep.LOGIN:
|
||||
@@ -203,14 +216,25 @@ export const AccountSwitcherAuthFlow = observer(function AccountSwitcherAuthFlow
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.register-step"
|
||||
/>
|
||||
);
|
||||
case AccountSwitcherAuthStep.FORGOT:
|
||||
case AccountSwitcherAuthStep.FORGOT: {
|
||||
const runtimeSnapshot = requireSelectedRuntimeSnapshot(authRuntimeTarget);
|
||||
if (usesUsernameSignIn(runtimeSnapshot.features)) {
|
||||
return (
|
||||
<RecoverAccountForm
|
||||
runtimeSnapshot={runtimeSnapshot}
|
||||
onRecovered={handleAccountRecovered}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.recover-account-step"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ForgotPasswordForm
|
||||
runtimeSnapshot={requireSelectedRuntimeSnapshot(authRuntimeTarget)}
|
||||
runtimeSnapshot={runtimeSnapshot}
|
||||
renderContainer={renderForgotPasswordContainer}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-auth-flow.forgot-password-step"
|
||||
/>
|
||||
);
|
||||
}
|
||||
case AccountSwitcherAuthStep.LOGIN:
|
||||
return (
|
||||
<AccountSwitcherLoginStep
|
||||
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
import type * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import authStyles from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherAuthFlow.module.css';
|
||||
import pageStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
|
||||
@@ -35,6 +36,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
|
||||
/>
|
||||
);
|
||||
}
|
||||
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
|
||||
return (
|
||||
<>
|
||||
<h1
|
||||
@@ -49,13 +51,15 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
|
||||
>
|
||||
<AuthRegisterFormCore
|
||||
fields={{
|
||||
showEmail: true,
|
||||
showEmail: !usernameSignIn,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: usernameSignIn,
|
||||
}}
|
||||
submitLabel={<Trans>Create account</Trans>}
|
||||
redirectPath=""
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
runtimeSnapshot={runtimeSnapshot}
|
||||
onRegister={onRegister}
|
||||
showLegalConsent
|
||||
|
||||
@@ -23,6 +23,7 @@ import {formatRecoveryKeyInput} from '@fluxer/constants/src/RecoveryKeyUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactNode} from 'react';
|
||||
import {useCallback, useEffect, useId, useState} from 'react';
|
||||
|
||||
const RECOVER_ACCOUNT_DESCRIPTOR = msg({
|
||||
@@ -88,30 +89,28 @@ function showRecoveredKit(payload: LoginSuccessPayload, kit: PendingRecoveryKit)
|
||||
rememberRecoveryKit(payload.userId, kit.createdAt);
|
||||
}
|
||||
|
||||
interface RecoverAccountPageContentProps {
|
||||
const EMPTY_RECOVERY_FRAGMENT: RecoveryFragment = {username: '', key: ''};
|
||||
|
||||
interface RecoverAccountFormProps {
|
||||
readonly runtimeSnapshot: RuntimeConfigSnapshot;
|
||||
readonly fragment?: RecoveryFragment;
|
||||
readonly footerLink?: ReactNode;
|
||||
readonly onRecovered: (payload: LoginSuccessPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
|
||||
export const RecoverAccountForm = observer(function RecoverAccountForm({
|
||||
runtimeSnapshot,
|
||||
}: RecoverAccountPageContentProps) {
|
||||
fragment = EMPTY_RECOVERY_FRAGMENT,
|
||||
footerLink,
|
||||
onRecovered,
|
||||
}: RecoverAccountFormProps) {
|
||||
const {i18n} = useLingui();
|
||||
const loginId = useId();
|
||||
const keyId = useId();
|
||||
const passwordId = useId();
|
||||
const confirmPasswordId = useId();
|
||||
const [fragment] = useState(readRecoveryFragment);
|
||||
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
|
||||
useEffect(() => {
|
||||
if (!usernameSignIn) {
|
||||
RouterUtils.replaceWith(Routes.LOGIN);
|
||||
}
|
||||
}, [usernameSignIn]);
|
||||
const [mfaChallenge, setMfaChallenge] = useState<MfaChallenge | null>(null);
|
||||
const [pendingKit, setPendingKit] = useState<PendingRecoveryKit | null>(null);
|
||||
useEffect(() => {
|
||||
clearRecoveryFragment();
|
||||
}, []);
|
||||
const {form, isLoading, error, fieldErrors} = useAuthForm({
|
||||
initialValues: {
|
||||
login: fragment.username,
|
||||
@@ -137,21 +136,27 @@ const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
|
||||
setMfaChallenge(result.challenge);
|
||||
return false;
|
||||
}
|
||||
showRecoveredKit(result.payload, {...result.kit, login});
|
||||
await AuthenticationCommands.completeLogin({...result.payload, runtimeSnapshot});
|
||||
try {
|
||||
await onRecovered(result.payload);
|
||||
} finally {
|
||||
showRecoveredKit(result.payload, {...result.kit, login});
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
firstFieldName: 'login',
|
||||
});
|
||||
const handleMfaSuccess = useCallback(
|
||||
async (payload: LoginSuccessPayload) => {
|
||||
if (pendingKit) {
|
||||
showRecoveredKit(payload, pendingKit);
|
||||
try {
|
||||
await onRecovered(payload);
|
||||
AuthenticationCommands.clearMfaTicket();
|
||||
} finally {
|
||||
if (pendingKit) {
|
||||
showRecoveredKit(payload, pendingKit);
|
||||
}
|
||||
}
|
||||
await AuthenticationCommands.completeLogin({...payload, runtimeSnapshot});
|
||||
AuthenticationCommands.clearMfaTicket();
|
||||
},
|
||||
[pendingKit, runtimeSnapshot],
|
||||
[onRecovered, pendingKit],
|
||||
);
|
||||
const handleMfaCancel = useCallback(() => {
|
||||
AuthenticationCommands.clearMfaTicket();
|
||||
@@ -265,11 +270,46 @@ const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
|
||||
<p className={styles.hint} data-flx="auth.recover-account-page.no-kit-hint">
|
||||
<Trans>No recovery kit? Ask an admin of this instance for a password reset link.</Trans>
|
||||
</p>
|
||||
{footerLink}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface RecoverAccountPageContentProps {
|
||||
readonly runtimeSnapshot: RuntimeConfigSnapshot;
|
||||
}
|
||||
|
||||
const RecoverAccountPageContent = observer(function RecoverAccountPageContent({
|
||||
runtimeSnapshot,
|
||||
}: RecoverAccountPageContentProps) {
|
||||
const {i18n} = useLingui();
|
||||
const [fragment] = useState(readRecoveryFragment);
|
||||
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
|
||||
useEffect(() => {
|
||||
if (!usernameSignIn) {
|
||||
RouterUtils.replaceWith(Routes.LOGIN);
|
||||
}
|
||||
}, [usernameSignIn]);
|
||||
useEffect(() => {
|
||||
clearRecoveryFragment();
|
||||
}, []);
|
||||
const handleRecovered = useCallback(
|
||||
(payload: LoginSuccessPayload) => AuthenticationCommands.completeLogin({...payload, runtimeSnapshot}),
|
||||
[runtimeSnapshot],
|
||||
);
|
||||
return (
|
||||
<RecoverAccountForm
|
||||
runtimeSnapshot={runtimeSnapshot}
|
||||
fragment={fragment}
|
||||
footerLink={
|
||||
<AuthRouterLink to={Routes.LOGIN} className={styles.link} data-flx="auth.recover-account-page.link">
|
||||
{i18n._(BACK_TO_SIGN_IN_DESCRIPTOR)}
|
||||
</AuthRouterLink>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
onRecovered={handleRecovered}
|
||||
data-flx="auth.recover-account-page.recover-account-form"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -903,23 +903,23 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
};
|
||||
const renderCredentialStep = (): ReactNode => {
|
||||
const forgotPasswordLink =
|
||||
identifierField === 'login' ? (
|
||||
<AuthRouterLink
|
||||
to={Routes.RECOVER_ACCOUNT}
|
||||
className={styles.link}
|
||||
data-flx="auth.flow.auth-login-layout.link.recover"
|
||||
>
|
||||
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
|
||||
</AuthRouterLink>
|
||||
) : (
|
||||
resolveForgotPasswordLink(
|
||||
emailsEnabled,
|
||||
forgotPasswordAction,
|
||||
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link">
|
||||
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
|
||||
</AuthRouterLink>,
|
||||
)
|
||||
);
|
||||
identifierField === 'login'
|
||||
? (forgotPasswordAction ?? (
|
||||
<AuthRouterLink
|
||||
to={Routes.RECOVER_ACCOUNT}
|
||||
className={styles.link}
|
||||
data-flx="auth.flow.auth-login-layout.link.recover"
|
||||
>
|
||||
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
|
||||
</AuthRouterLink>
|
||||
))
|
||||
: resolveForgotPasswordLink(
|
||||
emailsEnabled,
|
||||
forgotPasswordAction,
|
||||
<AuthRouterLink to="/forgot" className={styles.link} data-flx="auth.flow.auth-login-layout.link">
|
||||
{i18n._(FORGOT_YOUR_PASSWORD_DESCRIPTOR)}
|
||||
</AuthRouterLink>,
|
||||
);
|
||||
return (
|
||||
<AuthLoginCredentialStepPanel
|
||||
extraTopContent={extraTopContent}
|
||||
|
||||
@@ -256,14 +256,14 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
|
||||
...(userData ? {userData} : {}),
|
||||
runtimeSnapshot,
|
||||
});
|
||||
if (offerRecoveryKit && showPassword && values.password) {
|
||||
void createRecoveryKitWithPasswordAndOpen({
|
||||
userId: response.user_id,
|
||||
password: values.password,
|
||||
username: response.user?.username,
|
||||
discriminator: response.user?.discriminator,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (offerRecoveryKit && showPassword && values.password) {
|
||||
void createRecoveryKitWithPasswordAndOpen({
|
||||
userId: response.user_id,
|
||||
password: values.password,
|
||||
username: response.user?.username,
|
||||
discriminator: response.user?.discriminator,
|
||||
});
|
||||
}
|
||||
onAuthenticated?.();
|
||||
clearRegisterFormDraft(draftKey);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
snapshot: {apiEndpoint: 'https://instance.example/api', apiCodeVersion: 7} as RuntimeConfigSnapshot,
|
||||
snapshot: {apiEndpoint: 'https://instance.example/api', apiCodeVersion: 7, features: {}} as RuntimeConfigSnapshot,
|
||||
reset: vi.fn(),
|
||||
completeLogin: vi.fn(),
|
||||
search: '',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
|
||||
import {AuthErrorState} from '@app/features/auth/flow/AuthErrorState';
|
||||
@@ -163,6 +164,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
|
||||
);
|
||||
const runtimeSso = resolveAuthPanelSso(runtimeSnapshot);
|
||||
const sso = runtimeSso?.enabled === true && runtimeSso.enforced === true;
|
||||
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
|
||||
const header = (
|
||||
<>
|
||||
<DesktopDeepLinkPrompt
|
||||
@@ -185,9 +187,19 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
|
||||
/>
|
||||
) : (
|
||||
<AuthRegisterFormCore
|
||||
fields={{showUsername: false}}
|
||||
fields={
|
||||
usernameSignIn
|
||||
? {
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: true,
|
||||
}
|
||||
: {showUsername: false}
|
||||
}
|
||||
submitLabel={config.submitLabel}
|
||||
redirectPath={config.registerRedirectPath(id, safeRedirect)}
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
onRegister={onRegisterComplete}
|
||||
runtimeSnapshot={runtimeSnapshot}
|
||||
onAuthenticated={runtimeTarget.reset}
|
||||
|
||||
@@ -45,7 +45,12 @@ export const CreateDMModal = observer((props: CreateDMModalProps) => {
|
||||
}
|
||||
}, [modalLogic]);
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="channel.create-dm-modal.modal-root">
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
className={selectorStyles.selectorRoot}
|
||||
data-flx="channel.create-dm-modal.modal-root"
|
||||
>
|
||||
<Modal.Header title={i18n._(SELECT_FRIENDS_DESCRIPTOR)} data-flx="channel.create-dm-modal.modal-header">
|
||||
{!restriction && (
|
||||
<>
|
||||
|
||||
@@ -48,7 +48,12 @@ export const GiftSendToFriendModal = observer(function GiftSendToFriendModal({co
|
||||
const handleCopy = useCopyLinkHandler(giftUrl, true);
|
||||
const {sendingTo, sentTo, send: handleSendGift} = useSendLinkToRecipients(() => giftUrl);
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="expressions.gift-send-to-friend-modal.modal-root">
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
className={selectorStyles.selectorRoot}
|
||||
data-flx="expressions.gift-send-to-friend-modal.modal-root"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(YOU_ALREADY_HAVE_LIFETIME_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
data-flx="expressions.gift-send-to-friend-modal.modal-header"
|
||||
|
||||
@@ -326,7 +326,12 @@ const InviteModalContent = observer(function InviteModalContent({
|
||||
return i18n._(YOUR_INVITE_LINK_EXPIRES_IN_SECONDS_DESCRIPTOR, {seconds});
|
||||
};
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="invite.invite-modal.modal-root--2">
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
className={selectorStyles.selectorRoot}
|
||||
data-flx="invite.invite-modal.modal-root--2"
|
||||
>
|
||||
<Modal.Header
|
||||
title={!showAdvanced ? title : i18n._(INVITE_LINK_SETTINGS_DESCRIPTOR)}
|
||||
data-flx="invite.invite-modal.modal-header--2"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink';
|
||||
import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
|
||||
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
|
||||
@@ -92,6 +93,7 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
|
||||
</>
|
||||
);
|
||||
}
|
||||
const usernameSignIn = usesUsernameSignIn(runtimeSnapshot.features);
|
||||
return (
|
||||
<>
|
||||
<DesktopDeepLinkPrompt
|
||||
@@ -102,9 +104,19 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
|
||||
<InviteHeader invite={invite} data-flx="invite.invite-register-page.invite-header" />
|
||||
<div className={sharedStyles.container} data-flx="invite.invite-register-page.div--4">
|
||||
<AuthRegisterFormCore
|
||||
fields={{showUsername: false}}
|
||||
fields={
|
||||
usernameSignIn
|
||||
? {
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: true,
|
||||
}
|
||||
: {showUsername: false}
|
||||
}
|
||||
submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)}
|
||||
redirectPath="/"
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
inviteCode={code}
|
||||
runtimeSnapshot={runtimeSnapshot}
|
||||
onAuthenticated={runtimeTarget.reset}
|
||||
|
||||
@@ -21,14 +21,6 @@
|
||||
caret-color: var(--text-chat);
|
||||
}
|
||||
|
||||
.destinationList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: clamp(20rem, 48vh, 23.75rem);
|
||||
min-height: 0;
|
||||
max-height: clamp(20rem, 48vh, 23.75rem);
|
||||
}
|
||||
|
||||
.footerSection {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -361,6 +361,7 @@ export const ForwardModal = observer(
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
className={selectorStyles.selectorRoot}
|
||||
initialFocusRef={shouldFocusSearch ? searchInputRef : undefined}
|
||||
data-flx="messaging.forward-modal.modal-root"
|
||||
>
|
||||
@@ -388,7 +389,7 @@ export const ForwardModal = observer(
|
||||
</Modal.Header>
|
||||
<Modal.Content
|
||||
padding="none"
|
||||
className={modalStyles.destinationList}
|
||||
className={selectorStyles.selectorContent}
|
||||
data-flx="messaging.forward-modal.modal-content"
|
||||
>
|
||||
<div className={selectorStyles.listContainer} data-flx="messaging.forward-modal.div--2">
|
||||
|
||||
@@ -48,7 +48,12 @@ export const AddFriendsToGroupModal = observer((props: AddFriendsToGroupModalPro
|
||||
? i18n._(LINK_HIDDEN_WHILE_SHARING_DESCRIPTOR)
|
||||
: (modalLogic.inviteLink ?? '');
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="relationship.add-friends-to-group-modal.modal-root">
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
className={selectorStyles.selectorRoot}
|
||||
data-flx="relationship.add-friends-to-group-modal.modal-root"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(SELECT_FRIENDS_DESCRIPTOR)}
|
||||
data-flx="relationship.add-friends-to-group-modal.modal-header"
|
||||
|
||||
@@ -144,7 +144,12 @@ export const ShareThemeModal = observer(({themeCss}: {themeCss: string}) => {
|
||||
send: handleSendTheme,
|
||||
} = useSendLinkToRecipients(() => (themeUrl ? `${i18n._(CHECK_OUT_MY_CUSTOM_THEME_DESCRIPTOR)}\n${themeUrl}` : null));
|
||||
return (
|
||||
<Modal.Root size="small" centered data-flx="theme.share-theme-modal.modal-root">
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
className={selectorStyles.selectorRoot}
|
||||
data-flx="theme.share-theme-modal.modal-root"
|
||||
>
|
||||
<Modal.Header title={i18n._(SHARE_YOUR_THEME_DESCRIPTOR)} data-flx="theme.share-theme-modal.modal-header">
|
||||
<div className={selectorStyles.headerSearch} data-flx="theme.share-theme-modal.div">
|
||||
<Input
|
||||
|
||||
@@ -19,9 +19,12 @@ describe('how often a client re-checks the module manifest', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a shipped build polls every ten minutes, not every two seconds', () => {
|
||||
test('a shipped build polls every 75 to 105 seconds', () => {
|
||||
assert.equal(resolveModulePollPlan(null).intervalMs, MODULE_POLL_INTERVAL_MS);
|
||||
assert.equal(MODULE_POLL_INTERVAL_MS, 10 * 60 * 1000);
|
||||
assert.equal(MODULE_POLL_INTERVAL_MS, 90 * 1000);
|
||||
const plan = resolveModulePollPlan(null);
|
||||
assert.ok(nextModulePollDelay(plan, () => 0) >= 75 * 1000);
|
||||
assert.ok(nextModulePollDelay(plan, () => 1) <= 105 * 1000);
|
||||
});
|
||||
|
||||
test('a shipped build spreads the poll so clients do not stampede the manifest', () => {
|
||||
@@ -34,8 +37,8 @@ describe('how often a client re-checks the module manifest', () => {
|
||||
const mid = nextModulePollDelay(plan, () => 0.5);
|
||||
const high = nextModulePollDelay(plan, () => 1);
|
||||
assert.equal(mid, plan.intervalMs);
|
||||
assert.equal(low, plan.intervalMs * (1 - plan.jitterRatio));
|
||||
assert.equal(high, plan.intervalMs * (1 + plan.jitterRatio));
|
||||
assert.equal(low, Math.round(plan.intervalMs * (1 - plan.jitterRatio)));
|
||||
assert.equal(high, Math.round(plan.intervalMs * (1 + plan.jitterRatio)));
|
||||
assert.ok(low < mid && mid < high);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const MODULE_POLL_INTERVAL_MS = 10 * 60 * 1000;
|
||||
const MODULE_POLL_JITTER_RATIO = 0.2;
|
||||
export const MODULE_POLL_INTERVAL_MS = 90 * 1000;
|
||||
const MODULE_POLL_JITTER_RATIO = 0.15;
|
||||
|
||||
export interface ModulePollPlan {
|
||||
readonly intervalMs: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
||||
import {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity';
|
||||
import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan';
|
||||
import {resolveModulePollPlan} from '@electron/common/ModulePollPlan';
|
||||
import {
|
||||
hasOfflineRenderer,
|
||||
ModuleSystemLaunchDecision,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveModuleSystemLaunch,
|
||||
} from '@electron/common/ModuleSystem';
|
||||
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
|
||||
import {armDesktopUpdatePoll, wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll';
|
||||
import {DesktopUpdateRun, type DesktopUpdateTakeover} from '@electron/main/DesktopUpdateRun';
|
||||
import {armOpenUrlForwarding, observeRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff';
|
||||
import type {ModuleLaunchAttempt, ModuleStore as ModuleStoreInstance} from '@electron/main/ModuleStore';
|
||||
@@ -134,51 +135,6 @@ async function launchMainApp(permit: ModuleLaunchPermit, logger: BootstrapLogger
|
||||
await import(MAIN_PROCESS_ENTRY_URL);
|
||||
}
|
||||
|
||||
function armModulePoll(plan: ModulePollPlan, logger: BootstrapLogger): void {
|
||||
let inFlight = false;
|
||||
let failing = false;
|
||||
logger.info('Polling the module manifest', {
|
||||
intervalMs: plan.intervalMs,
|
||||
jitterRatio: plan.jitterRatio,
|
||||
});
|
||||
const schedule = (): void => {
|
||||
const timer = setTimeout(tick, nextModulePollDelay(plan));
|
||||
timer.unref();
|
||||
};
|
||||
const tick = (): void => {
|
||||
if (inFlight) {
|
||||
schedule();
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
void checkDesktopUpdateNow()
|
||||
.then((state) => {
|
||||
if (failing) {
|
||||
failing = false;
|
||||
logger.info('The module poll recovered');
|
||||
}
|
||||
if (state.available) {
|
||||
logger.info('A desktop update is available, waiting for the user to start it');
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!failing) {
|
||||
failing = true;
|
||||
logger.warn('The module poll failed, staying quiet until it recovers', error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
schedule();
|
||||
});
|
||||
};
|
||||
if (process.platform === 'linux') {
|
||||
tick();
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
|
||||
async function refuseUnsupportedBuild(reason: string): Promise<never> {
|
||||
const {app} = await import('electron');
|
||||
const {createChildLogger} = await import('@electron/common/Logger');
|
||||
@@ -811,8 +767,8 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
);
|
||||
markSplashLaunching();
|
||||
armDesktopUpdate({
|
||||
check: async () => {
|
||||
const check = await desktopUpdate.check();
|
||||
check: async (manual) => {
|
||||
const check = await desktopUpdate.check({manual});
|
||||
recordPendingUpdate(check);
|
||||
return check;
|
||||
},
|
||||
@@ -821,7 +777,15 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
armMainWindowHandoff(updater, permit.launchAttempt, () => {
|
||||
desktopUpdate.markLaunchSettled();
|
||||
preloadUpdateSplash();
|
||||
armModulePoll(resolveModulePollPlan(readDevModulePollInterval()), logger);
|
||||
armDesktopUpdatePoll(
|
||||
{
|
||||
plan: resolveModulePollPlan(readDevModulePollInterval()),
|
||||
check: () => checkDesktopUpdateNow(),
|
||||
logger,
|
||||
},
|
||||
process.platform === 'linux',
|
||||
);
|
||||
observeRendererLaunchConfirmed(wakeDesktopUpdatePoll);
|
||||
});
|
||||
app.once('before-quit', () => {
|
||||
closeSplashWindow();
|
||||
|
||||
@@ -195,12 +195,9 @@ describe('Bootstrap entry point', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the module poll only checks, it never downloads or reloads underneath the user', () => {
|
||||
const poll = source.slice(
|
||||
source.indexOf('function armModulePoll('),
|
||||
source.indexOf('async function refuseUnsupportedBuild('),
|
||||
);
|
||||
assert.match(poll, /void checkDesktopUpdateNow\(\)/);
|
||||
test('the update poll only checks, it never downloads or reloads underneath the user', () => {
|
||||
const poll = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'DesktopUpdatePoll.ts'), 'utf8');
|
||||
assert.match(source, /check: \(\) => checkDesktopUpdateNow\(\),/);
|
||||
assert.doesNotMatch(
|
||||
poll,
|
||||
/installPending|reloadIgnoringCache|refreshModuleRoots|activateMergedForRendererReload/,
|
||||
@@ -208,12 +205,9 @@ describe('Bootstrap entry point', () => {
|
||||
);
|
||||
assert.doesNotMatch(source, /reloadIgnoringCache/);
|
||||
});
|
||||
test('a module poll that went quiet says so when it comes back', () => {
|
||||
assert.match(
|
||||
source,
|
||||
/if \(failing\) \{\n\t+failing = false;\n\t+logger\.info\('The module poll recovered'\);\n\t+\}/,
|
||||
'The failure is logged once and then suppressed until recovery. Without a line on the way back, a reader of the log cannot tell a poll that recovered from one that is still failing, which is the whole question that warning raises.',
|
||||
);
|
||||
|
||||
test('a reload of the renderer checks for an update right away', () => {
|
||||
assert.match(source, /observeRendererLaunchConfirmed\(wakeDesktopUpdatePoll\);/);
|
||||
});
|
||||
|
||||
test('a minimized login launch converges the modules without flashing the splash', () => {
|
||||
|
||||
@@ -71,6 +71,50 @@ describe('DesktopUpdateGate', () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
test('a background poll joins a check already running instead of starting another', async () => {
|
||||
const pending = deferred();
|
||||
const calls = [];
|
||||
armDesktopUpdate({
|
||||
check: async (manual) => {
|
||||
calls.push(manual);
|
||||
await pending.promise;
|
||||
return {shellNewer: false, modulesChanged: false};
|
||||
},
|
||||
start: async () => {},
|
||||
});
|
||||
|
||||
const first = checkDesktopUpdateNow();
|
||||
const second = checkDesktopUpdateNow();
|
||||
pending.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
});
|
||||
|
||||
test('a manual check never settles for the answer of a background poll that was already running', async () => {
|
||||
const pending = deferred();
|
||||
const calls = [];
|
||||
armDesktopUpdate({
|
||||
check: async (manual) => {
|
||||
calls.push(manual);
|
||||
if (!manual) {
|
||||
await pending.promise;
|
||||
return {shellNewer: false, modulesChanged: false};
|
||||
}
|
||||
return {shellNewer: true, modulesChanged: false};
|
||||
},
|
||||
start: async () => {},
|
||||
});
|
||||
|
||||
const background = checkDesktopUpdateNow();
|
||||
const manual = checkDesktopUpdateNow({manual: true});
|
||||
pending.resolve();
|
||||
await background;
|
||||
|
||||
assert.deepEqual(await manual, {available: true, updating: false});
|
||||
assert.deepEqual(calls, [false, true]);
|
||||
});
|
||||
|
||||
test('a shell update that could not finish stays available for the next click', async () => {
|
||||
armDesktopUpdate({
|
||||
check: async () => ({shellNewer: true, modulesChanged: false}),
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface DesktopUpdateCheck {
|
||||
}
|
||||
|
||||
interface DesktopUpdateController {
|
||||
readonly check: () => Promise<DesktopUpdateCheck>;
|
||||
readonly check: (manual: boolean) => Promise<DesktopUpdateCheck>;
|
||||
readonly start: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface DesktopUpdateGateState {
|
||||
controller: DesktopUpdateController | null;
|
||||
lastCheck: DesktopUpdateCheck | null;
|
||||
running: Promise<void> | null;
|
||||
checking: {readonly manual: boolean; readonly settled: Promise<void>} | null;
|
||||
notifiedAvailable: boolean;
|
||||
notifiedUpdating: boolean;
|
||||
readonly listeners: Set<DesktopUpdateStateListener>;
|
||||
@@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = {
|
||||
controller: null,
|
||||
lastCheck: null,
|
||||
running: null,
|
||||
checking: null,
|
||||
notifiedAvailable: false,
|
||||
notifiedUpdating: false,
|
||||
listeners: new Set(),
|
||||
@@ -76,14 +78,37 @@ export function publishDesktopUpdateCheck(check: DesktopUpdateCheck): void {
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function checkDesktopUpdateNow(): Promise<DesktopUpdateState> {
|
||||
export async function checkDesktopUpdateNow({
|
||||
manual = false,
|
||||
}: {
|
||||
readonly manual?: boolean;
|
||||
} = {}): Promise<DesktopUpdateState> {
|
||||
const pending = gateState.checking;
|
||||
if (pending != null) {
|
||||
if (pending.manual || !manual) {
|
||||
await pending.settled;
|
||||
return getDesktopUpdateState();
|
||||
}
|
||||
await pending.settled.catch(() => {});
|
||||
return await checkDesktopUpdateNow({manual});
|
||||
}
|
||||
const controller = gateState.controller;
|
||||
if (controller == null || gateState.running != null) {
|
||||
return getDesktopUpdateState();
|
||||
}
|
||||
const check = await controller.check();
|
||||
if (gateState.running == null) {
|
||||
publishDesktopUpdateCheck(check);
|
||||
const settled = controller.check(manual).then((check) => {
|
||||
if (gateState.running == null) {
|
||||
publishDesktopUpdateCheck(check);
|
||||
}
|
||||
});
|
||||
const checking = {manual, settled};
|
||||
gateState.checking = checking;
|
||||
try {
|
||||
await settled;
|
||||
} finally {
|
||||
if (gateState.checking === checking) {
|
||||
gateState.checking = null;
|
||||
}
|
||||
}
|
||||
return getDesktopUpdateState();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {describe, test} from 'node:test';
|
||||
import './LocalAppTestSupport.test.mjs';
|
||||
|
||||
const {DesktopUpdatePoll} = await import('@electron/main/DesktopUpdatePoll');
|
||||
|
||||
const PLAN = Object.freeze({intervalMs: 90_000, jitterRatio: 0.15});
|
||||
|
||||
function settle() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
function harness(results) {
|
||||
const timers = [];
|
||||
const logs = [];
|
||||
let checks = 0;
|
||||
let release = null;
|
||||
const poll = new DesktopUpdatePoll({
|
||||
plan: PLAN,
|
||||
random: () => 0.5,
|
||||
check: () => {
|
||||
const result = results[Math.min(checks, results.length - 1)];
|
||||
checks += 1;
|
||||
if (result === 'hold') {
|
||||
return new Promise((resolve) => {
|
||||
release = () => resolve({available: false, updating: false});
|
||||
});
|
||||
}
|
||||
return result instanceof Error ? Promise.reject(result) : Promise.resolve(result);
|
||||
},
|
||||
logger: {
|
||||
info: (message) => logs.push(['info', message]),
|
||||
warn: (message) => logs.push(['warn', message]),
|
||||
},
|
||||
setTimer: (callback, delayMs) => {
|
||||
const timer = {callback, delayMs, cleared: false};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
},
|
||||
clearTimer: (timer) => {
|
||||
timer.cleared = true;
|
||||
},
|
||||
});
|
||||
const fire = async () => {
|
||||
const timer = timers.findLast((candidate) => !candidate.cleared);
|
||||
timer.cleared = true;
|
||||
timer.callback();
|
||||
await settle();
|
||||
};
|
||||
return {
|
||||
poll,
|
||||
timers,
|
||||
logs,
|
||||
fire,
|
||||
checks: () => checks,
|
||||
release: async () => {
|
||||
release();
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NOTHING = Object.freeze({available: false, updating: false});
|
||||
const AVAILABLE = Object.freeze({available: true, updating: false});
|
||||
|
||||
describe('DesktopUpdatePoll', () => {
|
||||
test('a healthy poll checks about every ninety seconds', async () => {
|
||||
const {poll, timers, fire, checks} = harness([NOTHING]);
|
||||
|
||||
poll.start(false);
|
||||
assert.equal(checks(), 0);
|
||||
assert.equal(timers.at(-1).delayMs, 90_000);
|
||||
|
||||
await fire();
|
||||
await fire();
|
||||
|
||||
assert.equal(checks(), 2);
|
||||
assert.equal(timers.at(-1).delayMs, 90_000);
|
||||
});
|
||||
|
||||
test('a failing poll backs off, warns once, and says so when it recovers', async () => {
|
||||
const offline = new Error('offline');
|
||||
const {poll, timers, logs, fire} = harness([offline, offline, offline, NOTHING, NOTHING]);
|
||||
|
||||
poll.start(true);
|
||||
await settle();
|
||||
const delays = [timers.at(-1).delayMs];
|
||||
await fire();
|
||||
delays.push(timers.at(-1).delayMs);
|
||||
await fire();
|
||||
delays.push(timers.at(-1).delayMs);
|
||||
await fire();
|
||||
delays.push(timers.at(-1).delayMs);
|
||||
await fire();
|
||||
|
||||
assert.deepEqual(delays, [135_000, 270_000, 540_000, 90_000]);
|
||||
assert.deepEqual(
|
||||
logs.filter(([level]) => level === 'warn').map(([, message]) => message),
|
||||
['The desktop update poll failed, backing off until it recovers'],
|
||||
);
|
||||
assert.equal(logs.filter(([, message]) => message === 'The desktop update poll recovered').length, 1);
|
||||
});
|
||||
|
||||
test('the back off never waits longer than fifteen minutes', async () => {
|
||||
const {poll, timers, fire} = harness([new Error('offline')]);
|
||||
|
||||
poll.start(true);
|
||||
await settle();
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
await fire();
|
||||
}
|
||||
|
||||
assert.ok(timers.at(-1).delayMs <= 15 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('a reopened window or a reload checks at once and restarts the interval', async () => {
|
||||
const {poll, timers, checks} = harness([NOTHING]);
|
||||
poll.start(false);
|
||||
const pending = timers.at(-1);
|
||||
|
||||
poll.wake();
|
||||
await settle();
|
||||
|
||||
assert.equal(checks(), 1);
|
||||
assert.equal(pending.cleared, true);
|
||||
assert.equal(timers.filter((timer) => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('a wake while a check runs never starts a second one', async () => {
|
||||
const {poll, checks, release, timers} = harness(['hold', NOTHING]);
|
||||
poll.start(true);
|
||||
|
||||
poll.wake();
|
||||
poll.wake();
|
||||
assert.equal(checks(), 1);
|
||||
|
||||
await release();
|
||||
assert.equal(timers.filter((timer) => !timer.cleared).length, 1);
|
||||
});
|
||||
|
||||
test('an update that stays available is logged once, not on every tick', async () => {
|
||||
const {poll, logs, fire} = harness([AVAILABLE]);
|
||||
poll.start(true);
|
||||
await settle();
|
||||
await fire();
|
||||
await fire();
|
||||
|
||||
assert.equal(
|
||||
logs.filter(([, message]) => message === 'A desktop update is available, waiting for the user to start it')
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {jitteredExponentialDelay} from '@electron/common/ExponentialBackoff';
|
||||
import {type ModulePollPlan, nextModulePollDelay} from '@electron/common/ModulePollPlan';
|
||||
import type {DesktopUpdateState} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
|
||||
const DESKTOP_UPDATE_POLL_BACKOFF_CAP_MS = 15 * 60 * 1000;
|
||||
|
||||
interface DesktopUpdatePollLogger {
|
||||
readonly info: (message: string, details?: unknown) => void;
|
||||
readonly warn: (message: string, details?: unknown) => void;
|
||||
}
|
||||
|
||||
interface DesktopUpdatePollOptions {
|
||||
readonly plan: ModulePollPlan;
|
||||
readonly check: () => Promise<DesktopUpdateState>;
|
||||
readonly logger: DesktopUpdatePollLogger;
|
||||
readonly random?: () => number;
|
||||
readonly setTimer?: (callback: () => void, delayMs: number) => unknown;
|
||||
readonly clearTimer?: (timer: unknown) => void;
|
||||
}
|
||||
|
||||
function setUnrefTimer(callback: () => void, delayMs: number): NodeJS.Timeout {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
timer.unref();
|
||||
return timer;
|
||||
}
|
||||
|
||||
export class DesktopUpdatePoll {
|
||||
private readonly options: DesktopUpdatePollOptions;
|
||||
private readonly random: () => number;
|
||||
private readonly setTimer: (callback: () => void, delayMs: number) => unknown;
|
||||
private readonly clearTimer: (timer: unknown) => void;
|
||||
private timer: unknown = null;
|
||||
private inFlight = false;
|
||||
private failures = 0;
|
||||
private available = false;
|
||||
|
||||
public constructor(options: DesktopUpdatePollOptions) {
|
||||
this.options = options;
|
||||
this.random = options.random ?? Math.random;
|
||||
this.setTimer = options.setTimer ?? setUnrefTimer;
|
||||
this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer as NodeJS.Timeout));
|
||||
}
|
||||
|
||||
public start(immediately: boolean): void {
|
||||
this.options.logger.info('Polling the desktop update feeds', {
|
||||
intervalMs: this.options.plan.intervalMs,
|
||||
jitterRatio: this.options.plan.jitterRatio,
|
||||
});
|
||||
if (immediately) {
|
||||
this.tick();
|
||||
} else {
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
public wake(): void {
|
||||
this.tick();
|
||||
}
|
||||
|
||||
private nextDelay(): number {
|
||||
const delay = nextModulePollDelay(this.options.plan, this.random);
|
||||
if (this.failures === 0) {
|
||||
return delay;
|
||||
}
|
||||
return Math.max(
|
||||
delay,
|
||||
jitteredExponentialDelay(
|
||||
this.options.plan.intervalMs,
|
||||
DESKTOP_UPDATE_POLL_BACKOFF_CAP_MS,
|
||||
this.failures,
|
||||
this.random,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
this.timer = this.setTimer(() => {
|
||||
this.timer = null;
|
||||
this.tick();
|
||||
}, this.nextDelay());
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
if (this.inFlight) {
|
||||
return;
|
||||
}
|
||||
if (this.timer != null) {
|
||||
this.clearTimer(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.inFlight = true;
|
||||
const {logger} = this.options;
|
||||
void this.options
|
||||
.check()
|
||||
.then((state) => {
|
||||
if (this.failures > 0) {
|
||||
logger.info('The desktop update poll recovered', {failures: this.failures});
|
||||
this.failures = 0;
|
||||
}
|
||||
if (state.available && !this.available) {
|
||||
logger.info('A desktop update is available, waiting for the user to start it');
|
||||
}
|
||||
this.available = state.available;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.failures += 1;
|
||||
if (this.failures === 1) {
|
||||
logger.warn('The desktop update poll failed, backing off until it recovers', error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.inFlight = false;
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let armedPoll: DesktopUpdatePoll | null = null;
|
||||
|
||||
export function armDesktopUpdatePoll(options: DesktopUpdatePollOptions, immediately: boolean): void {
|
||||
if (armedPoll != null) {
|
||||
return;
|
||||
}
|
||||
armedPoll = new DesktopUpdatePoll(options);
|
||||
armedPoll.start(immediately);
|
||||
}
|
||||
|
||||
export function wakeDesktopUpdatePoll(): void {
|
||||
armedPoll?.wake();
|
||||
}
|
||||
@@ -41,7 +41,7 @@ async function runUpdateCheck(): Promise<void> {
|
||||
for (;;) {
|
||||
let manualVersion: string | null = null;
|
||||
try {
|
||||
const state = await checkDesktopUpdateNow();
|
||||
const state = await checkDesktopUpdateNow({manual: true});
|
||||
if (state.updating === true) {
|
||||
logger.info('A desktop update is already running, leaving it to finish');
|
||||
return;
|
||||
@@ -129,7 +129,7 @@ export function startDesktopUpdateFromShell(): void {
|
||||
|
||||
export async function updateFromCommandLine(): Promise<void> {
|
||||
try {
|
||||
const state = await checkDesktopUpdateNow();
|
||||
const state = await checkDesktopUpdateNow({manual: true});
|
||||
if (!state.available) {
|
||||
logger.info('The command line asked for an update and none is waiting');
|
||||
return;
|
||||
|
||||
@@ -16,6 +16,7 @@ function harness({
|
||||
shellResults = [],
|
||||
installFails = false,
|
||||
settled = true,
|
||||
now = () => 0,
|
||||
} = {}) {
|
||||
const steps = [];
|
||||
const published = [];
|
||||
@@ -70,6 +71,7 @@ function harness({
|
||||
failures.push(failure);
|
||||
},
|
||||
logger: {info: quiet, warn: quiet, error: quiet},
|
||||
now,
|
||||
});
|
||||
if (settled) run.markLaunchSettled();
|
||||
return {run, steps, published, failures};
|
||||
@@ -144,6 +146,36 @@ describe('DesktopUpdateRun', () => {
|
||||
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'shell-update', 'reopen', 'probe', 'probe']);
|
||||
});
|
||||
|
||||
test('a manual check offers the shell again even after the platform feed said it had nothing', async () => {
|
||||
const lagging = {shellLatestVersion: '2026.1012.154032', shellNewer: true, modulesChanged: false};
|
||||
const {run} = harness({probes: [lagging], shellResults: [{reason: 'no-update', detail: null}]});
|
||||
await run.start();
|
||||
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false});
|
||||
|
||||
assert.deepEqual(
|
||||
await run.check({manual: true}),
|
||||
{shellNewer: true, modulesChanged: false},
|
||||
'one stale answer from the platform feed must not make Check for updates say up to date for the rest of the session',
|
||||
);
|
||||
assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false});
|
||||
});
|
||||
|
||||
test('the background check offers a lagging shell again once the platform feed had time to catch up', async () => {
|
||||
let clock = 0;
|
||||
const lagging = {shellLatestVersion: '2026.1012.154032', shellNewer: true, modulesChanged: false};
|
||||
const {run} = harness({
|
||||
probes: [lagging],
|
||||
shellResults: [{reason: 'no-update', detail: null}],
|
||||
now: () => clock,
|
||||
});
|
||||
await run.start();
|
||||
|
||||
clock = 9 * 60_000;
|
||||
assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false});
|
||||
clock = 10 * 60_000;
|
||||
assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false});
|
||||
});
|
||||
|
||||
test('a shell update that failed on the network is retried by the next click', async () => {
|
||||
const shell = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false};
|
||||
const {run, steps, published} = harness({
|
||||
|
||||
@@ -43,6 +43,8 @@ interface DesktopUpdateRunOptions {
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
const SHELL_FEED_BEHIND_RETRY_MS = 10 * 60_000;
|
||||
|
||||
interface FilteredProbe {
|
||||
readonly check: DesktopUpdateCheck;
|
||||
readonly shellLatestVersion: string;
|
||||
@@ -52,7 +54,7 @@ export class DesktopUpdateRun {
|
||||
private readonly options: DesktopUpdateRunOptions;
|
||||
private readonly now: () => number;
|
||||
private launchSettled = false;
|
||||
private shellFeedBehindVersion: string | null = null;
|
||||
private shellFeedBehind: {readonly version: string; readonly at: number} | null = null;
|
||||
private shellInstallFailed = false;
|
||||
private lastShellFailure: ShellSelfUpdateResult | null = null;
|
||||
private lastCheck: DesktopUpdateCheck | null = null;
|
||||
@@ -66,7 +68,10 @@ export class DesktopUpdateRun {
|
||||
this.launchSettled = true;
|
||||
}
|
||||
|
||||
public async check(): Promise<DesktopUpdateCheck> {
|
||||
public async check({manual = false}: {readonly manual?: boolean} = {}): Promise<DesktopUpdateCheck> {
|
||||
if (manual) {
|
||||
this.shellFeedBehind = null;
|
||||
}
|
||||
return (await this.probe()).check;
|
||||
}
|
||||
|
||||
@@ -188,7 +193,7 @@ export class DesktopUpdateRun {
|
||||
logger.warn('The shell self update did not restart the app', result);
|
||||
this.lastShellFailure = result.reason === 'no-update' ? null : result;
|
||||
if (result.reason === 'no-update') {
|
||||
this.shellFeedBehindVersion = probe.shellLatestVersion;
|
||||
this.shellFeedBehind = {version: probe.shellLatestVersion, at: this.now()};
|
||||
return {shellNewer: false, modulesChanged: probe.check.modulesChanged};
|
||||
}
|
||||
if (result.reason === 'install-failed') {
|
||||
@@ -201,13 +206,18 @@ export class DesktopUpdateRun {
|
||||
const probe = await this.options.probe();
|
||||
const check = {
|
||||
shellNewer:
|
||||
this.options.canSelfUpdateShell && probe.shellNewer && probe.shellLatestVersion !== this.shellFeedBehindVersion,
|
||||
this.options.canSelfUpdateShell && probe.shellNewer && !this.isShellFeedBehind(probe.shellLatestVersion),
|
||||
modulesChanged: probe.modulesChanged,
|
||||
};
|
||||
this.lastCheck = check;
|
||||
return {check, shellLatestVersion: probe.shellLatestVersion};
|
||||
}
|
||||
|
||||
private isShellFeedBehind(version: string): boolean {
|
||||
const behind = this.shellFeedBehind;
|
||||
return behind != null && behind.version === version && this.now() - behind.at < SHELL_FEED_BEHIND_RETRY_MS;
|
||||
}
|
||||
|
||||
private publish(check: DesktopUpdateCheck): void {
|
||||
this.lastCheck = check;
|
||||
this.options.publish(check);
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
parseModuleUpdateManifest,
|
||||
} from '@electron/main/ModuleManifest';
|
||||
import type {ModuleStore} from '@electron/main/ModuleStore';
|
||||
import {type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||
|
||||
const CHANNEL_MANIFEST_CACHE_FILE_NAME = 'manifest.json';
|
||||
const MODULE_MANIFEST_TIMEOUT_MS = 15000;
|
||||
const MODULE_MANIFEST_MEMO_TTL_MS = 60000;
|
||||
const MODULE_MANIFEST_MAX_BYTES = 1024 * 1024;
|
||||
const SHELL_FEED_MAX_BYTES = 64 * 1024;
|
||||
const MODULE_CACHE_FILE_MODE = 0o644;
|
||||
|
||||
export interface ModuleManifestDocument {
|
||||
@@ -93,6 +95,8 @@ export class ModuleManifestRepository {
|
||||
private memo: {readonly expiresAt: number; readonly document: ModuleManifestDocument} | null = null;
|
||||
private inFlight: Promise<ModuleManifestDocument> | null = null;
|
||||
private memoEpoch = 0;
|
||||
private validated: ModuleManifestDocument | null = null;
|
||||
private shellFeed: {readonly etag: string; readonly version: ModuleVersion} | null = null;
|
||||
|
||||
public constructor(options: ModuleManifestRepositoryOptions) {
|
||||
this.store = options.store;
|
||||
@@ -158,9 +162,45 @@ export class ModuleManifestRepository {
|
||||
await writeFileAtomically(this.manifestCachePath, document.bytes, MODULE_CACHE_FILE_MODE);
|
||||
}
|
||||
|
||||
public async fetchShellLatestVersion(): Promise<ModuleVersion> {
|
||||
const url = this.shellFeedUrl();
|
||||
const known = this.shellFeed;
|
||||
const headers: Record<string, string> = {accept: 'application/json'};
|
||||
if (known != null) {
|
||||
headers['if-none-match'] = known.etag;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImplementation(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(MODULE_MANIFEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ModuleManifestFetchError(`failed to reach ${url}`, null, {cause: error});
|
||||
}
|
||||
if (response.status === 304 && known != null) {
|
||||
return known.version;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ModuleManifestFetchError(`shell feed request returned ${response.status}`, response.status);
|
||||
}
|
||||
const bytes = await this.readBoundedBody(response, SHELL_FEED_MAX_BYTES, 'shell feed');
|
||||
const payload = JSON.parse(bytes.toString('utf8')) as {version?: unknown};
|
||||
if (typeof payload.version !== 'string') {
|
||||
throw new ModuleManifestFetchError('shell feed is missing its version', response.status);
|
||||
}
|
||||
const version = parseModuleVersion(payload.version, 'shell feed version');
|
||||
const etag = response.headers.get('etag');
|
||||
this.shellFeed = etag == null ? null : {etag, version};
|
||||
return version;
|
||||
}
|
||||
|
||||
private async readLatestDocument(): Promise<ModuleManifestDocument> {
|
||||
const etag = this.store.getState().last_manifest_etag;
|
||||
const first = await this.requestManifest(etag);
|
||||
const validated = this.validated;
|
||||
if (validated != null) {
|
||||
return (await this.requestManifest(validated.etag)) ?? validated;
|
||||
}
|
||||
const first = await this.requestManifest(this.store.getState().last_manifest_etag);
|
||||
if (first != null) {
|
||||
return first;
|
||||
}
|
||||
@@ -175,12 +215,20 @@ export class ModuleManifestRepository {
|
||||
return refetched;
|
||||
}
|
||||
|
||||
private manifestUrl(): string {
|
||||
private feedUrl(fileName: string): string {
|
||||
return new URL(
|
||||
`${this.packageOrigin}/desktop/${this.target.releaseChannel}/${this.target.platform}/${this.target.arch}/modules.json`,
|
||||
`${this.packageOrigin}/desktop/${this.target.releaseChannel}/${this.target.platform}/${this.target.arch}/${fileName}`,
|
||||
).toString();
|
||||
}
|
||||
|
||||
private manifestUrl(): string {
|
||||
return this.feedUrl('modules.json');
|
||||
}
|
||||
|
||||
private shellFeedUrl(): string {
|
||||
return this.feedUrl('latest.json');
|
||||
}
|
||||
|
||||
private async requestManifest(ifNoneMatch: string | null): Promise<ModuleManifestDocument | null> {
|
||||
const url = this.manifestUrl();
|
||||
const headers: Record<string, string> = {accept: 'application/json'};
|
||||
@@ -204,7 +252,9 @@ export class ModuleManifestRepository {
|
||||
}
|
||||
const bytes = await this.readBoundedBody(response, MODULE_MANIFEST_MAX_BYTES, 'manifest');
|
||||
parseModuleUpdateManifest(bytes, this.target);
|
||||
return {bytes, etag: response.headers.get('etag'), fromCache: false};
|
||||
const document = {bytes, etag: response.headers.get('etag'), fromCache: false};
|
||||
this.validated = document.etag == null ? null : document;
|
||||
return document;
|
||||
}
|
||||
|
||||
private async readBoundedBody(response: Response, limit: number, description: string): Promise<Buffer> {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {Buffer} from 'node:buffer';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs';
|
||||
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {rm} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -129,7 +129,14 @@ function createUpdater(store, modules, options = {}) {
|
||||
fetch: async (url, init) => {
|
||||
requests.push(url);
|
||||
const response = options.respond == null ? null : await options.respond(url, init);
|
||||
return response ?? new Response(bytes, {status: 200, headers: {etag: '"manifest-1"'}});
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
if (String(url).endsWith('/latest.json')) {
|
||||
const version = options.shellFeed ?? options.shellLatest ?? SHELL_VERSION;
|
||||
return new Response(JSON.stringify({version}), {status: 200, headers: {etag: `"feed-${version}"`}});
|
||||
}
|
||||
return new Response(bytes, {status: 200, headers: {etag: '"manifest-1"'}});
|
||||
},
|
||||
sleep: async (ms) => {
|
||||
sleeps.push(ms);
|
||||
@@ -412,7 +419,7 @@ describe('ModuleUpdater.run', () => {
|
||||
assert.equal(store.getState().boot_attempt, 2);
|
||||
});
|
||||
|
||||
test('a manifest older than the one already seen launches the verified set without retrying', async () => {
|
||||
test('a manifest older than the one already seen launches on the last accepted manifest without retrying', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
||||
@@ -423,13 +430,30 @@ describe('ModuleUpdater.run', () => {
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.equal(outcome.status, 'launching');
|
||||
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
|
||||
assert.deepEqual(sleeps, []);
|
||||
assert.deepEqual(
|
||||
reports.map((report) => report.type),
|
||||
['manifest-rollback'],
|
||||
);
|
||||
assert.deepEqual(reports, []);
|
||||
assert.equal(store.getState().manifest_high_water[0].metadataVersion, 5);
|
||||
});
|
||||
|
||||
test('an older manifest with no accepted copy to fall back on retries like an outage instead of blocking', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const storeRoot = getModuleStoreRoot(userDataPath);
|
||||
const store = await openStore(userDataPath);
|
||||
installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
||||
await store.commit({fluxer_renderer: RENDERER_SHA});
|
||||
const seen = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {metadataVersion: 5});
|
||||
assert.equal((await seen.updater.run()).status, 'launching');
|
||||
rmSync(path.join(storeRoot, 'manifest.json'));
|
||||
const {updater, sleeps, reports} = createUpdater(store, {fluxer_renderer: NEXT_RENDERER_SHA}, {metadataVersion: 4});
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
|
||||
assert.ok(sleeps.length > 0, 'a stale edge copy clears within a minute, so the launch retries before giving up');
|
||||
assert.ok(reports.every((report) => report.type === 'manifest-rollback'));
|
||||
});
|
||||
|
||||
test('a package the server no longer has demotes an optional module to its committed digest', async () => {
|
||||
@@ -501,7 +525,7 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
|
||||
assert.deepEqual(check, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true});
|
||||
assert.deepEqual(
|
||||
requests.map((url) => String(url).split('/').pop()),
|
||||
['modules.json'],
|
||||
['modules.json', 'latest.json'],
|
||||
);
|
||||
assert.deepEqual(installs, []);
|
||||
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
|
||||
@@ -521,6 +545,96 @@ describe('ModuleUpdater checks without downloading and installs only when asked'
|
||||
});
|
||||
});
|
||||
|
||||
test('a shell feed ahead of the manifest offers the newer shell', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {shellFeed: '2026.1012.154032'});
|
||||
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: '2026.1012.154032',
|
||||
shellNewer: true,
|
||||
modulesChanged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a shell feed behind the manifest never hides the shell the manifest names', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: RENDERER_SHA},
|
||||
{shellLatest: '2026.824.1', shellFeed: SHELL_VERSION},
|
||||
);
|
||||
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: '2026.824.1',
|
||||
shellNewer: true,
|
||||
modulesChanged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('an unreachable shell feed still answers from the manifest', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{respond: async (url) => (String(url).endsWith('/latest.json') ? new Response('', {status: 503}) : null)},
|
||||
);
|
||||
|
||||
assert.deepEqual(await updater.checkForUpdate(), {
|
||||
shellLatestVersion: SHELL_VERSION,
|
||||
shellNewer: false,
|
||||
modulesChanged: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('repeat checks revalidate both feeds and reuse them when nothing changed', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const conditions = [];
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{
|
||||
shellFeed: '2026.824.1',
|
||||
respond: async (url, init) => {
|
||||
const etag = init?.headers?.['if-none-match'] ?? null;
|
||||
conditions.push([String(url).split('/').pop(), etag]);
|
||||
return etag == null ? null : new Response(null, {status: 304});
|
||||
},
|
||||
},
|
||||
);
|
||||
const first = await updater.checkForUpdate();
|
||||
|
||||
const second = await updater.checkForUpdate();
|
||||
|
||||
assert.deepEqual(second, first);
|
||||
assert.deepEqual(second, {shellLatestVersion: '2026.824.1', shellNewer: true, modulesChanged: true});
|
||||
assert.deepEqual(conditions.slice(-2), [
|
||||
['modules.json', '"manifest-1"'],
|
||||
['latest.json', '"feed-2026.824.1"'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an older manifest during a background check reads as nothing new instead of failing', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
let metadataVersion = 6;
|
||||
const {updater} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{
|
||||
respond: async (url) =>
|
||||
String(url).endsWith('/modules.json')
|
||||
? new Response(manifestBytes({fluxer_renderer: NEXT_RENDERER_SHA}, {metadataVersion}), {status: 200})
|
||||
: null,
|
||||
},
|
||||
);
|
||||
const fresh = await updater.checkForUpdate();
|
||||
metadataVersion = 5;
|
||||
|
||||
const stale = await updater.checkForUpdate();
|
||||
|
||||
assert.deepEqual(stale, fresh);
|
||||
assert.deepEqual(stale, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true});
|
||||
});
|
||||
|
||||
test('a module that needs the next shell reads as a shell update, not as a module update', async () => {
|
||||
const {store} = await storeOnRenderer();
|
||||
const {updater} = createUpdater(
|
||||
|
||||
@@ -324,6 +324,7 @@ export class ModuleUpdater {
|
||||
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
||||
private manifestObservationTail: Promise<void> = Promise.resolve();
|
||||
private recordedManifestSha256: string | null = null;
|
||||
private lastAccepted: {readonly document: ModuleManifestDocument; readonly metadataVersion: number} | null = null;
|
||||
|
||||
public constructor(options: ModuleUpdaterOptions) {
|
||||
this.store = options.store;
|
||||
@@ -448,7 +449,7 @@ export class ModuleUpdater {
|
||||
try {
|
||||
const document = await this.manifestRepository.fetchLatest();
|
||||
fetched = true;
|
||||
const manifest = await this.observeManifest(document);
|
||||
const manifest = await this.acceptManifest(document);
|
||||
const startupUpdateRequired = await this.startupUpdateRequired();
|
||||
if (!startupUpdateRequired) {
|
||||
return await this.launchIfAtFloor();
|
||||
@@ -502,7 +503,9 @@ export class ModuleUpdater {
|
||||
}
|
||||
const delayMs = backoff.fail();
|
||||
const exhausted =
|
||||
backoff.reachedCeiling || isRejectedManifestError(error) || (await this.onlyForcedByShellChange());
|
||||
backoff.reachedCeiling ||
|
||||
error instanceof ModuleManifestEquivocationError ||
|
||||
(await this.onlyForcedByShellChange());
|
||||
if (exhausted || !fetched) {
|
||||
const decision = await this.planner.evaluateUnreachableLaunch(
|
||||
this.isStartupUpdateEnforced(),
|
||||
@@ -623,7 +626,7 @@ export class ModuleUpdater {
|
||||
}
|
||||
|
||||
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
|
||||
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||
return this.acceptManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||
}
|
||||
|
||||
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
|
||||
@@ -642,9 +645,19 @@ export class ModuleUpdater {
|
||||
}
|
||||
|
||||
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
|
||||
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY);
|
||||
const shellLatestVersion = manifest.shell.latestVersion.source;
|
||||
const shellNewer = compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) > 0;
|
||||
const [manifest, feedLatest] = await Promise.all([
|
||||
this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY),
|
||||
this.manifestRepository.fetchShellLatestVersion().catch((error: unknown) => {
|
||||
this.reportFailure(error);
|
||||
return null;
|
||||
}),
|
||||
]);
|
||||
const shellLatest =
|
||||
feedLatest != null && compareModuleVersions(feedLatest, manifest.shell.latestVersion) > 0
|
||||
? feedLatest
|
||||
: manifest.shell.latestVersion;
|
||||
const shellLatestVersion = shellLatest.source;
|
||||
const shellNewer = compareModuleVersions(shellLatest, this.shellVersion) > 0;
|
||||
if (this.planner.shellUpdateRequired(manifest)) {
|
||||
return {shellLatestVersion, shellNewer, modulesChanged: false};
|
||||
}
|
||||
@@ -672,7 +685,25 @@ export class ModuleUpdater {
|
||||
|
||||
private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise<DesktopModuleUpdateManifest> {
|
||||
this.manifestRepository.clearMemo();
|
||||
return await this.observeManifest(await this.manifestRepository.fetchLatest(), mode);
|
||||
return await this.acceptManifest(await this.manifestRepository.fetchLatest(), mode);
|
||||
}
|
||||
|
||||
private async acceptManifest(
|
||||
document: ModuleManifestDocument,
|
||||
mode: ModuleManifestObservationMode = ModuleManifestObservationMode.RECORD_FETCH,
|
||||
): Promise<DesktopModuleUpdateManifest> {
|
||||
try {
|
||||
return await this.observeManifest(document, mode);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ModuleManifestRollbackError)) {
|
||||
throw error;
|
||||
}
|
||||
const fallback = this.lastAccepted?.document ?? (await this.manifestRepository.readCached());
|
||||
if (fallback == null || fallback === document) {
|
||||
throw error;
|
||||
}
|
||||
return await this.observeManifest(fallback, mode);
|
||||
}
|
||||
}
|
||||
|
||||
private async collectPendingItems(
|
||||
@@ -923,6 +954,10 @@ export class ModuleUpdater {
|
||||
): Promise<DesktopModuleUpdateManifest> {
|
||||
const observe = async (): Promise<DesktopModuleUpdateManifest> => {
|
||||
const manifest = this.manifestRepository.parse(document);
|
||||
const accepted = this.lastAccepted;
|
||||
if (accepted != null && manifest.metadataVersion < accepted.metadataVersion) {
|
||||
throw new ModuleManifestRollbackError(this.manifestFeed, manifest.metadataVersion, accepted.metadataVersion);
|
||||
}
|
||||
const observation: ModuleManifestFeedObservation = {
|
||||
feed: this.manifestFeed,
|
||||
metadataVersion: manifest.metadataVersion,
|
||||
@@ -937,6 +972,7 @@ export class ModuleUpdater {
|
||||
await this.persistManifest(document, manifest, observation);
|
||||
this.recordedManifestSha256 = observation.manifestSha256;
|
||||
}
|
||||
this.lastAccepted = {document, metadataVersion: manifest.metadataVersion};
|
||||
return manifest;
|
||||
};
|
||||
const observed = this.manifestObservationTail.then(observe, observe);
|
||||
|
||||
@@ -148,7 +148,7 @@ function registerSelfUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await checkDesktopUpdateNow();
|
||||
await checkDesktopUpdateNow({manual: true});
|
||||
send(getMainWindow(), {type: 'not-available', context});
|
||||
} catch (error) {
|
||||
log.warn('Desktop update check failed', error);
|
||||
@@ -164,7 +164,7 @@ function registerManualUpdater(
|
||||
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
|
||||
if (context === 'user') {
|
||||
try {
|
||||
await checkDesktopUpdateNow();
|
||||
await checkDesktopUpdateNow({manual: true});
|
||||
} catch (error) {
|
||||
log.warn('Desktop module update check failed', error);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath
|
||||
import {resolveDesktopLandingUrl} from '@electron/main/DesktopLastRoute';
|
||||
import {DesktopRuntimeSecurity} from '@electron/main/DesktopRuntimeSecurity';
|
||||
import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray';
|
||||
import {wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll';
|
||||
import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia';
|
||||
import {isNativeGatewayAvailable} from '@electron/main/GatewaySocketNativeBoundary';
|
||||
import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions';
|
||||
@@ -856,6 +857,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
||||
});
|
||||
mainWindow.on('restore', refreshDesktopTrayMenu);
|
||||
mainWindow.on('show', refreshDesktopTrayMenu);
|
||||
mainWindow.on('show', wakeDesktopUpdatePoll);
|
||||
mainWindow.on('hide', refreshDesktopTrayMenu);
|
||||
mainWindow.on('close', (event) => {
|
||||
if (saveTimeout) clearTimeout(saveTimeout);
|
||||
|
||||
@@ -7,7 +7,7 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
fluxer_common = { path = "../fluxer_common" }
|
||||
ammonia = "4.2.0"
|
||||
ammonia = "4.2.3"
|
||||
anyhow = "1.0.104"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
encoding_rs = "0.8.41"
|
||||
|
||||
Reference in new issue
Block a user