mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-13 05:58:29 +09:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffe8f3387 | ||
|
|
4194525933 | ||
|
|
44799dafa2 | ||
|
|
30b32c9a35 | ||
|
|
d1b4b82895 | ||
|
|
9bc43c0c08 | ||
|
|
13960a0bb3 | ||
|
|
64cb159d65 | ||
|
|
d2173fe879 | ||
|
|
49eb8cdd81 | ||
|
|
b4fc1241ae | ||
|
|
54f1b37666 |
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]]
|
||||
|
||||
@@ -236,8 +236,8 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
# Optional media policies, both off by default. See the operator docs.
|
||||
#
|
||||
# CORS limits which web origins may read media. A request with no Origin is
|
||||
# always served. Add https://web.fluxer.app if people use the hosted client.
|
||||
# CORS limits which web origins may read media. A request with no Origin, or
|
||||
# from the desktop app, is always served.
|
||||
#
|
||||
# Signatures make an attachment read need a signed URL, so a copied link stops
|
||||
# working. Needs a secret from openssl rand -base64 32, first entry signs and
|
||||
@@ -247,7 +247,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
# media-proxy reads these at start, so apply with docker compose up -d
|
||||
# media-proxy. The values below are examples.
|
||||
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
|
||||
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -54,17 +54,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rightSplit {
|
||||
bottom: 0;
|
||||
inset-inline-end: 0;
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
transition: opacity 0.4s ease;
|
||||
width: auto;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.leftSplit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -126,10 +115,9 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.splashImage {
|
||||
.splashBackdrop {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
|
||||
@@ -26,7 +26,6 @@ import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scrolle
|
||||
import {isMobileExperienceEnabled} from '@app/features/ui/utils/MobileExperience';
|
||||
import {hasUnavailableElectronNativeContext} from '@app/features/ui/utils/NativeUtils';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import Window from '@app/features/window/state/Window';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
@@ -65,7 +64,6 @@ function resolveAuthLayoutContentMode({current, value}: ResolveAuthLayoutContent
|
||||
}
|
||||
|
||||
const AuthLayoutContent = observer(function AuthLayoutContent({children}: {children?: ReactNode}) {
|
||||
const {width: viewportWidth, height: viewportHeight} = Window.windowSize;
|
||||
const [splashUrl, setSplashUrl] = useState<string | null>(null);
|
||||
const [cardVariant, setCardVariant] = useState<AuthCardVariant>('default');
|
||||
const [contentMode, setContentMode] = useState<AuthLayoutContentMode | null>(null);
|
||||
@@ -102,14 +100,6 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
useLayoutEffect(() => {
|
||||
setContentMode((current) => current ?? AuthLayoutContentMode.CARD);
|
||||
}, []);
|
||||
const splashScale = useMemo(() => {
|
||||
if (!splashDimensions) return null;
|
||||
const {width, height} = splashDimensions;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
const heightScale = viewportHeight / height;
|
||||
const widthScale = viewportWidth / width;
|
||||
return Math.max(heightScale, widthScale);
|
||||
}, [splashDimensions, viewportHeight, viewportWidth]);
|
||||
const handleSetContentMode = useCallback<Dispatch<SetStateAction<AuthLayoutContentMode>>>((value) => {
|
||||
setContentMode((current) => resolveAuthLayoutContentMode({current, value}));
|
||||
}, []);
|
||||
@@ -236,11 +226,10 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
data-flx="app.auth-layout.auth-layout-content.character-background"
|
||||
>
|
||||
<AuthBackground
|
||||
className={styles.splashBackdrop}
|
||||
splashUrl={splashUrl}
|
||||
splashDimensions={splashDimensions}
|
||||
splashScale={splashScale}
|
||||
splashAlignment={splashAlignment}
|
||||
useFullCover={false}
|
||||
data-flx="app.auth-layout.auth-layout-content.auth-background"
|
||||
/>
|
||||
<flx-app-auth-layout-split
|
||||
|
||||
@@ -12,7 +12,6 @@ import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {RadioGroup, type RadioOption} from '@app/features/ui/radio_group/RadioGroup';
|
||||
import {WarningAlert} from '@app/features/ui/warning_alert/WarningAlert';
|
||||
import {ThemeSelector} from '@app/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent';
|
||||
import {LanguageSelector} from '@app/features/user/components/modals/tabs/LanguageTab';
|
||||
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
|
||||
@@ -82,10 +81,6 @@ const SIGN_IN_METHOD_USERNAME_DESC_DESCRIPTOR = msg({
|
||||
'People sign in with a username and password. No email address is collected. Each person can save a recovery kit in case they forget their password.',
|
||||
comment: 'Description for the username sign-in option in the setup wizard.',
|
||||
});
|
||||
const SIGN_IN_METHOD_USERNAME_MOBILE_NOTICE_DESCRIPTOR = msg({
|
||||
message: 'Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon.',
|
||||
comment: 'Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.',
|
||||
});
|
||||
const SIGN_IN_METHOD_EMAIL_NAME_DESCRIPTOR = msg({
|
||||
message: 'Email',
|
||||
comment: 'Setup wizard option where people sign in with an email address and password.',
|
||||
@@ -645,11 +640,6 @@ export const SignInMethodStep = observer(
|
||||
aria-label={i18n._(SIGN_IN_METHOD_TITLE_DESCRIPTOR)}
|
||||
data-flx="app.self-hosted-setup-wizard-gate.sign-in-method-radio-group"
|
||||
/>
|
||||
{usernameSignIn && (
|
||||
<WarningAlert data-flx="app.self-hosted-setup-wizard-gate.username-mobile-notice">
|
||||
{i18n._(SIGN_IN_METHOD_USERNAME_MOBILE_NOTICE_DESCRIPTOR)}
|
||||
</WarningAlert>
|
||||
)}
|
||||
{!usernameSignIn && (
|
||||
<div className={styles.usernameStyle} data-flx="app.self-hosted-setup-wizard-gate.username-style">
|
||||
<h3
|
||||
|
||||
+46
-2
@@ -7,6 +7,8 @@
|
||||
--account-profile-instance-badge-size: 3rem;
|
||||
--account-profile-state-badge-size: 2.25rem;
|
||||
--account-profile-name-color: var(--text-on-brand-primary);
|
||||
--account-profile-domain-height: 0;
|
||||
--account-profile-domain-gap: 0;
|
||||
--account-profile-name-gap: 0.625rem;
|
||||
--account-profile-name-height: 1.5rem;
|
||||
--account-profile-more-size: 1.25rem;
|
||||
@@ -41,6 +43,12 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tileSlotWithDomain {
|
||||
--account-profile-domain-height: 1.1rem;
|
||||
--account-profile-domain-gap: 0.3rem;
|
||||
--account-profile-name-gap: 0.125rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -70,7 +78,12 @@
|
||||
top: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: calc(var(--account-profile-name-gap) + var(--account-profile-name-height));
|
||||
height: calc(
|
||||
var(--account-profile-domain-gap) +
|
||||
var(--account-profile-domain-height) +
|
||||
var(--account-profile-name-gap) +
|
||||
var(--account-profile-name-height)
|
||||
);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@@ -99,7 +112,8 @@
|
||||
color: var(--text-secondary);
|
||||
transition:
|
||||
background-color 40ms ease,
|
||||
box-shadow 40ms ease;
|
||||
box-shadow 40ms ease,
|
||||
transform 120ms ease-out;
|
||||
}
|
||||
|
||||
.avatarFrame::before {
|
||||
@@ -134,6 +148,14 @@
|
||||
box-shadow: 0 0 0 0.125rem color-mix(in srgb, var(--background-modifier-hover) 66%, var(--background-secondary));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
|
||||
.tile:not(:disabled):hover
|
||||
.avatarFrame {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.tileCurrent .avatarFrame::before {
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -158,6 +180,28 @@
|
||||
0 0 0 0.375rem color-mix(in srgb, var(--status-online) 20%, transparent);
|
||||
}
|
||||
|
||||
.domainRow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
width: calc(100% - 0.5rem);
|
||||
height: var(--account-profile-domain-height);
|
||||
font-size: 0.85rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary-muted);
|
||||
gap: 0.1rem;
|
||||
margin: var(--account-profile-domain-gap) auto 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.domainRow > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nameRow {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
|
||||
+28
-1
@@ -154,6 +154,27 @@ function renderInstanceBadge({
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderInstanceDomainArgs {
|
||||
readonly instanceBadge: AccountInstanceBadgeInfo | null;
|
||||
readonly showInstance: boolean;
|
||||
}
|
||||
|
||||
function renderInstanceDomain({instanceBadge, showInstance}: RenderInstanceDomainArgs): React.ReactNode {
|
||||
if (!showInstance || instanceBadge == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.domainRow}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-profile-picker.render-instance-domain.domain-row"
|
||||
>
|
||||
<span data-flx="auth.accounts.account-switcher-modal.account-profile-picker.render-instance-domain.span">
|
||||
{instanceBadge.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderExpiredAccountStatus(isExpired: boolean, statusLabel: string): React.ReactNode {
|
||||
if (!isExpired) {
|
||||
return null;
|
||||
@@ -238,6 +259,7 @@ const AccountProfileTile = observer(function AccountProfileTile({
|
||||
const secondaryDisabled = disabled || resolveAccountPickerDisabled(secondaryActionDisabled, account, context);
|
||||
const selectLabel = resolveSelectLabel({i18n, showInstance, accountTag: tagLabel, instanceLabel});
|
||||
const removeLabel = i18n._(resolveRemoveAccountDescriptor(context.isCurrent), {accountTag: tagLabel});
|
||||
const instanceDomain = renderInstanceDomain({instanceBadge, showInstance});
|
||||
const avatarFrameRef = useRef<HTMLSpanElement | null>(null);
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
if (primaryDisabled) {
|
||||
@@ -316,7 +338,11 @@ const AccountProfileTile = observer(function AccountProfileTile({
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.tileSlot, context.isExpired && styles.tileSlotExpired)}
|
||||
className={clsx(
|
||||
styles.tileSlot,
|
||||
context.isExpired && styles.tileSlotExpired,
|
||||
instanceDomain != null && styles.tileSlotWithDomain,
|
||||
)}
|
||||
role="listitem"
|
||||
data-flx="auth.accounts.account-switcher-modal.account-profile-picker.account-profile-tile.tile-slot"
|
||||
>
|
||||
@@ -357,6 +383,7 @@ const AccountProfileTile = observer(function AccountProfileTile({
|
||||
</span>
|
||||
</button>
|
||||
</FocusRing>
|
||||
{instanceDomain}
|
||||
<div
|
||||
className={styles.nameRow}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-profile-picker.account-profile-tile.name-row"
|
||||
|
||||
+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
|
||||
|
||||
+12
@@ -59,10 +59,22 @@
|
||||
}
|
||||
|
||||
.accountsStage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shortcutTitle {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding-bottom: 0.625rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.authStage {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
+25
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {AccountProfilePicker} from '@app/features/auth/components/accounts/account_switcher_modal/AccountProfilePicker';
|
||||
import {
|
||||
type AccountSwitcherContentView,
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherModalTypes';
|
||||
import styles from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherOverlay.module.css';
|
||||
import {AccountSwitcherViewTransition} from '@app/features/auth/components/accounts/account_switcher_modal/AccountSwitcherViewTransition';
|
||||
import Keybind, {type KeybindCommand} from '@app/features/input/state/InputKeybind';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope';
|
||||
import {usePrefersReducedMotion} from '@app/features/ui/hooks/usePrefersReducedMotion';
|
||||
import {KeybindHint} from '@app/features/ui/keybind_hint/KeybindHint';
|
||||
import {AnimeButton, type AnimeTarget, createAnimeFlxElement} from '@app/features/ui/motion/AnimeElement';
|
||||
import LayerManager, {LayerType} from '@app/features/ui/state/LayerManager';
|
||||
import {getZIndexForStack} from '@app/features/ui/state/Modal';
|
||||
@@ -18,12 +21,15 @@ import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useContext, useEffect, useId, useRef} from 'react';
|
||||
|
||||
const AccountSwitcherAnimatedRoot = createAnimeFlxElement('flx-auth-account-switcher-overlay');
|
||||
const AccountSwitcherAnimatedShell = createAnimeFlxElement('flx-auth-account-switcher-overlay-shell');
|
||||
|
||||
const SYSTEM_OPEN_ACCOUNT_SWITCHER_ACTION: KeybindCommand = 'system_open_account_switcher';
|
||||
|
||||
const CLOSE_ACCOUNT_SWITCHER_DESCRIPTOR = msg({
|
||||
message: 'Close account switcher',
|
||||
comment: 'Accessible label for closing the account switcher overlay.',
|
||||
@@ -48,6 +54,24 @@ interface AccountSwitcherStageProps {
|
||||
readonly view: AccountSwitcherContentView;
|
||||
}
|
||||
|
||||
const AccountSwitcherShortcutHint = observer(function AccountSwitcherShortcutHint(): React.ReactElement | null {
|
||||
const {combo} = Keybind.getByAction(SYSTEM_OPEN_ACCOUNT_SWITCHER_ACTION);
|
||||
if (Accessibility.hideKeyboardHints || (!combo.key && !combo.code)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={styles.shortcutTitle}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-shortcut-hint.shortcut-title"
|
||||
>
|
||||
<KeybindHint
|
||||
combo={combo}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-shortcut-hint.keybind-hint"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function AccountSwitcherStage({
|
||||
accounts,
|
||||
authContent,
|
||||
@@ -73,6 +97,7 @@ function AccountSwitcherStage({
|
||||
className={flxElementClassName(styles.accountsStage)}
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-stage.accounts-stage"
|
||||
>
|
||||
<AccountSwitcherShortcutHint data-flx="auth.accounts.account-switcher-modal.account-switcher-overlay.account-switcher-stage.account-switcher-shortcut-hint" />
|
||||
{accounts.length === 0 ? (
|
||||
<flx-auth-account-switcher-overlay-empty
|
||||
className={flxElementClassName(styles.noAccounts)}
|
||||
|
||||
+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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,91 +5,49 @@ import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import type React from 'react';
|
||||
|
||||
const getSplashAlignmentStyles = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
|
||||
const getSplashObjectPosition = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
|
||||
switch (alignment) {
|
||||
case GuildSplashCardAlignment.LEFT:
|
||||
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
|
||||
return 'left bottom';
|
||||
case GuildSplashCardAlignment.RIGHT:
|
||||
return {transformOrigin: 'bottom right', objectPosition: 'right bottom'};
|
||||
return 'right bottom';
|
||||
default:
|
||||
return {transformOrigin: 'bottom center', objectPosition: 'center bottom'};
|
||||
return 'center bottom';
|
||||
}
|
||||
};
|
||||
|
||||
export interface AuthBackgroundProps {
|
||||
splashUrl: string | null;
|
||||
splashDimensions?: {width: number; height: number} | null;
|
||||
splashScale?: number | null;
|
||||
patternImageUrl?: string | null;
|
||||
className?: string;
|
||||
useFullCover?: boolean;
|
||||
splashAlignment?: ValueOf<typeof GuildSplashCardAlignment>;
|
||||
}
|
||||
|
||||
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
splashUrl,
|
||||
splashDimensions,
|
||||
splashScale,
|
||||
patternImageUrl,
|
||||
className,
|
||||
useFullCover = false,
|
||||
splashAlignment = GuildSplashCardAlignment.CENTER,
|
||||
}) => {
|
||||
const shouldShowSplash = splashUrl && splashDimensions && (useFullCover || splashScale);
|
||||
const {transformOrigin, objectPosition} = getSplashAlignmentStyles(splashAlignment);
|
||||
if (shouldShowSplash) {
|
||||
if (useFullCover) {
|
||||
return (
|
||||
<div className={className} data-flx="auth.flow.auth-background.div">
|
||||
<div style={{position: 'absolute', inset: 0}} data-flx="auth.flow.auth-background.div--2">
|
||||
<img
|
||||
src={splashUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition,
|
||||
}}
|
||||
data-flx="auth.flow.auth-background.img"
|
||||
/>
|
||||
<div className={styles.splashOverlay} data-flx="auth.flow.auth-background.splash-overlay" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (splashUrl && splashDimensions) {
|
||||
return (
|
||||
<div className={styles.rightSplit} data-flx="auth.flow.auth-background.right-split">
|
||||
<div
|
||||
className={styles.splashImage}
|
||||
<div className={className} data-flx="auth.flow.auth-background.div">
|
||||
<img
|
||||
src={splashUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: splashDimensions.width,
|
||||
height: splashDimensions.height,
|
||||
transform: `scale(${splashScale})`,
|
||||
transformOrigin,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: getSplashObjectPosition(splashAlignment),
|
||||
}}
|
||||
data-flx="auth.flow.auth-background.splash-image"
|
||||
>
|
||||
<img
|
||||
src={splashUrl}
|
||||
alt=""
|
||||
width={splashDimensions.width}
|
||||
height={splashDimensions.height}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition,
|
||||
}}
|
||||
data-flx="auth.flow.auth-background.img--2"
|
||||
/>
|
||||
<div className={styles.splashOverlay} data-flx="auth.flow.auth-background.splash-overlay--2" />
|
||||
</div>
|
||||
data-flx="auth.flow.auth-background.img"
|
||||
/>
|
||||
<div className={styles.splashOverlay} data-flx="auth.flow.auth-background.splash-overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -294,7 +294,7 @@ export const ChannelSourcePreview = observer(function ChannelSourcePreview({
|
||||
{renderChannelAvatar(channel, resolvedAvatarSize, avatarFallbackLabels)}
|
||||
<div className={styles.textContainer} data-flx="channel.channel-source-preview.text-container">
|
||||
<div className={styles.nameRow} data-flx="channel.channel-source-preview.name-row">
|
||||
{channel.guildId ? ChannelUtils.getIcon(channel, {weight: 'bold'}) : null}
|
||||
{channel.guildId ? ChannelUtils.getIcon(channel, {className: styles.channelIcon, weight: 'bold'}) : null}
|
||||
{renderNameAction()}
|
||||
{mentionCount != null && mentionCount > 0 ? (
|
||||
<MentionBadge
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "يجب أن يتكون اسم المستخدم من حرف واحد عل
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "يجب ألا يتجاوز اسم المستخدم 32 حرفًا"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "تسجيل الدخول باسم المستخدم لا يزال قيد الطرح على تطبيقات الهاتف المحمول. سيتم توفير الدعم الكامل على كل منصة قريبًا."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -1631,7 +1631,6 @@
|
||||
"Use {name} on {domain}",
|
||||
"Used in Custom mode. The Gaming and Screen share presets set their own hint.",
|
||||
"User settings, privacy review needed",
|
||||
"Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon.",
|
||||
"Username tags",
|
||||
"Usernames",
|
||||
"Usernames (recommended)",
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Потребителското име трябва да е поне 1 з
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Потребителското име трябва да е до 32 знака"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Влизането с потребителско име все още се въвежда в мобилните приложения. Пълна поддръжка на всяка платформа идва скоро."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Uživatelské jméno musí mít alespoň 1 znak"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Uživatelské jméno může mít maximálně 32 znaků"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Přihlašování pomocí uživatelského jména se na mobilních aplikacích stále zavádí. Plná podpora na všech platformách bude brzy."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Brugernavnet skal være på mindst 1 tegn"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Brugernavnet må højst være 32 tegn"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Brugernavnslogin rulles stadig ud til mobilappsene. Fuld understøttelse på alle platforme kommer snart."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Der Benutzername muss mindestens 1 Zeichen lang sein"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Der Benutzername darf höchstens 32 Zeichen lang sein"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Die Anmeldung per Benutzername wird noch für die mobilen Apps ausgerollt. Vollständige Unterstützung auf jeder Plattform folgt in Kürze."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Το όνομα χρήστη πρέπει να έχει τουλάχισ
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Το όνομα χρήστη πρέπει να έχει έως 32 χαρακτήρες"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Η σύνδεση με όνομα χρήστη εξακολουθεί να διατίθεται σταδιακά στις εφαρμογές για κινητά. Η πλήρης υποστήριξη σε κάθε πλατφόρμα έρχεται σύντομα."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Username must be at least 1 character"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Username must be at most 32 characters"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Username must be at least 1 character"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Username must be at most 32 characters"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "El nombre de usuario debe tener al menos 1 carácter"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "El nombre de usuario debe tener 32 caracteres como máximo"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "El inicio de sesión con nombre de usuario aún se está implementando en las aplicaciones móviles. Pronto habrá soporte completo en todas las plataformas."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "El nombre de usuario debe tener al menos 1 carácter"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "El nombre de usuario debe tener 32 caracteres como máximo"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "El inicio de sesión con nombre de usuario aún se está implementando en las aplicaciones móviles. Pronto habrá soporte completo en todas las plataformas."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Käyttäjänimen on oltava vähintään 1 merkki pitkä"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Käyttäjänimen pituus saa olla enintään 32 merkkiä"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Käyttäjänimellä kirjautuminen on vielä kehitysvaiheessa mobiilisovelluksissa. Täysi tuki kaikilla alustoilla on tulossa pian."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Le nom d'utilisateur doit contenir au moins 1 caractère"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Le nom d'utilisateur doit contenir au maximum 32 caractères"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "La connexion par nom d'utilisateur est encore en cours de déploiement sur les applications mobiles. La prise en charge complète sur toutes les plateformes arrivera bientôt."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "שם המשתמש חייב להכיל תו אחד לפחות"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "שם המשתמש יכול להכיל 32 תווים לכל היותר"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "כניסה באמצעות שם משתמש עדיין בפריסה לאפליקציות מובייל. תמיכה מלאה בכל הפלטפורמות תגיע בקרוב."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "यूज़रनेम कम से कम 1 वर्ण का ह
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "यूज़रनेम ज़्यादा से ज़्यादा 32 वर्णों का होना चाहिए"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "यूज़रनेम साइन-इन अभी भी मोबाइल ऐप्स में रोल आउट हो रहा है। हर प्लेटफ़ॉर्म पर पूरा सपोर्ट जल्द ही आ रहा है।"
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Korisničko ime mora imati barem 1 znak"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Korisničko ime mora imati najviše 32 znaka"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Prijava korisničkim imenom još uvijek se uvodi u mobilne aplikacije. Puna podrška na svim platformama stiže uskoro."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "A felhasználónévnek legalább 1 karakter hosszúnak kell lennie"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "A felhasználónév legfeljebb 32 karakterből állhat"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "A felhasználónévvel való bejelentkezés még csak most érhető el a mobilalkalmazásokban. Hamarosan minden platformon teljes támogatást kap."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Nama pengguna minimal 1 karakter"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Nama pengguna maksimal 32 karakter"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Masuk dengan nama pengguna masih diluncurkan ke aplikasi seluler. Dukungan penuh di setiap platform akan segera hadir."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Il nome utente deve contenere almeno 1 carattere"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Il nome utente deve contenere al massimo 32 caratteri"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "La registrazione tramite nome utente è ancora in fase di distribuzione per le app mobile. Il supporto completo su ogni piattaforma arriverà presto."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "ユーザー名は1文字以上で入力してください"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "ユーザー名は32文字以内で入力してください"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "ユーザー名でのサインインは、現在モバイルアプリ向けに順次展開中です。すべてのプラットフォームでの完全なサポートは近日中に提供されます。"
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "사용자 이름은 1자 이상이어야 합니다"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "사용자 이름은 32자 이하여야 합니다"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "사용자 이름 로그인은 아직 모바일 앱에 점진적으로 적용되고 있습니다. 모든 플랫폼에서 완벽하게 지원될 예정입니다."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37335,11 +37335,6 @@ msgstr "Naudotojo vardą turi sudaryti bent 1 simbolis"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Naudotojo vardas turi būti ne ilgesnis nei 32 simboliai"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Vartotojo vardo prisijungimas dar palaipsniui diegiamas mobiliosiose programėlėse. Visas palaikymas kiekvienoje platformoje bus netrukus."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Gebruikersnaam moet minstens 1 teken lang zijn"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Gebruikersnaam mag maximaal 32 tekens lang zijn"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Aanmelden met een gebruikersnaam wordt nog uitgerold naar de mobiele apps. Volledige ondersteuning op elk platform volgt binnenkort."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Brukernavnet må være på minst 1 tegn"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Brukernavnet kan ikke være lenger enn 32 tegn"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Brukernavninnlogging rulles fortsatt ut til mobilappene. Full støtte på alle plattformer kommer snart."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Nazwa użytkownika musi mieć co najmniej 1 znak"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Nazwa użytkownika może mieć maksymalnie 32 znaki"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Logowanie nazwą użytkownika jest wciąż wdrażane w aplikacjach mobilnych. Pełne wsparcie na każdej platformie pojawi się wkrótce."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "O nome de usuário deve ter pelo menos 1 caractere"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "O nome de usuário deve ter no máximo 32 caracteres"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "O login com nome de usuário ainda está sendo implementado nos apps móveis. O suporte completo em todas as plataformas chegará em breve."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Numele de utilizator trebuie să aibă cel puțin 1 caracter"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Numele de utilizator trebuie să aibă maximum 32 de caractere"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Autentificarea cu nume de utilizator este încă în curs de implementare pentru aplicațiile mobile. Suportul complet pe fiecare platformă va fi disponibil în curând."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Имя пользователя должно содержать хотя
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Имя пользователя не может быть длиннее 32 символов"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Регистрация по имени пользователя пока доступна не во всех мобильных приложениях. Полная поддержка на всех платформах появится скоро."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Användarnamnet måste vara minst 1 tecken långt"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Användarnamnet får vara högst 32 tecken långt"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Inloggning med användarnamn rullas fortfarande ut till mobilapparna. Fullt stöd på alla plattformar kommer snart."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "ชื่อผู้ใช้ต้องมีอย่างน้อ
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "ชื่อผู้ใช้ต้องมีความยาวไม่เกิน 32 ตัวอักษร"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "การลงชื่อเข้าใช้ด้วยชื่อผู้ใช้ยังอยู่ระหว่างการทยอยเปิดให้ใช้งานบนแอปมือถือ การรองรับเต็มรูปแบบในทุกแพลตฟอร์มกำลังจะมาเร็วๆ นี้"
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Kullanıcı adı en az 1 karakter olmalı"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Kullanıcı adı en fazla 32 karakter olmalı"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Kullanıcı adıyla oturum açma özelliği mobil uygulamalarda henüz kullanıma sunuluyor. Her platformda tam destek yakında gelecek."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Ім'я користувача має містити щонайменш
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Ім'я користувача має містити не більше 32 символів"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Вхід за іменем користувача ще розгортається для мобільних додатків. Повна підтримка на всіх платформах з’явиться незабаром."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "Tên người dùng phải có ít nhất 1 ký tự"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "Tên người dùng phải có tối đa 32 ký tự"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Tính năng đăng nhập bằng tên người dùng vẫn đang được triển khai cho ứng dụng di động. Hỗ trợ đầy đủ trên mọi nền tảng sẽ sớm ra mắt."
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "用户名至少需要 1 个字符"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "用户名最多 32 个字符"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "用户名登录仍在逐步推广到移动应用。完整平台支持即将推出。"
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -37334,11 +37334,6 @@ msgstr "使用者名稱至少須有 1 個字元"
|
||||
msgid "Username must be at most 32 characters"
|
||||
msgstr "使用者名稱最多 32 個字元"
|
||||
|
||||
#. Warning shown in the setup wizard when the username sign-in option is selected. Keep it short and plain.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username sign-in is still rolling out to the mobile apps. Full support on every platform is coming soon."
|
||||
msgstr "Username 登入功能仍在行動應用程式中逐步推出。完整支援所有平台即將推出。"
|
||||
|
||||
#. Heading above the choice of how usernames are tagged in the setup wizard.
|
||||
#: src/features/app/components/setup/SetupWizardSteps.tsx
|
||||
msgid "Username tags"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -125,7 +125,6 @@ export const InvitePagePreviewModal: React.FC<InvitePagePreviewModalProps> = obs
|
||||
splashDimensions={splashDimensions}
|
||||
patternImageUrl={patternReady ? foodPatternUrl : null}
|
||||
splashAlignment={splashAlignment}
|
||||
useFullCover={true}
|
||||
data-flx="invite.invite-page-preview-modal.background"
|
||||
/>
|
||||
<div className={styles.foreground} data-flx="invite.invite-page-preview-modal.foreground">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -280,7 +280,6 @@
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
will-change: transform;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,6 @@ finish-args:
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --talk-name=org.ayatana.indicator.application
|
||||
- --talk-name=com.canonical.indicator.application
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
- --talk-name=org.gnome.Mutter.IdleMonitor
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
build-options:
|
||||
|
||||
@@ -23,7 +23,6 @@ finish-args:
|
||||
- --talk-name=org.kde.StatusNotifierWatcher
|
||||
- --talk-name=org.ayatana.indicator.application
|
||||
- --talk-name=com.canonical.indicator.application
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
- --talk-name=org.gnome.Mutter.IdleMonitor
|
||||
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
|
||||
build-options:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,7 +19,7 @@ Every path names a channel, either `stable` or `canary`. The two are published i
|
||||
| `/arch/` | A pacman repository, one directory per architecture |
|
||||
| `/deb/` | An apt repository in the `dists` and `pool` layout |
|
||||
| `/rpm/` | A dnf repository, one directory per channel and architecture |
|
||||
| `/keys/` | The public signing key for the apt and rpm repositories |
|
||||
| `/keys/` | The public signing key for every repository |
|
||||
|
||||
## What it guarantees
|
||||
|
||||
@@ -29,6 +29,4 @@ Artifacts under a version directory never change once published, so they are cac
|
||||
|
||||
## Signing
|
||||
|
||||
The apt and rpm repositories are signed. The public key lives at `/keys/fluxer-archive-keyring.asc`, and the entrypoint files reference it, so a client verifies every package it installs.
|
||||
|
||||
The flatpak and pacman repositories are unsigned and rely on HTTPS for transport integrity. pacman still verifies each package against the SHA256 its index records, and flatpak verifies each object against its content address.
|
||||
Every repository is signed with one key. `/keys/` publishes it as `fluxer-archive-keyring.asc`, and as `fluxer-archive-keyring.gpg` for apt. [Linux repositories](/downloads/linux-repositories/) shows how each repository gets the key.
|
||||
@@ -105,7 +105,7 @@ The policy applies to a response the Media Proxy generates, and a shared cache i
|
||||
|
||||
A `static` or `relay` endpoint ignores the policy. The [upload relay](/media-proxy/upload-relay/) and the [operator and internal endpoints](/media-proxy/routes/#operator-and-internal-endpoints) are exempt from it. A method other than `GET` or `HEAD` on a read path returns 405 whatever its `Origin`.
|
||||
|
||||
The reference self-hosted deployment leaves the policy `off`. An operator who turns it on gets the instance public origin as the only entry, and [Media Proxy settings](/operator/configuration/#media-proxy-settings) states how to allow `https://web.fluxer.app` as well.
|
||||
The reference self-hosted deployment leaves the policy `off`. An operator who turns it on gets the instance public origin as the only entry.
|
||||
|
||||
## Signed attachment URLs
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,8 +6,18 @@ description: Bring up a Fluxer instance with Docker Compose, from a fresh host t
|
||||
|
||||
import InstallerChecksum from '@/components/InstallerChecksum.astro';
|
||||
|
||||
:::tip[Plutonium and donations fund Fluxer]
|
||||
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
|
||||
:::tip[The Operator Pass is coming]
|
||||
The [Operator Pass](https://fluxer.app/pass) gives you a direct line to the Fluxer team. It goes on sale in early November 2026 for $199 or €199, paid once. The price goes up on 1 January 2027. Pass holders get these perks for life.
|
||||
|
||||
- A private channel in Fluxer Labs where the team answers questions about your instance
|
||||
- The Operator role in Fluxer Labs
|
||||
- An Operator Pass profile badge
|
||||
- Invitations to test new features on your instance
|
||||
- Your avatar and name on the Fluxer website, if you want
|
||||
|
||||
The pass unlocks nothing in the software, and self-hosting stays free. If you have Visionary, you already have the pass.
|
||||
|
||||
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer).
|
||||
:::
|
||||
|
||||
By the end you have a Fluxer instance on a hostname you own, running the web app, HTTP API, Gateway, admin dashboard, uploads, search and voice signalling in Docker containers on one machine. Allow about 30 minutes, most of it spent downloading images.
|
||||
@@ -27,11 +37,11 @@ By the end you have a Fluxer instance on a hostname you own, running the web app
|
||||
| Outbound | Working DNS resolution from inside the containers |
|
||||
| Host tools | `curl` and `openssl` on Linux. macOS ships both. The Windows installer needs neither |
|
||||
|
||||
Memory limits are ceilings, not reservations. On a 4 GB host, lower the `api` and `worker` limits first. With Docker Desktop, check the RAM assigned to its Linux virtual machine rather than the host's total.
|
||||
Memory limits are ceilings. On a 4 GB host, lower the `api` and `worker` limits first. With Docker Desktop, check the RAM of its Linux virtual machine.
|
||||
|
||||
The stack is one Compose project. The edge and LiveKit are the only services that publish ports.
|
||||
The edge and LiveKit are the only services that publish ports.
|
||||
|
||||
The steps below use the bundled Caddy, which obtains and renews a TLS certificate for the hostname and needs no configuration. To put your own proxy in front, add the `docker-compose.proxy.yml` overlay. The stack then leaves 80 and 443 alone and publishes one plain HTTP port for your proxy to forward to. [Behind your own reverse proxy](/operator/reverse-proxy/) has the overlay, the requirements, and worked configuration for nginx, Caddy, Traefik, HAProxy, Apache, a Cloudflare Tunnel and Nginx Proxy Manager.
|
||||
The steps below use the bundled Caddy, which obtains and renews a TLS certificate for the hostname. To put your own proxy in front, add the `docker-compose.proxy.yml` overlay. The stack then leaves 80 and 443 alone and publishes one plain HTTP port for your proxy to forward to. [Behind your own reverse proxy](/operator/reverse-proxy/) has the overlay, the requirements, and worked configuration for nginx, Caddy, Traefik, HAProxy, Apache, a Cloudflare Tunnel and Nginx Proxy Manager.
|
||||
|
||||
## Step 1: Install Docker
|
||||
|
||||
@@ -77,7 +87,7 @@ Open these ports on the host:
|
||||
| 7881 | tcp | LiveKit media, published by the LiveKit container |
|
||||
| 7882 | udp | LiveKit media, published by the LiveKit container |
|
||||
|
||||
A machine behind a home router needs 80, 443 and both LiveKit ports forwarded to it. The certificate is issued from the public internet, and voice media arrives on the LiveKit ports directly. Running your own reverse proxy changes only the first two. The proxy holds them, and the stack binds a plain HTTP port on the loopback. 7881 and 7882 have to reach the host either way, because voice media never goes through a proxy.
|
||||
A machine behind a home router needs 80, 443 and both LiveKit ports forwarded to it. The certificate is issued from the public internet, and voice media arrives on the LiveKit ports directly. Behind your own reverse proxy, the proxy holds 80 and 443 and the stack binds a plain HTTP port on the loopback. 7881 and 7882 still have to reach the host.
|
||||
|
||||
For another port, open that one instead of 443 and read [Serving on another port](#serving-on-another-port).
|
||||
|
||||
@@ -120,9 +130,7 @@ Invoke-WebRequest https://fluxer.dev/install.ps1.sha256 -OutFile install.ps1.sha
|
||||
|
||||
Read the script before you run it. It writes `.env`, and that file holds every secret the instance has.
|
||||
|
||||
The run below uses the bundled TLS. Add `--tls proxy` when something else terminates TLS, and read [Behind your own reverse proxy](/operator/reverse-proxy/) first.
|
||||
|
||||
Then run it with the hostname from Step 2. `--email` is optional and sets the contact address on outgoing web push. It defaults to `admin@` and the hostname.
|
||||
Run it with the hostname from Step 2. The command below uses the bundled TLS. When something else terminates TLS, add `--tls proxy` after reading [Behind your own reverse proxy](/operator/reverse-proxy/).
|
||||
|
||||
```bash
|
||||
sh install.sh --domain chat.example.com
|
||||
@@ -140,7 +148,7 @@ The run prints one line per phase and ends with the URL to open.
|
||||
|
||||
### Serving on another port
|
||||
|
||||
The installer writes an `.env` for `https` on `443` and has no flag for another port. The two lines below in `.env` move the instance to another port. `FLUXER_PUBLIC_PORT` sets the port of the public address, and `FLUXER_HTTPS_PORT` sets the host port the edge publishes HTTPS on:
|
||||
The installer writes an `.env` for `https` on `443` and has no flag for another port. Set both lines below in `.env`. `FLUXER_PUBLIC_PORT` is the port of the public address, and every endpoint the services advertise follows it. `FLUXER_HTTPS_PORT` is the host port the edge publishes HTTPS on.
|
||||
|
||||
```ini
|
||||
FLUXER_PUBLIC_PORT=8443
|
||||
@@ -151,7 +159,7 @@ FLUXER_HTTPS_PORT=8443
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`FLUXER_PUBLIC_PORT` is the port of the public address, and every endpoint the services advertise follows it. It does not move what the host publishes, so `FLUXER_HTTPS_PORT` has to name the same port. Move `FLUXER_PUBLIC_SCHEME` with them when the new port serves plain HTTP, and set `FLUXER_HTTP_PORT` instead of `FLUXER_HTTPS_PORT`. `.env.example` ships beside `.env` and writes out both complete recipes.
|
||||
For plain HTTP on the new port, also set `FLUXER_PUBLIC_SCHEME=http`, and set `FLUXER_HTTP_PORT` in place of `FLUXER_HTTPS_PORT`. `.env.example` ships beside `.env` and writes out both complete recipes.
|
||||
|
||||
Host 80 stays published on the `https` recipe and still answers the ACME challenge. Let's Encrypt only ever connects to the public 80 or 443, so the certificate is issued if a router in front forwards public 80 to this host. Serve your own certificate from the `Caddyfile` when it cannot.
|
||||
|
||||
@@ -178,10 +186,6 @@ Leave `FLUXER_PUBLIC_ORIGIN` commented out, or keep it consistent with the publi
|
||||
|
||||
Run `--dry-run` first to see what a set of flags does. The PowerShell script takes the same flags as `-Domain`, `-Email` and so on, and has no `-AllowRoot`. [Upgrading](/operator/upgrading/#run-the-upgrade) has the flags that only `--update` and `--rollback` read, and the exit codes.
|
||||
|
||||
### The script is the reference
|
||||
|
||||
Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it.
|
||||
|
||||
## Step 5: Check that it works
|
||||
|
||||
Watch the stack settle:
|
||||
@@ -232,7 +236,7 @@ On a username instance the wizard shows the owner's [recovery kit](/operator/sig
|
||||
|
||||
Create the owner account with an email address at a domain you control. Email delivery is optional, and [Email](/operator/configuration/#email) sets it up at any time.
|
||||
|
||||
Once email is on, the address goes through a DNS check before the account exists. Email counts as on when the switch is set and the provider is `smtp` with a complete SMTP configuration, so `FLUXER_EMAIL_ENABLED=true` on its own does not turn the check on. The address domain has to publish an `MX` record, or an `A` or `AAAA` record as a fallback. A name that resolves on your own network alone, such as a `.lan`, `.internal` or `home.arpa` name, publishes neither, and the account form answers `That email domain cannot receive mail.` whatever the address looks like. Use a domain with public records, or leave email off.
|
||||
Once email is on, registration checks that the address domain can receive mail. A `.lan`, `.internal` or `home.arpa` address fails. Use a domain with public records, or leave email off.
|
||||
|
||||
`.env.example` ships `FLUXER_EMAIL_ENABLED=false`, which marks every address verified at creation and sends no mail at all. A forgotten owner password therefore has no email reset. Record it, and register a passkey or a second admin account before you open registration.
|
||||
|
||||
@@ -240,10 +244,14 @@ Once email is on, the address goes through a DNS check before the account exists
|
||||
|
||||
Sign in to the admin dashboard at `https://chat.example.com/admin` with the account holding the wildcard ACL. The **Instance Config** page has everything the wizard asked, plus registration mode, approvals and integration keys. **Limit Config** holds the instance limits published to clients. **Voice Regions** and **Voice Servers** come seeded, so voice needs no setup there.
|
||||
|
||||
The desktop client opens the hosted web app for its release channel, so reach your own instance in a browser. [This feedback post](https://feedback.fluxer.com/p/641) tracks that, and is being worked on.
|
||||
|
||||
[Configuration](/operator/configuration/) lists every runtime setting and the environment variable each one overrides. [Deployment availability](/http-api/deployment-availability/) lists the routes that exist only on the hosted deployment.
|
||||
|
||||
## Connect the apps
|
||||
|
||||
The web app is at your instance address. The desktop app asks for an instance first. Enter the instance address, such as `chat.example.com`. One desktop app can hold accounts on several instances and switch between them. The mobile apps take the same address.
|
||||
|
||||
The Flathub build of the desktop app may not connect to an instance yet. Its update is pending. To skip the wait, uninstall the Flathub copy and install our own Flatpak from [Linux repositories](/downloads/linux-repositories/#flatpak). It uses the same application id, `app.fluxer.Fluxer`.
|
||||
|
||||
## Backups
|
||||
|
||||
Recovery requires `.env`, a database dump and a backup of `seaweedfs-data`, which holds uploads, avatars, reports and harvests. See [Volumes and buckets](/operator/configuration/#volumes-and-buckets) for everything that needs backing up.
|
||||
@@ -302,7 +310,7 @@ cd ~/fluxer
|
||||
sh install.sh --update
|
||||
```
|
||||
|
||||
`--update` records the running images, backs up the database and the uploads, refreshes the stack files, pulls, recreates, and verifies. It leaves every secret in `.env` untouched. On Windows it is `.\install.ps1 -Update`. Run `sh install.sh --update --dry-run` first to see the plan.
|
||||
On Windows it is `.\install.ps1 -Update`.
|
||||
|
||||
[Upgrading](/operator/upgrading/) covers what the backup holds, rolling back, pinning a release and reclaiming disk.
|
||||
|
||||
|
||||
@@ -4,13 +4,23 @@ title: Behind your own reverse proxy
|
||||
description: Serving a Fluxer instance from nginx, Caddy, Traefik, HAProxy, Apache, or a tunnel.
|
||||
---
|
||||
|
||||
:::tip[Plutonium and donations fund Fluxer]
|
||||
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
|
||||
:::tip[The Operator Pass is coming]
|
||||
The [Operator Pass](https://fluxer.app/pass) gives you a direct line to the Fluxer team. It goes on sale in early November 2026 for $199 or €199, paid once. The price goes up on 1 January 2027. Pass holders get these perks for life.
|
||||
|
||||
- A private channel in Fluxer Labs where the team answers questions about your instance
|
||||
- The Operator role in Fluxer Labs
|
||||
- An Operator Pass profile badge
|
||||
- Invitations to test new features on your instance
|
||||
- Your avatar and name on the Fluxer website, if you want
|
||||
|
||||
The pass unlocks nothing in the software, and self-hosting stays free. If you have Visionary, you already have the pass.
|
||||
|
||||
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer).
|
||||
:::
|
||||
|
||||
Fluxer runs behind any proxy that terminates TLS and forwards WebSocket upgrades.
|
||||
|
||||
The stack ships Caddy and runs it by default on `80` and `443`. One extra Compose file stops that and publishes a single plain HTTP port instead. That port already routes every path to the right internal service, so the proxy in front needs one routing rule: send everything to it.
|
||||
The stack ships Caddy and runs it by default on `80` and `443`. One extra Compose file stops that and publishes a single plain HTTP port. That port routes every path to the right internal service. The proxy in front forwards everything to it unchanged.
|
||||
|
||||
## Enable the overlay
|
||||
|
||||
@@ -26,7 +36,7 @@ Or set `COMPOSE_FILE` in `.env` once and keep typing plain `docker compose` comm
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||
```
|
||||
|
||||
On a first install, `sh install.sh --tls proxy` writes that line and `FLUXER_EDGE_BIND` into `.env` for you, so there is nothing to add by hand. [Installer flags](/operator/get-started/#installer-flags) lists it with the rest.
|
||||
On a first install, `sh install.sh --tls proxy` writes that line and `FLUXER_EDGE_BIND` into `.env`. [Installer flags](/operator/get-started/#installer-flags) lists it with the rest.
|
||||
|
||||
The port binds `127.0.0.1:8080` by default. `FLUXER_EDGE_BIND` sets both the interface and the host port. Move the port when something else on the host already holds `8080`:
|
||||
|
||||
@@ -48,10 +58,6 @@ curl -i http://127.0.0.1:8080/_health
|
||||
|
||||
`/_health` returns `200 OK` from the edge itself without reaching an upstream, so it is the health check to give your proxy.
|
||||
|
||||
:::note[There is nothing to configure inside the instance]
|
||||
Forward all paths unchanged to the edge. No internal routing changes are needed.
|
||||
:::
|
||||
|
||||
## What every proxy must do
|
||||
|
||||
#### Terminate TLS and serve the hostname over HTTPS
|
||||
@@ -121,15 +127,9 @@ FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
```
|
||||
|
||||
They stay `https` on `443` even though the instance itself speaks plain HTTP on `8080`. Clients read every base URL from the discovery document the API builds out of these values.
|
||||
They stay `https` on `443` though the edge speaks plain HTTP on `8080`. Clients read every base URL from the discovery document the API builds from these values.
|
||||
|
||||
Serving on a port other than `443` means changing one of those variables:
|
||||
|
||||
```ini
|
||||
FLUXER_PUBLIC_PORT=8443
|
||||
```
|
||||
|
||||
Your proxy holds the public port, and the overlay pins the edge to plain HTTP on `8080` whatever that port is, so nothing else in `.env` moves with it.
|
||||
To serve on another public port, change `FLUXER_PUBLIC_PORT` alone. The proxy holds that port, and the overlay keeps the edge on `8080`.
|
||||
|
||||
Leave `FLUXER_PUBLIC_ORIGIN` commented out, or keep it consistent with all three settings. Conflicting addresses can break sign-in and setup. See [The public origin](/operator/configuration/#the-public-origin).
|
||||
|
||||
@@ -455,7 +455,7 @@ The edge reads the variable at container start, so apply a change with `docker c
|
||||
|
||||
## Caching in front of Fluxer
|
||||
|
||||
Media reads are the only traffic worth caching, and the instance expects no cache in front of it. Every rule below is a correctness rule rather than a tuning choice, and a cache that cannot follow one should not store media at all.
|
||||
Media reads are the only traffic worth caching, and the instance expects no cache in front of it. A cache that cannot follow every rule below must not store media.
|
||||
|
||||
#### Leave the signature parameters out of the cache key
|
||||
|
||||
@@ -489,7 +489,7 @@ Every media 200 is `public, max-age=31536000` with no `ETag` and no `Last-Modifi
|
||||
|
||||
## Turning the signature policy on
|
||||
|
||||
`FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE` is the instance switch, and a proxy that verifies signatures has one of its own. Both sides name the same three states, `off`, `report`, and `enforce`. Both read their setting once at start, so apply an instance change with `docker compose up -d media-proxy`, which replaces the container. `docker compose restart media-proxy` reuses the old environment and changes nothing.
|
||||
`FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE` is the instance switch, and a proxy that verifies signatures has one of its own. Both sides name the same three states, `off`, `report`, and `enforce`. Apply an instance change with `docker compose up -d media-proxy`.
|
||||
|
||||
Turn the policy on in this order, and stop wherever a refusal appears that you did not expect:
|
||||
|
||||
@@ -547,4 +547,4 @@ FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com
|
||||
|
||||
The policy entry is the origin alone, with no port or path. The hostname needs a valid TLS certificate and must reach the `/livekit` route on the edge with WebSocket upgrades. The media ports above never belong in the policy, because WebRTC media is not governed by `connect-src`. A separate hostname is rarely needed, since media goes to the address LiveKit advertises, not to a hostname.
|
||||
|
||||
`api` writes `FLUXER_LIVEKIT_URL` into the default voice server at start, and `app-proxy` builds the policy at container start, so apply the change with `docker compose up -d api app-proxy`. `docker compose restart` reuses the existing containers with their old environment. [Content Security Policy](/operator/configuration/#content-security-policy) has the other variables.
|
||||
`api` writes `FLUXER_LIVEKIT_URL` into the default voice server at start, and `app-proxy` builds the policy at container start. Apply the change with `docker compose up -d api app-proxy`. [Content Security Policy](/operator/configuration/#content-security-policy) has the other variables.
|
||||
@@ -4,13 +4,13 @@ title: Sign-in methods
|
||||
description: Usernames or email addresses as the way members sign in, recovery kits, and admin reset links.
|
||||
---
|
||||
|
||||
A self-hosted instance signs members in with a username or with an email address. A new instance uses usernames unless SMTP is set up. The method is chosen once, before setup is complete and before the first account exists, and it cannot change after that.
|
||||
A self-hosted instance signs members in with a username or with an email address. A new instance uses usernames unless the email switch is on. The method is chosen once, before setup is complete and before the first account exists, and it cannot change after that.
|
||||
|
||||
## Usernames
|
||||
|
||||
Usernames are the default on a new instance. Members register with a username and a password. The instance never asks for an email address and stores none. It sends no email at all and needs no SMTP server.
|
||||
|
||||
Every username is unique, and members sign in with the username alone. A username instance never uses tags, as [username tags](#username-tags) describes.
|
||||
Every username is unique, and members sign in with the username alone.
|
||||
|
||||
A forgotten password is recovered with a [recovery kit](#recovery-kits), or with an [admin reset link](#admin-reset-links) when the kit is gone too.
|
||||
|
||||
@@ -35,13 +35,13 @@ Bot accounts keep their random discriminators, and the apps and the admin dashbo
|
||||
|
||||
### Random tags
|
||||
|
||||
Every account gets a random tag of four digits, such as `alex#4821`. Several members can share a name. This is how every instance worked before tag styles existed. Random tags need email sign-in.
|
||||
Every account gets a random tag of four digits, such as `alex#4821`. Several members can share a name. Random tags need email sign-in.
|
||||
|
||||
Members can change their tag after confirming their password or second factor, where the custom tag perk allows it. A tag someone else holds returns `TAG_ALREADY_TAKEN`.
|
||||
|
||||
## Email
|
||||
|
||||
Members register and sign in with an email address. This is how every instance worked before username sign-in existed. Email verification and password recovery send mail. An email instance needs SMTP set up under [Email](/operator/configuration/#email). Without SMTP, every address is marked verified at registration and a forgotten password has no reset path.
|
||||
Members register and sign in with an email address. Email verification and password recovery send mail. An email instance needs SMTP set up under [Email](/operator/configuration/#email). Without SMTP, every address is marked verified at registration and a forgotten password has no reset path.
|
||||
|
||||
## Choosing the method
|
||||
|
||||
@@ -49,9 +49,9 @@ The setup wizard asks how members sign in, straight after the theme step and bef
|
||||
|
||||
When Email is picked, the same step asks how usernames are tagged. No tags is the recommended choice.
|
||||
|
||||
To decide before the first start, set `FLUXER_ACCOUNT_IDENTITY` to `username` or `email` in `.env`. For an email instance, set `FLUXER_TAG_STYLE` to `none` or `random`. A username instance ignores `FLUXER_TAG_STYLE`. The API reads them once, on the first start of a new instance, and stores the result. Without `FLUXER_ACCOUNT_IDENTITY`, a new instance with SMTP set up starts as an email instance with the tag style from `FLUXER_TAG_STYLE`, and any other new instance starts as a username instance. The wizard can still change the stored choice until setup is complete or the first account exists. An instance stored as already in use never offers the choice.
|
||||
To decide before the first start, set `FLUXER_ACCOUNT_IDENTITY` to `username` or `email` in `.env`. For an email instance, set `FLUXER_TAG_STYLE` to `none` or `random`. A username instance ignores `FLUXER_TAG_STYLE`. The API reads them once, on the first start of a new instance, and stores the result. Without `FLUXER_ACCOUNT_IDENTITY`, a new instance starts on email when the [email switch](/operator/configuration/#email) is on and on usernames when it is not. The wizard can still change the stored choice until setup is complete or the first account exists. An instance stored as already in use never offers the choice.
|
||||
|
||||
The method and the tag style are fixed once the instance has its first account or setup is complete. The Instance Config page in the admin dashboard shows them, read-only. Nothing changes them later.
|
||||
The Instance Config page in the admin dashboard shows the method and the tag style, read-only.
|
||||
|
||||
The hosted service at `fluxer.app` always uses email.
|
||||
|
||||
@@ -63,7 +63,7 @@ An instance that was running before this release stays on email with random tags
|
||||
- Setup is marked complete, in the database or through `FLUXER_INSTANCE_SETUP_CONFIGURED`.
|
||||
- The first admin was already granted.
|
||||
|
||||
An instance with none of these is new. It starts on the value of `FLUXER_ACCOUNT_IDENTITY` when that is set. Otherwise it starts on email when email delivery is switched on, through `FLUXER_EMAIL_ENABLED` or the admin dashboard, and on usernames when it is not. The setup wizard can still change the choice on a new instance.
|
||||
An instance with none of these is new, and starts as [Choosing the method](#choosing-the-method) describes.
|
||||
|
||||
When that check cannot read the database, the API stores nothing and the instance behaves as an email instance. The next start runs the check again.
|
||||
|
||||
@@ -108,7 +108,7 @@ A guild ban on an email instance also blocks the banned account's email address,
|
||||
|
||||
## Apps
|
||||
|
||||
The web app an instance serves supports both methods. A Fluxer app built before username sign-in only offers email sign-in, and it checks that the value looks like an email address. The mobile app is one of them until an update adds username sign-in.
|
||||
The web, desktop and mobile apps support both methods. A Fluxer app built before username sign-in only offers email sign-in, and it checks that the value looks like an email address.
|
||||
|
||||
Members on such an app sign in with the username, `@` and the instance host, such as `[email protected]`, until the app is updated. The instance host is the host of the instance web address, of any address in `FLUXER_APP_ORIGIN_ALIASES` or of `FLUXER_DOMAIN`, in any letter case and with or without a port. These apps expect a dot in the host, so an instance at a host without one, such as `localhost`, cannot be reached this way.
|
||||
|
||||
|
||||
@@ -4,8 +4,18 @@ title: Upgrading
|
||||
description: Upgrading and rolling back an instance with the installer, what the backup covers, and how to restore it.
|
||||
---
|
||||
|
||||
:::tip[Plutonium and donations fund Fluxer]
|
||||
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer). The Operator Pass is coming, and adds a direct line to the team for help and feedback.
|
||||
:::tip[The Operator Pass is coming]
|
||||
The [Operator Pass](https://fluxer.app/pass) gives you a direct line to the Fluxer team. It goes on sale in early November 2026 for $199 or €199, paid once. The price goes up on 1 January 2027. Pass holders get these perks for life.
|
||||
|
||||
- A private channel in Fluxer Labs where the team answers questions about your instance
|
||||
- The Operator role in Fluxer Labs
|
||||
- An Operator Pass profile badge
|
||||
- Invitations to test new features on your instance
|
||||
- Your avatar and name on the Fluxer website, if you want
|
||||
|
||||
The pass unlocks nothing in the software, and self-hosting stays free. If you have Visionary, you already have the pass.
|
||||
|
||||
We are grateful to everyone supporting the project through [Fluxer Plutonium](https://fluxer.app/plutonium) or [donations](https://fluxer.app/donate). All of our code is free and open source on [GitHub](https://github.com/fluxerapp/fluxer).
|
||||
:::
|
||||
|
||||
An upgrade moves an instance to a newer release. `install.sh --update` does all of it: back up, download the new stack files, pull the new images, recreate the containers that changed. The stack files are the compose files, the `Caddyfile` and `.env.example`. Allow about twenty minutes, most of it the backup and the pull.
|
||||
@@ -33,7 +43,7 @@ The stack files on `main` can run ahead of the `v1` images. The current `docker-
|
||||
|
||||
| Step | What it does |
|
||||
| --- | --- |
|
||||
| Mint | Writes any key the refreshed stack requires that `.env` does not hold, listed under [Run the upgrade](#run-the-upgrade) |
|
||||
| Mint | Writes any key the refreshed stack requires that `.env` does not hold, listed below |
|
||||
| Record | Writes the image references, the image ID each container has, and the tag `.env` names, into a new record directory |
|
||||
| Save | Copies `.env` and the stack files into that record |
|
||||
| Dump | Dumps the database with the stack still serving, and checks the custom-format header on the result |
|
||||
@@ -47,45 +57,27 @@ The stack files on `main` can run ahead of the `v1` images. The current `docker-
|
||||
|
||||
A failed run leaves the instance running on the images it already had.
|
||||
|
||||
`docker compose up -d` does not notice a changed `Caddyfile`. The script restarts `edge` by name to pick it up.
|
||||
|
||||
An instance set up before the edge served LiveKit at `/livekit` needs one edit that the upgrade does not make. Until you make it, voice does not connect. [Voice signalling moved to /livekit](#voice-signalling-moved-to-livekit) has the edit.
|
||||
|
||||
The refreshed `docker-compose.yml` renames the `caddy` service to `edge`, and the two publish the same host ports. `--remove-orphans` takes the old container down in the same call, so the new one can bind them. Without it the step stops with `Bind for 0.0.0.0:443 failed`. It also removes any container in the project whose service the loaded Compose files no longer define, so keep `COMPOSE_FILE` the same across an upgrade. [Keep a local compose change](#keep-a-local-compose-change) has the file layout that survives one. [When the compose file list names a missing file](#when-the-compose-file-list-names-a-missing-file) has the error Compose prints when `COMPOSE_FILE` names a file that does not exist.
|
||||
|
||||
The rename also moves the `caddy-data` and `caddy-config` volumes to `edge-data` and `edge-config`, so the old two are left unused and the edge requests its certificate again on the first start.
|
||||
|
||||
Upgrades change `FLUXER_IMAGE_TAG` and add missing required secrets. Existing values are preserved, except for the unusable upload-relay placeholder described below.
|
||||
Upgrades change `FLUXER_IMAGE_TAG` and write any of the three secrets below that `.env` lacks or holds as `CHANGE_ME`. Every other value in `.env` is kept.
|
||||
|
||||
The refreshed stack requires `FLUXER_ERLANG_COOKIE`. `--update` generates a missing value as 64 hex characters. Without it, Compose commands fail with `set FLUXER_ERLANG_COOKIE in .env`.
|
||||
- `FLUXER_ERLANG_COOKIE`.
|
||||
- `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64`. It must decode to at least 32 bytes.
|
||||
- `FLUXER_PROFILE_PSEUDONYM_SECRET`.
|
||||
|
||||
To write it by hand, run the generator in a shell:
|
||||
A missing one stops every Compose command with `set <name> in .env`. To write one by hand, run its line in a shell:
|
||||
|
||||
```bash
|
||||
printf '\nFLUXER_ERLANG_COOKIE=%s\n' "$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Do not paste `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` into `.env`. Compose does not execute shell commands in that file.
|
||||
|
||||
`api` and `media-proxy` require `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64` to decode to at least 32 bytes. `--update` replaces a missing or `CHANGE_ME` value with a generated secret. Both services must use the same value.
|
||||
|
||||
To write it by hand, run the generator in a shell the same way:
|
||||
|
||||
```bash
|
||||
printf '\nFLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=%s\n' "$(openssl rand -base64 32)" >> .env
|
||||
```
|
||||
|
||||
The refreshed stack requires `FLUXER_PROFILE_PSEUDONYM_SECRET` on `api`, `worker`, and `users-shard`. `--update` replaces a missing or `CHANGE_ME` value with 64 hex characters. Without it, Compose commands fail with `set FLUXER_PROFILE_PSEUDONYM_SECRET in .env`.
|
||||
|
||||
To write it by hand, run the generator in a shell the same way:
|
||||
|
||||
```bash
|
||||
printf '\nFLUXER_PROFILE_PSEUDONYM_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
## The script is the reference
|
||||
|
||||
Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it.
|
||||
Do not paste `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` into `.env`. Compose does not execute shell commands in that file.
|
||||
|
||||
## Keep a local compose change
|
||||
|
||||
@@ -97,9 +89,7 @@ The supported way to hold a local choice is a separate file, listed in `COMPOSE_
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml:local.compose.yml
|
||||
```
|
||||
|
||||
Compose merges the files left to right, so `local.compose.yml` wins over the ones before it. An upgrade refreshes the stack files and nothing else, so a file outside that set survives every upgrade untouched. A record copies `.env` and those files, and no other file from the working directory. An override file is never backed up with them, so keep it wherever the rest of the configuration lives.
|
||||
|
||||
Keep environment settings in `.env`. Upgrades preserve them apart from the tag and required-secret changes described above.
|
||||
Compose merges the files left to right, and `local.compose.yml` wins over the ones before it. An upgrade replaces only the stack files, and a file outside that set survives it. A record copies only `.env` and the stack files. Back up an override file yourself.
|
||||
|
||||
[Enable the overlay](/operator/reverse-proxy/#enable-the-overlay) has the overlay this is most often used for. [Docker labels](/operator/reverse-proxy/#docker-labels) has a worked third file.
|
||||
|
||||
@@ -316,7 +306,7 @@ docker compose exec -T postgres sh -c 'pg_restore -U $POSTGRES_USER -d $POSTGRES
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`--clean` prints notices about objects that do not exist yet, which is expected against a fresh directory. The role and database come from the `postgres` container's environment, which follows `FLUXER_POSTGRES_USERNAME` and `FLUXER_POSTGRES_DATABASE`, the same way the installer's dump reads them. These commands are for the bundled database. A database outside the stack restores with its own tools.
|
||||
`--clean` prints notices about objects that do not exist yet, which is expected against a fresh directory. These commands are for the bundled database, like the dump in [Backups](/operator/get-started/#backups). A database outside the stack restores with its own tools.
|
||||
|
||||
PowerShell has no `<` redirection. On Windows, copy the dump into the container and name it as a file:
|
||||
|
||||
@@ -339,7 +329,7 @@ docker run --rm -v fluxer_seaweedfs-data:/data \
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For a `seaweedfs-data.tar`, write `tar xf` in place of `tar xzf`. `tar` extracts over whatever is already on the volume, so the `find` empties it first. In PowerShell write `${PWD}` instead of `$PWD`. Success is an existing attachment URL answering 200 again. The `fluxer_` prefix is the Compose project name, which `docker-compose.yml` sets to `fluxer`.
|
||||
For a `seaweedfs-data.tar`, write `tar xf` in place of `tar xzf`. `tar` extracts over whatever is already on the volume. The `find` empties it first. In PowerShell write `${PWD}` in place of `$PWD`. Success is an existing attachment URL answering 200 again.
|
||||
|
||||
## Move to a new Postgres major version
|
||||
|
||||
|
||||
@@ -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