mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 04:02:41 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 |
@@ -112,6 +112,29 @@ describe('StripeCheckoutCountryEnforcement', () => {
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects a localized gift price even from inside that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('accepts the base gift price from inside a localized market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
const response = await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/gift')
|
||||
.body({price_id: MOCK_PRICES.gift1MonthUsd, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(response.url).toContain('checkout.stripe.com');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('accepts the localized price for a request that geolocates to that market', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
|
||||
@@ -113,7 +113,10 @@ export function setupSyncStripeWebhookWorker(): void {
|
||||
setInjectedWorkerService(new SyncTaskWorkerService({processStripeWebhook}));
|
||||
}
|
||||
|
||||
let originalWebhookSecretDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
|
||||
originalWebhookSecretDescriptor ??= Object.getOwnPropertyDescriptor(Config.stripe, 'webhookSecret');
|
||||
Object.defineProperty(Config.stripe, 'webhookSecret', {
|
||||
get: () => secret,
|
||||
configurable: true,
|
||||
@@ -121,6 +124,11 @@ export function mockStripeWebhookSecret(secret = 'whsec_test'): void {
|
||||
}
|
||||
|
||||
export function restoreStripeWebhookSecret(): void {
|
||||
if (originalWebhookSecretDescriptor) {
|
||||
Object.defineProperty(Config.stripe, 'webhookSecret', originalWebhookSecretDescriptor);
|
||||
originalWebhookSecretDescriptor = undefined;
|
||||
return;
|
||||
}
|
||||
delete (
|
||||
Config.stripe as {
|
||||
webhookSecret?: string;
|
||||
|
||||
@@ -32,5 +32,8 @@ export function getCurrencyPreferences(countryCode: string | null | undefined):
|
||||
}
|
||||
|
||||
export function getGiftCurrencyPreferences(countryCode: string | null | undefined): Array<Currency> {
|
||||
return getCurrencyPreferences(countryCode);
|
||||
if (countryCode && isEuEeaCountryCode(countryCode.toUpperCase())) {
|
||||
return ['EUR', 'USD'];
|
||||
}
|
||||
return ['USD', 'EUR'];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getCurrency} from '../CurrencyUtils';
|
||||
import {getCurrency, getCurrencyPreferences, getGiftCurrencyPreferences} from '../CurrencyUtils';
|
||||
|
||||
describe('getCurrency', () => {
|
||||
describe('returns USD for non-EEA countries', () => {
|
||||
@@ -162,3 +162,29 @@ describe('getCurrency', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGiftCurrencyPreferences', () => {
|
||||
it('never offers a localized currency for a localized market', () => {
|
||||
for (const country of ['BR', 'IN', 'PL', 'TR']) {
|
||||
expect(getGiftCurrencyPreferences(country)).not.toContain(getCurrencyPreferences(country)[0]);
|
||||
}
|
||||
});
|
||||
it('uses EUR for EEA countries', () => {
|
||||
expect(getGiftCurrencyPreferences('DE')).toEqual(['EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('PL')).toEqual(['EUR', 'USD']);
|
||||
});
|
||||
it('uses USD everywhere else', () => {
|
||||
expect(getGiftCurrencyPreferences('BR')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('IN')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('TR')).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences('US')).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
it('uses USD when the country is unknown', () => {
|
||||
expect(getGiftCurrencyPreferences(null)).toEqual(['USD', 'EUR']);
|
||||
expect(getGiftCurrencyPreferences(undefined)).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
it('is case insensitive', () => {
|
||||
expect(getGiftCurrencyPreferences('de')).toEqual(['EUR', 'USD']);
|
||||
expect(getGiftCurrencyPreferences('br')).toEqual(['USD', 'EUR']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
|
||||
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
|
||||
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
|
||||
import {useWindowEventListeners} from '@app/features/window/hooks/useWindowEventListeners';
|
||||
@@ -149,7 +148,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => startScreenSharePiPController(), []);
|
||||
useEffect(() => startMediaDeviceStartupPreload(), []);
|
||||
useServiceWorkerBadge();
|
||||
useKeybindManager(i18n);
|
||||
useDesktopElectronBridges();
|
||||
|
||||
-1
@@ -7,7 +7,6 @@
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
-6
@@ -49,12 +49,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
|
||||
+1
-7
@@ -19,7 +19,7 @@ interface PurchaseHistorySectionProps {
|
||||
export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = observer(
|
||||
({premiumState, loadingPortal, handleOpenCustomerPortal}) => {
|
||||
const invoices = premiumState?.billing.invoices ?? [];
|
||||
const [invoicesExpanded, setInvoicesExpanded] = useState(invoices.length > 0);
|
||||
const [invoicesExpanded, setInvoicesExpanded] = useState(false);
|
||||
return (
|
||||
<section className={styles.section} data-flx="app.plutonium.purchase-history-section.section">
|
||||
<div className={styles.card} data-flx="app.plutonium.purchase-history-section.card">
|
||||
@@ -28,12 +28,6 @@ export const PurchaseHistorySection: React.FC<PurchaseHistorySectionProps> = obs
|
||||
<h3 className={styles.title} data-flx="app.plutonium.purchase-history-section.title">
|
||||
<Trans>Purchase history</Trans>
|
||||
</h3>
|
||||
<p className={styles.description} data-flx="app.plutonium.purchase-history-section.description">
|
||||
<Trans>
|
||||
Your recent invoices. To change the payment method for your subscription, add or choose one in the
|
||||
billing portal and make it the default.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {MediaDeviceStartupPreloadManager} from '@app/features/voice/components/MediaDeviceStartupPreloadManager';
|
||||
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
|
||||
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -35,6 +36,9 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
|
||||
{isAuthenticated && (
|
||||
<MediaDeviceStartupPreloadManager data-flx="app.app-layout.media-device-startup-preload-manager" />
|
||||
)}
|
||||
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
|
||||
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
|
||||
<div
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "دورة الفوترة"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "سجل الفواتير"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "إدارة الألقاب"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "إدارة طرق الدفع"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "إعدادات الخصوصية في ملفك الشخصي محدودة
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "هداياك التي اشتريتها"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "فواتيرك الأخيرة. لتغيير طريقة الدفع لاشتراكك، أضف طريقة دفع أو اختر واحدة من بوابة الفواتير واجعلها الطريقة الافتراضية."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Платежен цикъл"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "История на плащанията"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Управление на прякори"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Управление на методите за плащане"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Настройките за поверителност на вашия
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Вашите закупени подаръци"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Вашите скорошни фактури. За да промените метода на плащане за абонамента си, добавете или изберете такъв в портала за плащане и го направете по подразбиране."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Fakturační cyklus"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Historie plateb"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Spravovat přezdívky"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Spravovat platební metody"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Vaše nastavení soukromí profilu je omezené, takže někteří lidé
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Vaše zakoupené dárky"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Vaše nedávné faktury. Chcete-li změnit platební metodu pro své předplatné, přidejte nebo vyberte ji na platebním portálu a nastavte ji jako výchozí."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Faktureringscyklus"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Faktureringshistorik"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Administrer kaldenavne"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Administrer betalingsmetoder"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Din profil er indstillet til begrænset privatliv, så nogle personer vi
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Dine købte gaver"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Dine seneste fakturaer. Hvis du vil ændre betalingsmetoden for dit abonnement, skal du tilføje eller vælge en i faktureringsportalen og gøre den til standard."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Abrechnungszeitraum"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Rechnungsverlauf"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Anzeigenamen verwalten"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Zahlungsmethoden verwalten"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Deine Profil-Privatsphäre ist eingeschränkt, sodass einige Personen di
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Deine gekauften Geschenke"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Deine letzten Rechnungen. Um die Zahlungsmethode für dein Abo zu ändern, füge eine im Abrechnungsportal hinzu oder wähle eine aus und legen sie als Standard fest."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Κύκλος χρέωσης"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Ιστορικό χρεώσεων"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Διαχείριση ψευδώνυμων"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Διαχείριση τρόπων πληρωμής"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Οι ρυθμίσεις απορρήτου του προφίλ σας ε
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Τα δώρα που αγόρασες"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Τα πρόσφατα τιμολόγιά σας. Για να αλλάξετε τον τρόπο πληρωμής της συνδρομής σας, προσθέστε ή επιλέξτε έναν στην πύλη χρέωσης και ορίστε τον ως προεπιλογή."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Billing cycle"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Billing history"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Manage nicknames"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Manage payment methods"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Your profile privacy is limited, so some people still won't see this con
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Your purchased gifts"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4890,7 +4890,7 @@ msgstr "Billing cycle"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Billing history"
|
||||
@@ -18818,7 +18818,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Manage nicknames"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Manage payment methods"
|
||||
|
||||
@@ -38326,10 +38326,6 @@ msgstr "Your profile privacy is limited, so some people still won't see this con
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Your purchased gifts"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Ciclo de facturación"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Historial de facturación"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Administrar apodos"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Administrar métodos de pago"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "La privacidad de tu perfil es limitada, por lo que algunas personas aún
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Tus regalos comprados"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Tus facturas recientes. Para cambiar el método de pago de tu suscripción, agrega o elige uno en el portal de facturación y establécelo como predeterminado."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Ciclo de facturación"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Historial de facturación"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Gestionar apodos"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Gestionar métodos de pago"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "La privacidad de tu perfil es limitada, por lo que algunas personas segu
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Tus regalos comprados"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Tus facturas recientes. Para cambiar el método de pago de tu suscripción, añade o elige uno en el portal de facturación y establécelo como predeterminado."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Laskutuskausi"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Laskutushistoria"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Hallitse lempinimiä"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Hallinnoi maksutapoja"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Profiilisi yksityisyysasetukset ovat rajoitetut, joten jotkut eivät sil
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Ostetut lahjasi"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Viimeisimmät laskusi. Jos haluat vaihtaa tilauksesi maksutapaa, lisää tai valitse maksutapa laskutusportaalissa ja aseta se oletukseksi."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Cycle de facturation"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Historique de facturation"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Gérer les surnoms"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Gérer les modes de paiement"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Les paramètres de confidentialité de votre profil sont limités, certa
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Vos cadeaux achetés"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Vos factures récentes. Pour modifier le mode de paiement de votre abonnement, ajoutez-en un ou choisissez-en un dans le portail de facturation et définissez-le comme mode par défaut."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "מחזור חיוב"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "היסטוריית חיובים"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "ניהול כינויים"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "ניהול אמצעי תשלום"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "הגדרות הפרטיות שלך מוגבלות, כך שאנשים מ
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "המתנות שרכשת"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "החשבוניות האחרונות שלך. כדי לשנות את אמצעי התשלום עבור המינוי שלך, הוסף או בחר אמצעי תשלום בפורטל החיובים והגדר אותו כברירת מחדל."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "बिलिंग साइकल"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "बिलिंग इतिहास"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "उपनाम प्रबंधित करें"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "भुगतान के तरीके प्रबंधित करें"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "आपकी प्रोफ़ाइल गोपनीयता सी
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "आपके खरीदे गए उपहार"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "आपके हाल के इनवॉइस। अपनी सदस्यता के लिए भुगतान विधि बदलने के लिए, बिलिंग पोर्टल में एक जोड़ें या चुनें और उसे डिफ़ॉल्ट बनाएं।"
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Obračunsko razdoblje"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Povijest naplate"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Upravljanje nadimcima"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Upravljanje načinima plaćanja"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Vaše postavke privatnosti profila su ograničene, pa čak i kada je oda
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Vaši kupljeni darovi"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Vaši nedavni računi. Za promjenu načina plaćanja za vašu pretplatu, dodajte ili odaberite jedan na portalu za naplatu i postavite ga kao zadani."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Számlázási ciklus"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Számlázási előzmények"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Becenevek kezelése"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Fizetési módok kezelése"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "A profilod adatvédelmi beállításai korlátozottak, ezért néhány f
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Megvásárolt ajándékaid"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Legutóbbi számlái. Az előfizetéséhez tartozó fizetési mód módosításához adjon hozzá vagy válasszon ki egyet a számlázási portálon, és állítsa be alapértelmezettként."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Siklus penagihan"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Riwayat tagihan"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Kelola nama panggilan"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Kelola metode pembayaran"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Privasi profil Anda terbatas, jadi beberapa orang tetap tidak akan melih
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Hadiah yang kamu beli"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Faktur terbaru Anda. Untuk mengubah metode pembayaran langganan Anda, tambahkan atau pilih salah satu di portal tagihan dan jadikan sebagai default."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Ciclo di fatturazione"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Cronologia pagamenti"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Gestisci soprannomi"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Gestisci i metodi di pagamento"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "La privacy del tuo profilo è limitata, quindi alcune persone non vedran
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "I tuoi regali acquistati"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Le tue fatture recenti. Per modificare il metodo di pagamento del tuo abbonamento, aggiungine o scegline uno nel portale di fatturazione e impostalo come predefinito."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "請求サイクル"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "請求履歴"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "ニックネームを管理"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "支払い方法を管理"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "プロフィールのプライバシー設定には制限があるため
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "購入したギフト"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "最近の請求書です。サブスクリプションの支払い方法を変更するには、支払いポータルで支払い方法を追加または選択し、デフォルトに設定してください。"
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "결제 주기"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "결제 내역"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "닉네임 관리"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "결제 수단 관리"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "프로필 개인 정보 설정에 제한이 있으므로 일부 사용
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "구매한 선물"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "최근 인보이스입니다. 구독 결제 수단을 변경하려면 결제 포털에서 결제 수단을 추가하거나 선택한 다음 기본값으로 설정하세요."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Atsiskaitymo ciklas"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Atsiskaitymų istorija"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Tvarkyti slapyvardžius"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Mokėjimo metodų tvarkymas"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Jūsų profilio privatumas yra ribotas, todėl net jei nustatyta „Visi
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Jūsų įsigytos dovanos"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Jūsų naujausios sąskaitos faktūros. Norėdami pakeisti prenumeratos mokėjimo būdą, pridėkite arba pasirinkite jį atsiskaitymo portale ir nustatykite kaip numatytąjį."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Factureringscyclus"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Factuurgeschiedenis"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Bijnamen beheren"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Betaalmethoden beheren"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Je profielprivacy is beperkt, dus sommige mensen zullen deze verbinding
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Je gekochte cadeaus"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Je recente facturen. Om de betaalmethode voor je abonnement te wijzigen, voeg je er een toe of kies je er een in het facturatieportaal en maak je deze de standaardmethode."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Faktureringssyklus"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Fakturahistorikk"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Administrer kallenavn"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Administrer betalingsmåter"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Personverninnstillingene dine er begrenset, så noen personer vil fortsa
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Dine kjøpte gaver"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Dine nylige fakturaer. For å endre betalingsmåten for abonnementet ditt, legg til eller velg en i faktureringsportalen og gjør den til standard."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Cykl rozliczeniowy"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Historia płatności"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Zarządzanie pseudonimami"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Zarządzaj metodami płatności"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Twoje ustawienia prywatności profilu są ograniczone, więc niektórzy
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Twoje kupione prezenty"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Twoje ostatnie faktury. Aby zmienić metodę płatności za subskrypcję, dodaj lub wybierz ją w portalu płatności i ustaw jako domyślną."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Ciclo de cobrança"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Histórico de pagamentos"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Gerenciar apelidos"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Gerenciar formas de pagamento"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Sua privacidade de perfil é limitada, então algumas pessoas ainda não
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Seus presentes comprados"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Suas faturas recentes. Para alterar o método de pagamento da sua assinatura, adicione ou escolha um no portal de cobrança e defina-o como padrão."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Ciclu de facturare"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Istoricul plăților"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Gestionează poreclele"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Gestionează metodele de plată"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Confidențialitatea profilului tău este limitată, astfel încât unele
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Cadourile tale cumpărate"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Facturile tale recente. Pentru a schimba metoda de plată pentru abonamentul tău, adaugă sau alege una în portalul de facturare și seteaz-o ca implicită."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Платежный цикл"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "История платежей"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Управление никнеймами"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Управление способами оплаты"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Настройки конфиденциальности вашего п
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Ваши купленные подарки"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Ваши недавние счета. Чтобы изменить способ оплаты подписки, добавьте или выберите его на портале оплаты и сделайте его основным."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Faktureringsperiod"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Faktureringshistorik"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Hantera smeknamn"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Hantera betalningsmetoder"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Din profilintegritet är begränsad, så vissa personer kommer fortfaran
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Dina köpta presenter"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Dina senaste fakturor. För att ändra betalningsmetod för din prenumeration, lägg till eller välj en i faktureringsportalen och gör den till standard."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "รอบบิล"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "ประวัติการเรียกเก็บเงิน"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "จัดการชื่อเล่น"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "จัดการวิธีการชำระเงิน"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "ความเป็นส่วนตัวของโปรไฟล
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "ของขวัญที่คุณซื้อ"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "ใบแจ้งหนี้ล่าสุดของคุณ หากต้องการเปลี่ยนวิธีการชำระเงินสำหรับการสมัครสมาชิก ให้เพิ่มหรือเลือกวิธีการชำระเงินในพอร์ทัลการเรียกเก็บเงินและตั้งเป็นค่าเริ่มต้น"
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Faturalandırma dönemi"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Fatura geçmişi"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Takma adları yönet"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Ödeme yöntemlerini yönet"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Profil gizliliğiniz sınırlıdır, bu nedenle bazı kişiler herkes et
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Satın aldığınız hediyeler"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Son faturalarınız. Aboneliğinizin ödeme yöntemini değiştirmek için faturalandırma portalında bir ödeme yöntemi ekleyin veya seçin ve varsayılan olarak ayarlayın."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Платіжний цикл"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Історія платежів"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Керувати нікнеймами"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Керувати способами оплати"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Ваша конфіденційність профілю обмежен
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Ваші придбані подарунки"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Ваші останні рахунки. Щоб змінити спосіб оплати підписки, додайте або виберіть його на платіжному порталі та зробіть його основним."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "Chu kỳ thanh toán"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "Lịch sử thanh toán"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "Quản lý biệt danh"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "Quản lý phương thức thanh toán"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "Quyền riêng tư hồ sơ của bạn bị giới hạn, vì vậy m
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "Quà bạn đã mua"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "Các hóa đơn gần đây của bạn. Để thay đổi phương thức thanh toán cho gói đăng ký, hãy thêm hoặc chọn một phương thức trong cổng thanh toán và đặt làm mặc định."
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "结算周期"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "账单历史"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "管理昵称"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "管理付款方式"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "您的个人资料隐私受到限制,因此即使启用了“所有人
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "你已购买的礼物"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "您最近的账单。要更改订阅的付款方式,请在账单门户中添加或选择一种付款方式并将其设为默认。"
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -4889,7 +4889,7 @@ msgstr "帳單週期"
|
||||
|
||||
#. Accordion title in the premium billing panel.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:56
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:50
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:104
|
||||
msgid "Billing history"
|
||||
msgstr "帳單記錄"
|
||||
@@ -18817,7 +18817,7 @@ msgid "Manage nicknames"
|
||||
msgstr "管理暱稱"
|
||||
|
||||
#. Billing button that opens Stripe customer portal for payment-method management only.
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:46
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:40
|
||||
msgid "Manage payment methods"
|
||||
msgstr "管理付款方式"
|
||||
|
||||
@@ -38325,10 +38325,6 @@ msgstr "您的個人資料隱私設定有限,因此即使啟用「所有人」
|
||||
msgid "Your purchased gifts"
|
||||
msgstr "您已購買的禮物"
|
||||
|
||||
#: src/features/app/components/dialogs/components/plutonium/PurchaseHistorySection.tsx:32
|
||||
msgid "Your recent invoices. To change the payment method for your subscription, add or choose one in the billing portal and make it the default."
|
||||
msgstr "您最近的帳單。若要變更訂閱的付款方式,請在帳務入口網站中新增或選擇一個付款方式,並將其設為預設。"
|
||||
|
||||
#. Short label in the region display names. Keep it concise.
|
||||
#: src/features/user/utils/UserGeo.ts:7
|
||||
msgid "your region"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {act, createElement} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const preload = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/MediaDeviceStartupPreload', () => ({
|
||||
startMediaDeviceStartupPreload: () => {
|
||||
preload.start();
|
||||
return preload.stop;
|
||||
},
|
||||
}));
|
||||
|
||||
const {MediaDeviceStartupPreloadManager} = await import(
|
||||
'@app/features/voice/components/MediaDeviceStartupPreloadManager'
|
||||
);
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.replaceChildren();
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
describe('MediaDeviceStartupPreloadManager', () => {
|
||||
test('starts the preload once loaded and stops it on unmount', async () => {
|
||||
act(() => {
|
||||
root.render(createElement(MediaDeviceStartupPreloadManager));
|
||||
});
|
||||
await vi.waitFor(() => expect(preload.start).toHaveBeenCalledTimes(1));
|
||||
expect(preload.stop).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
expect(preload.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('never starts the preload when unmounted before it finishes loading', async () => {
|
||||
act(() => {
|
||||
root.render(createElement(MediaDeviceStartupPreloadManager));
|
||||
});
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
await import('@app/features/voice/utils/MediaDeviceStartupPreload');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(preload.start).not.toHaveBeenCalled();
|
||||
expect(preload.stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
const logger = new Logger('MediaDeviceStartupPreloadManager');
|
||||
|
||||
export const MediaDeviceStartupPreloadManager: React.FC = () => {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let stopPreload: (() => void) | null = null;
|
||||
void loadLazyModule(() => import('@app/features/voice/utils/MediaDeviceStartupPreload'))
|
||||
.then(({startMediaDeviceStartupPreload}) => {
|
||||
if (disposed) return;
|
||||
stopPreload = startMediaDeviceStartupPreload();
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to load media device startup preload', {error});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
stopPreload?.();
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
clearVideoFrameWatchersForTests,
|
||||
getActiveVideoFrameWatcherCountForTests,
|
||||
watchVideoElementRenderedFrame,
|
||||
} from '@app/features/voice/components/VideoElementFrameState';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const POLL_TICK_MS = 125;
|
||||
|
||||
interface FakeVideoOptions {
|
||||
supportsFrameCallback: boolean;
|
||||
readyState?: number;
|
||||
videoWidth?: number;
|
||||
videoHeight?: number;
|
||||
}
|
||||
|
||||
interface FakeVideo {
|
||||
element: HTMLVideoElement;
|
||||
presentFrame: () => void;
|
||||
cancelledFrameCallbacks: Array<number>;
|
||||
listenerCount: () => number;
|
||||
}
|
||||
|
||||
function createFakeVideo(options: FakeVideoOptions): FakeVideo {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
const frameCallbacks = new Map<number, () => void>();
|
||||
const cancelledFrameCallbacks: Array<number> = [];
|
||||
let nextFrameCallbackHandle = 1;
|
||||
|
||||
const element: Record<string, unknown> = {
|
||||
readyState: options.readyState ?? 0,
|
||||
videoWidth: options.videoWidth ?? 0,
|
||||
videoHeight: options.videoHeight ?? 0,
|
||||
addEventListener: (type: string, listener: () => void): void => {
|
||||
const existing = listeners.get(type);
|
||||
if (existing) {
|
||||
existing.add(listener);
|
||||
return;
|
||||
}
|
||||
listeners.set(type, new Set([listener]));
|
||||
},
|
||||
removeEventListener: (type: string, listener: () => void): void => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
},
|
||||
};
|
||||
|
||||
if (options.supportsFrameCallback) {
|
||||
element.requestVideoFrameCallback = (callback: () => void): number => {
|
||||
const handle = nextFrameCallbackHandle;
|
||||
nextFrameCallbackHandle += 1;
|
||||
frameCallbacks.set(handle, callback);
|
||||
return handle;
|
||||
};
|
||||
element.cancelVideoFrameCallback = (handle: number): void => {
|
||||
cancelledFrameCallbacks.push(handle);
|
||||
frameCallbacks.delete(handle);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
element: element as unknown as HTMLVideoElement,
|
||||
presentFrame: (): void => {
|
||||
for (const [handle, callback] of frameCallbacks) {
|
||||
frameCallbacks.delete(handle);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
cancelledFrameCallbacks,
|
||||
listenerCount: (): number => {
|
||||
let total = 0;
|
||||
for (const registered of listeners.values()) {
|
||||
total += registered.size;
|
||||
}
|
||||
return total;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('watchVideoElementRenderedFrame', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal('window', globalThis);
|
||||
clearVideoFrameWatchersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearVideoFrameWatchersForTests();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('reports a frame from the element the ref switches to after watching starts', () => {
|
||||
const initial = createFakeVideo({supportsFrameCallback: true});
|
||||
const videoRef: {current: HTMLVideoElement | null} = {current: initial.element};
|
||||
const onFrame = vi.fn();
|
||||
|
||||
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
|
||||
videoRef.current = createFakeVideo({
|
||||
supportsFrameCallback: true,
|
||||
readyState: 2,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
}).element;
|
||||
|
||||
vi.advanceTimersByTime(POLL_TICK_MS);
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(POLL_TICK_MS * 4);
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('reports one frame and drops the poll target when the frame callback fires first', () => {
|
||||
const video = createFakeVideo({supportsFrameCallback: true});
|
||||
const videoRef: {current: HTMLVideoElement | null} = {current: video.element};
|
||||
const onFrame = vi.fn();
|
||||
|
||||
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
|
||||
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(1);
|
||||
|
||||
video.presentFrame();
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
|
||||
expect(video.listenerCount()).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(POLL_TICK_MS * 4);
|
||||
|
||||
expect(onFrame).toHaveBeenCalledTimes(1);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('reports nothing and leaves no interval running when disposed before any frame', () => {
|
||||
const video = createFakeVideo({supportsFrameCallback: true});
|
||||
const videoRef: {current: HTMLVideoElement | null} = {current: video.element};
|
||||
const onFrame = vi.fn();
|
||||
|
||||
const dispose = watchVideoElementRenderedFrame({videoRef, onFrame});
|
||||
dispose();
|
||||
|
||||
videoRef.current = createFakeVideo({
|
||||
supportsFrameCallback: false,
|
||||
readyState: 2,
|
||||
videoWidth: 1280,
|
||||
videoHeight: 720,
|
||||
}).element;
|
||||
vi.advanceTimersByTime(POLL_TICK_MS * 4);
|
||||
|
||||
expect(onFrame).not.toHaveBeenCalled();
|
||||
expect(getActiveVideoFrameWatcherCountForTests()).toBe(0);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(video.cancelledFrameCallbacks).toHaveLength(1);
|
||||
expect(video.listenerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -117,18 +117,17 @@ export function watchVideoElementRenderedFrame({videoRef, onFrame}: VideoElement
|
||||
onFrame();
|
||||
return () => {};
|
||||
}
|
||||
removeSharedPollTarget = addSharedVideoFramePollTarget({videoRef, onFrame: markReadyIfPossible});
|
||||
video?.addEventListener('loadeddata', markReadyIfPossible);
|
||||
video?.addEventListener('playing', markReadyIfPossible);
|
||||
video?.addEventListener('resize', markReadyIfPossible);
|
||||
if (video?.requestVideoFrameCallback) {
|
||||
frameCallbackHandle = video.requestVideoFrameCallback(() => {
|
||||
frameCallbackHandle = null;
|
||||
markPresentedFrame();
|
||||
});
|
||||
} else {
|
||||
removeSharedPollTarget = addSharedVideoFramePollTarget({videoRef, onFrame: markReadyIfPossible});
|
||||
video?.addEventListener('loadeddata', markReadyIfPossible);
|
||||
video?.addEventListener('playing', markReadyIfPossible);
|
||||
video?.addEventListener('resize', markReadyIfPossible);
|
||||
markReadyIfPossible();
|
||||
}
|
||||
markReadyIfPossible();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearWatchers();
|
||||
|
||||
@@ -552,6 +552,7 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
isPublicationDesired,
|
||||
hasSubscribedVideo: hasSubscribedScreenShareVideo,
|
||||
operationKey: isScreenShareRepublishBuffering ? `republish:${screenSharePublicationMigrationVersion}` : null,
|
||||
publication,
|
||||
videoRef,
|
||||
});
|
||||
useStoreVersion(LastFrameSnapshotCache);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useScreenShareWatchFailure} from '@app/features/voice/components/useScreenShareWatchFailure';
|
||||
import {clearVideoFrameWatchersForTests} from '@app/features/voice/components/VideoElementFrameState';
|
||||
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
|
||||
import {ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
|
||||
import type {RemoteTrackPublication} from 'livekit-client';
|
||||
import {act, useRef} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const STREAM_KEY = 'guild-a:channel-a:connection-watch';
|
||||
|
||||
function WatchProbe({publication}: {publication: RemoteTrackPublication | null}) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
useScreenShareWatchFailure({
|
||||
enabled: true,
|
||||
streamKey: STREAM_KEY,
|
||||
participantIdentity: 'user_1_connection-watch',
|
||||
trackSid: 'TR_watch',
|
||||
hasPublication: publication != null,
|
||||
isPublicationDesired: publication != null,
|
||||
hasSubscribedVideo: publication != null,
|
||||
publication,
|
||||
videoRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function watchDeadlineFor(streamKey: string) {
|
||||
const snapshot = voiceMediaGraphStore.getGraphSnapshot();
|
||||
for (const deadline of snapshot.deadlinesByKey.values()) {
|
||||
if (deadline.kind === 'watchAttempt' && deadline.streamKey === streamKey) return deadline;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useScreenShareWatchFailure', () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
voiceMediaGraphStore.reset();
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
clearVideoFrameWatchersForTests();
|
||||
});
|
||||
|
||||
it('keeps the running watch deadline when the publication arrives after the watch started', () => {
|
||||
ScreenShareWatchFailures.markWatchStarted(STREAM_KEY);
|
||||
act(() => {
|
||||
root?.render(<WatchProbe publication={null} />);
|
||||
});
|
||||
const deadline = watchDeadlineFor(STREAM_KEY);
|
||||
expect(deadline).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
root?.render(<WatchProbe publication={{trackSid: 'TR_watch'} as unknown as RemoteTrackPublication} />);
|
||||
});
|
||||
|
||||
expect(watchDeadlineFor(STREAM_KEY)).toBe(deadline);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ScreenShareWatchFailure,
|
||||
ScreenShareWatchFailures,
|
||||
} from '@app/features/voice/state/ScreenShareWatchFailures';
|
||||
import type {RemoteTrackPublication} from 'livekit-client';
|
||||
import type React from 'react';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
|
||||
@@ -23,6 +24,7 @@ interface UseScreenShareWatchFailureOptions {
|
||||
isPublicationDesired: boolean;
|
||||
hasSubscribedVideo: boolean;
|
||||
operationKey?: string | number | null;
|
||||
publication?: RemoteTrackPublication | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ export function useScreenShareWatchFailure({
|
||||
participantSid,
|
||||
trackSid,
|
||||
operationKey,
|
||||
publication,
|
||||
videoRef,
|
||||
}: UseScreenShareWatchFailureOptions): ScreenShareWatchFailureState {
|
||||
const attemptEnabled = enabled && streamKey !== '';
|
||||
@@ -101,6 +104,14 @@ export function useScreenShareWatchFailure({
|
||||
};
|
||||
}, [attemptEnabled, attemptKey, target]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!attemptEnabled) return;
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef, publication});
|
||||
return () => {
|
||||
ScreenShareWatchFailures.clearWatchTarget(streamKey);
|
||||
};
|
||||
}, [attemptEnabled, publication, streamKey, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!attemptEnabled || !attemptKey) return;
|
||||
if (isOperationBuffering) return;
|
||||
|
||||
@@ -515,6 +515,19 @@ class ScreenShareCodecNegotiation {
|
||||
return this.selectedCodec;
|
||||
}
|
||||
|
||||
getLocalCodecAdvertisements(): Array<FluxerCodecAdvertisement> {
|
||||
if (this.localCodecs.length === 0) return buildLocalCodecAdvertisements();
|
||||
return [...this.localCodecs];
|
||||
}
|
||||
|
||||
getRemoteDecodeCodecsByIdentity(): Record<string, Array<VideoCodec>> {
|
||||
const result: Record<string, Array<VideoCodec>> = {};
|
||||
for (const [identity, codecs] of this.remoteCodecsByIdentity) {
|
||||
result[identity] = [...getDecodeSet(codecs)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
setSelectionChangeListener(
|
||||
listener: ((room: Room, codec: VideoCodec, reason: NegotiationReason) => void) | null,
|
||||
): void {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type {FluxerCodecAdvertisement} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import type {HardwareEncodeReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const gpuReport: HardwareEncodeReport = {
|
||||
av1: 'hardware',
|
||||
@@ -77,13 +77,15 @@ const {
|
||||
buildLocalCodecAdvertisements,
|
||||
computeNegotiatedVideoCodec,
|
||||
} = await import('./ScreenShareCodecNegotiation');
|
||||
const {findStalledVideoDecoder} = await import('@app/features/voice/utils/ScreenShareCodecDiagnostics');
|
||||
const {findStalledVideoDecoder, scheduleScreenShareDecoderVerification} = await import(
|
||||
'@app/features/voice/utils/ScreenShareCodecDiagnostics'
|
||||
);
|
||||
const {getVideoDecoderExclusionsSync, markScreenShareDecodeFailure, resetVideoDecoderExclusions} = await import(
|
||||
'@app/features/voice/utils/VideoDecoderCapabilities'
|
||||
);
|
||||
const {resetCachedCodecCapabilities} = await import('@app/features/voice/utils/CodecCapabilityDetector');
|
||||
|
||||
function stalledScreenShareStats(mimeType: string): RTCStatsReport {
|
||||
function screenShareStats(mimeType: string, frames: {framesReceived: number; framesDecoded: number}): RTCStatsReport {
|
||||
const entries: Array<Record<string, unknown>> = [
|
||||
{id: 'codec-1', type: 'codec', mimeType},
|
||||
{
|
||||
@@ -93,13 +95,23 @@ function stalledScreenShareStats(mimeType: string): RTCStatsReport {
|
||||
codecId: 'codec-1',
|
||||
packetsReceived: 4200,
|
||||
bytesReceived: 3_500_000,
|
||||
framesReceived: 180,
|
||||
framesDecoded: 0,
|
||||
decoderImplementation: 'D3D11VideoDecoder',
|
||||
powerEfficientDecoder: true,
|
||||
...frames,
|
||||
},
|
||||
];
|
||||
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
|
||||
}
|
||||
|
||||
function stalledScreenShareStats(mimeType: string): RTCStatsReport {
|
||||
return screenShareStats(mimeType, {framesReceived: 180, framesDecoded: 0});
|
||||
}
|
||||
|
||||
function statsSequence(...reports: Array<RTCStatsReport>): () => Promise<RTCStatsReport | undefined> {
|
||||
const queue = [...reports];
|
||||
return () => Promise.resolve(queue.shift());
|
||||
}
|
||||
|
||||
function decodeAdvertisedFor(name: 'H264' | 'VP8' | 'VP9'): boolean | undefined {
|
||||
return buildLocalCodecAdvertisements().find((codec) => codec.name === name)?.decode;
|
||||
}
|
||||
@@ -164,3 +176,119 @@ describe('a stalled H.264 screen share decode', () => {
|
||||
expect(decodeAdvertisedFor('VP8')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirming a screen share decode stall before a codec is withdrawn', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetVideoDecoderExclusions();
|
||||
resetCachedCodecCapabilities();
|
||||
ScreenShareCodecNegotiation.dispose();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('withdraws nothing from a single stalled sample', async () => {
|
||||
const onDecodeFailure = vi.fn();
|
||||
scheduleScreenShareDecoderVerification(
|
||||
statsSequence(
|
||||
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
|
||||
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
|
||||
),
|
||||
undefined,
|
||||
onDecodeFailure,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(onDecodeFailure).not.toHaveBeenCalled();
|
||||
expect(decodeAdvertisedFor('H264')).toBe(true);
|
||||
});
|
||||
|
||||
it('withdraws nothing when packets arrive but no whole frames do', async () => {
|
||||
const onDecodeFailure = vi.fn();
|
||||
scheduleScreenShareDecoderVerification(
|
||||
statsSequence(
|
||||
screenShareStats('video/H264', {framesReceived: 0, framesDecoded: 0}),
|
||||
screenShareStats('video/H264', {framesReceived: 0, framesDecoded: 0}),
|
||||
),
|
||||
undefined,
|
||||
onDecodeFailure,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(onDecodeFailure).not.toHaveBeenCalled();
|
||||
expect(decodeAdvertisedFor('H264')).toBe(true);
|
||||
});
|
||||
|
||||
it('withdraws the codec exactly once when a second sample confirms the stall', async () => {
|
||||
const withdrawn: Array<string> = [];
|
||||
scheduleScreenShareDecoderVerification(
|
||||
statsSequence(
|
||||
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
|
||||
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
|
||||
),
|
||||
undefined,
|
||||
(failure) => {
|
||||
if (markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) {
|
||||
withdrawn.push(failure.codec);
|
||||
}
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(withdrawn).toEqual([]);
|
||||
expect(decodeAdvertisedFor('H264')).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(withdrawn).toEqual(['h264']);
|
||||
expect(decodeAdvertisedFor('H264')).toBe(false);
|
||||
});
|
||||
|
||||
it('withdraws nothing when the second sample decoded a frame', async () => {
|
||||
const onDecodeFailure = vi.fn();
|
||||
scheduleScreenShareDecoderVerification(
|
||||
statsSequence(
|
||||
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
|
||||
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 12}),
|
||||
),
|
||||
undefined,
|
||||
onDecodeFailure,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(onDecodeFailure).not.toHaveBeenCalled();
|
||||
expect(decodeAdvertisedFor('H264')).toBe(true);
|
||||
});
|
||||
|
||||
it('withdraws nothing when no new frames arrived between the samples', async () => {
|
||||
const onDecodeFailure = vi.fn();
|
||||
scheduleScreenShareDecoderVerification(
|
||||
statsSequence(
|
||||
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
|
||||
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
|
||||
),
|
||||
undefined,
|
||||
onDecodeFailure,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(onDecodeFailure).not.toHaveBeenCalled();
|
||||
expect(decodeAdvertisedFor('H264')).toBe(true);
|
||||
});
|
||||
|
||||
it('withdraws nothing when the track goes away between the samples', async () => {
|
||||
const onDecodeFailure = vi.fn();
|
||||
const cancel = scheduleScreenShareDecoderVerification(
|
||||
statsSequence(
|
||||
screenShareStats('video/H264', {framesReceived: 180, framesDecoded: 0}),
|
||||
screenShareStats('video/H264', {framesReceived: 240, framesDecoded: 0}),
|
||||
),
|
||||
undefined,
|
||||
onDecodeFailure,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
cancel();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(onDecodeFailure).not.toHaveBeenCalled();
|
||||
expect(decodeAdvertisedFor('H264')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {installVoiceMenuTestBootstrap} from '@app/features/ui/action_menu/items/__fixtures__/VoiceMenuTestBootstrap';
|
||||
import type {RoomEventCallbacks, RoomEventDependencies} from '@app/features/voice/engine/VoiceRoomEventBinder';
|
||||
import {ConnectionState, type Room, RoomEvent} from 'livekit-client';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
bindCodecNegotiation: vi.fn(() => vi.fn()),
|
||||
disposeCodecNegotiation: vi.fn(),
|
||||
bindScreenShareMigration: vi.fn(() => vi.fn()),
|
||||
disposeScreenShareMigration: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => {
|
||||
const descriptor = (value: unknown): unknown => (typeof value === 'string' ? {message: value} : value);
|
||||
return {msg: descriptor, t: descriptor, plural: () => '', select: () => '', selectOrdinal: () => ''};
|
||||
});
|
||||
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: () => null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? '', locale: 'en'}}),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/ScreenShareCodecNegotiation', () => ({
|
||||
default: {
|
||||
bind: mocks.bindCodecNegotiation,
|
||||
dispose: mocks.disposeCodecNegotiation,
|
||||
getSelectedCodec: vi.fn(() => null),
|
||||
publishLocalCapabilities: vi.fn(async () => null),
|
||||
selectNativeScreenShareCodec: vi.fn(() => null),
|
||||
selectScreenShareCodec: vi.fn(() => null),
|
||||
setSelectionChangeListener: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/ScreenSharePublicationMigration', () => ({
|
||||
default: {
|
||||
bind: mocks.bindScreenShareMigration,
|
||||
dispose: mocks.disposeScreenShareMigration,
|
||||
getManagedScreenSharePublications: vi.fn(() => []),
|
||||
getScreenSharePublicationsToDisable: vi.fn(() => []),
|
||||
isScreenShareBuffering: vi.fn(() => false),
|
||||
selectScreenSharePublication: vi.fn(() => null),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
version: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
installVoiceMenuTestBootstrap();
|
||||
|
||||
const {bindRoomEvents} = await import('@app/features/voice/engine/VoiceRoomEventBinder');
|
||||
|
||||
class FakeRoom {
|
||||
private handlers = new Map<string, Set<(...args: Array<unknown>) => unknown>>();
|
||||
localParticipant = {identity: 'local', on: vi.fn(), off: vi.fn()};
|
||||
remoteParticipants = new Map<string, {identity: string}>();
|
||||
|
||||
constructor(public state: ConnectionState) {}
|
||||
|
||||
on(event: string, handler: (...args: Array<unknown>) => unknown): this {
|
||||
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
|
||||
this.handlers.get(event)?.add(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
off(event: string, handler: (...args: Array<unknown>) => unknown): this {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
return this;
|
||||
}
|
||||
|
||||
async emit(event: string, ...args: Array<unknown>): Promise<void> {
|
||||
for (const handler of this.handlers.get(event) ?? []) {
|
||||
await handler(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createCallbacks(): RoomEventCallbacks {
|
||||
return {
|
||||
onConnected: vi.fn(async () => undefined),
|
||||
onDisconnected: vi.fn(),
|
||||
onReconnecting: vi.fn(),
|
||||
onReconnected: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies(options: {attemptIsStale?: boolean} = {}): RoomEventDependencies {
|
||||
return {
|
||||
connection: {
|
||||
createGuardedHandler: (_attemptId, handler) => {
|
||||
return (...args) => {
|
||||
if (options.attemptIsStale) return;
|
||||
void handler(...args);
|
||||
};
|
||||
},
|
||||
isDisconnecting: () => false,
|
||||
isUserMovePending: () => true,
|
||||
markConnected: vi.fn(),
|
||||
markDisconnected: vi.fn(),
|
||||
markReconnecting: vi.fn(),
|
||||
markReconnected: vi.fn(),
|
||||
},
|
||||
media: {
|
||||
applyAllLocalAudioPreferences: vi.fn(),
|
||||
ensureMicrophone: vi.fn(async () => undefined),
|
||||
playEntranceSound: vi.fn(async () => undefined),
|
||||
resetStreamTracking: vi.fn(),
|
||||
},
|
||||
mediaState: {
|
||||
handleLocalTrackStateChange: vi.fn(() => false),
|
||||
resetLocalMediaState: vi.fn(),
|
||||
},
|
||||
participants: {
|
||||
clear: vi.fn(),
|
||||
hydrateFromRoom: vi.fn(),
|
||||
removeParticipant: vi.fn(),
|
||||
updateActiveSpeakers: vi.fn(),
|
||||
upsertParticipant: vi.fn(),
|
||||
},
|
||||
permissions: {
|
||||
applyDeafen: vi.fn(),
|
||||
syncWithPermissionState: vi.fn(),
|
||||
},
|
||||
remoteSpeaking: {
|
||||
attachIfApplicable: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
detachByIdentity: vi.fn(),
|
||||
detachIfTrackMatches: vi.fn(),
|
||||
hydrateFromRoom: vi.fn(),
|
||||
},
|
||||
screenShare: {
|
||||
cleanupLingeringScreenShareTracks: vi.fn(async () => undefined),
|
||||
handleLocalScreenShareTrackUnpublished: vi.fn(),
|
||||
isScreenSharePublicationReplaceInFlight: () => false,
|
||||
},
|
||||
subscriptions: {
|
||||
isScreenShareSubscribed: () => false,
|
||||
reattachScreenShareAfterPublish: vi.fn(),
|
||||
reconcileSubscriptions: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindFakeRoom(room: FakeRoom, options: {attemptIsStale?: boolean} = {}): void {
|
||||
bindRoomEvents(room as unknown as Room, 1, 'guild-1', 'channel-1', createCallbacks(), createDependencies(options));
|
||||
}
|
||||
|
||||
describe('binding room events for screen share negotiation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('binds codec negotiation and publication migration to a room that is already connected', () => {
|
||||
bindFakeRoom(new FakeRoom(ConnectionState.Connected));
|
||||
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('binds neither until a room that is not connected yet reports it has connected', async () => {
|
||||
const room = new FakeRoom(ConnectionState.Disconnected);
|
||||
bindFakeRoom(room);
|
||||
expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled();
|
||||
expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled();
|
||||
room.state = ConnectionState.Connected;
|
||||
await room.emit(RoomEvent.Connected);
|
||||
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('binds once however often an already connected room reports it has connected', async () => {
|
||||
const room = new FakeRoom(ConnectionState.Connected);
|
||||
bindFakeRoom(room);
|
||||
await room.emit(RoomEvent.Connected);
|
||||
await room.emit(RoomEvent.Connected);
|
||||
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('binds again after an already connected room disconnects and connects once more', async () => {
|
||||
const room = new FakeRoom(ConnectionState.Connected);
|
||||
bindFakeRoom(room);
|
||||
await room.emit(RoomEvent.Disconnected);
|
||||
expect(mocks.disposeCodecNegotiation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.disposeScreenShareMigration).toHaveBeenCalledTimes(1);
|
||||
await room.emit(RoomEvent.Connected);
|
||||
expect(mocks.bindCodecNegotiation).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.bindScreenShareMigration).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('binds nothing to an already connected room whose connection attempt is stale', () => {
|
||||
bindFakeRoom(new FakeRoom(ConnectionState.Connected), {attemptIsStale: true});
|
||||
expect(mocks.bindCodecNegotiation).not.toHaveBeenCalled();
|
||||
expect(mocks.bindScreenShareMigration).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
RemoteTrackPublication,
|
||||
Room,
|
||||
} from 'livekit-client';
|
||||
import {ParticipantEvent, RoomEvent, Track} from 'livekit-client';
|
||||
import {ConnectionState, ParticipantEvent, RoomEvent, Track} from 'livekit-client';
|
||||
|
||||
const logger = new Logger('VoiceRoomEventBinder');
|
||||
|
||||
@@ -141,10 +141,11 @@ export function bindRoomEvents(
|
||||
): void {
|
||||
const guard = dependencies.connection.createGuardedHandler;
|
||||
const participantSpeakingDisposers = new Map<string, ParticipantSpeakingDisposer>();
|
||||
const screenShareDecoderVerificationTimers = new Map<string, NodeJS.Timeout>();
|
||||
const screenShareDecoderVerificationCancels = new Map<string, () => void>();
|
||||
const remoteTrackLifecycleDisposers = new Map<string, () => void>();
|
||||
let codecNegotiationDisposer: (() => void) | null = null;
|
||||
let screenShareMigrationDisposer: (() => void) | null = null;
|
||||
let screenShareNegotiationBound = false;
|
||||
const remoteTrackLifecycleRoleFor = (pub: RemoteTrackPublication): 'remote-screen-share' | 'remote-camera' | null => {
|
||||
if (pub.kind !== Track.Kind.Video) return null;
|
||||
if (pub.source === Track.Source.ScreenShare) return 'remote-screen-share';
|
||||
@@ -227,16 +228,16 @@ export function bindRoomEvents(
|
||||
};
|
||||
const clearScreenShareDecoderVerification = (trackSid: string | undefined): void => {
|
||||
if (!trackSid) return;
|
||||
const timer = screenShareDecoderVerificationTimers.get(trackSid);
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
screenShareDecoderVerificationTimers.delete(trackSid);
|
||||
const cancel = screenShareDecoderVerificationCancels.get(trackSid);
|
||||
if (!cancel) return;
|
||||
cancel();
|
||||
screenShareDecoderVerificationCancels.delete(trackSid);
|
||||
};
|
||||
const clearAllScreenShareDecoderVerifications = (): void => {
|
||||
for (const timer of screenShareDecoderVerificationTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
for (const cancel of screenShareDecoderVerificationCancels.values()) {
|
||||
cancel();
|
||||
}
|
||||
screenShareDecoderVerificationTimers.clear();
|
||||
screenShareDecoderVerificationCancels.clear();
|
||||
};
|
||||
const bindCodecNegotiation = (): void => {
|
||||
codecNegotiationDisposer?.();
|
||||
@@ -254,16 +255,27 @@ export function bindRoomEvents(
|
||||
ScreenSharePublicationMigration.dispose();
|
||||
screenShareMigrationDisposer = null;
|
||||
};
|
||||
const bindScreenShareNegotiation = (): void => {
|
||||
if (screenShareNegotiationBound) return;
|
||||
screenShareNegotiationBound = true;
|
||||
bindCodecNegotiation();
|
||||
bindScreenShareMigration();
|
||||
};
|
||||
const unbindScreenShareNegotiation = (): void => {
|
||||
screenShareNegotiationBound = false;
|
||||
unbindCodecNegotiation();
|
||||
unbindScreenShareMigration();
|
||||
};
|
||||
const scheduleDecoderVerification = (track: RemoteTrack, pub: RemoteTrackPublication): void => {
|
||||
if (pub.source !== Track.Source.ScreenShare || pub.kind !== Track.Kind.Video) return;
|
||||
clearScreenShareDecoderVerification(pub.trackSid);
|
||||
const trackSid = pub.trackSid;
|
||||
screenShareDecoderVerificationTimers.set(
|
||||
screenShareDecoderVerificationCancels.set(
|
||||
trackSid,
|
||||
scheduleScreenShareDecoderVerification(
|
||||
() => track.getRTCStatsReport(),
|
||||
() => {
|
||||
screenShareDecoderVerificationTimers.delete(trackSid);
|
||||
screenShareDecoderVerificationCancels.delete(trackSid);
|
||||
},
|
||||
(failure) => {
|
||||
if (!markScreenShareDecodeFailure(failure.codec, 'screen-share-decode-stalled')) return;
|
||||
@@ -307,8 +319,7 @@ export function bindRoomEvents(
|
||||
bindParticipantSpeakingEvents(room.localParticipant);
|
||||
room.remoteParticipants.forEach((participant) => bindParticipantSpeakingEvents(participant));
|
||||
dependencies.remoteSpeaking.hydrateFromRoom(room);
|
||||
bindCodecNegotiation();
|
||||
bindScreenShareMigration();
|
||||
bindScreenShareNegotiation();
|
||||
dependencies.permissions.applyDeafen(room, getEffectiveAudioState().effectiveDeaf);
|
||||
dependencies.connection.markConnected();
|
||||
await callbacks.onConnected();
|
||||
@@ -335,8 +346,7 @@ export function bindRoomEvents(
|
||||
guard(attemptId, () => {
|
||||
participantSpeakingDisposers.forEach(({dispose}) => dispose());
|
||||
participantSpeakingDisposers.clear();
|
||||
unbindCodecNegotiation();
|
||||
unbindScreenShareMigration();
|
||||
unbindScreenShareNegotiation();
|
||||
clearAllScreenShareDecoderVerifications();
|
||||
clearAllRemoteTrackLifecycleBindings();
|
||||
dependencies.remoteSpeaking.clear();
|
||||
@@ -610,4 +620,7 @@ export function bindRoomEvents(
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (room.state === ConnectionState.Connected) {
|
||||
guard(attemptId, bindScreenShareNegotiation)();
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type VoiceEngineV2AppScreenShareControllerGateway,
|
||||
VoiceEngineV2AppScreenShareControllerRouting,
|
||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareControllerRouting';
|
||||
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
||||
import {
|
||||
getRecentScreenShares,
|
||||
recordScreenShareStarted,
|
||||
resetRecentScreenSharesForTests,
|
||||
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/voice/engine/voice_screen_share_manager/shared', () => ({
|
||||
logger: {debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/state/LocalVoiceState', () => ({
|
||||
default: {getSelfStream: () => true},
|
||||
}));
|
||||
|
||||
const setEnabled = vi.fn(async () => undefined);
|
||||
|
||||
function createAdapter(): VoiceEngineV2AppScreenShareExecutionAdapter {
|
||||
return {liveKitFlows: {setEnabled}} as unknown as VoiceEngineV2AppScreenShareExecutionAdapter;
|
||||
}
|
||||
|
||||
function createGateway(
|
||||
overrides: Partial<VoiceEngineV2AppScreenShareControllerGateway> = {},
|
||||
): VoiceEngineV2AppScreenShareControllerGateway {
|
||||
return {
|
||||
isScreenCommandRoutable: () => true,
|
||||
hasScreenPublication: () => true,
|
||||
hasScreenDesired: () => false,
|
||||
clearScreenDesired: () => undefined,
|
||||
executingScreenOperationId: () => null,
|
||||
isScreenOperationPending: () => true,
|
||||
publishScreen: async () => undefined,
|
||||
unpublishScreen: async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('routed screen share stop attribution', () => {
|
||||
beforeEach(() => {
|
||||
resetRecentScreenSharesForTests();
|
||||
setEnabled.mockClear();
|
||||
});
|
||||
|
||||
it('keeps a stop the user asked for as a user stop when the controller carries it out', async () => {
|
||||
const routing = new VoiceEngineV2AppScreenShareControllerRouting(createAdapter());
|
||||
let executingOperationId: number | null = null;
|
||||
routing.setGateway(
|
||||
createGateway({
|
||||
executingScreenOperationId: () => executingOperationId,
|
||||
unpublishScreen: async (onPlanned) => {
|
||||
onPlanned([7]);
|
||||
executingOperationId = 7;
|
||||
await routing.unpublishViaLiveKitFlows(null);
|
||||
},
|
||||
}),
|
||||
);
|
||||
recordScreenShareStarted();
|
||||
|
||||
await routing.setEnabled(null, false, {sendUpdate: true, playSound: true});
|
||||
|
||||
expect(setEnabled).toHaveBeenCalledTimes(1);
|
||||
expect(getRecentScreenShares()[0].stopTrigger).toBe('user');
|
||||
});
|
||||
|
||||
it('records an unpublish the app never asked for as a gateway echo', async () => {
|
||||
const routing = new VoiceEngineV2AppScreenShareControllerRouting(createAdapter());
|
||||
routing.setGateway(createGateway());
|
||||
recordScreenShareStarted();
|
||||
|
||||
await routing.unpublishViaLiveKitFlows(null);
|
||||
|
||||
expect(getRecentScreenShares()[0].stopTrigger).toBe('gateway-echo');
|
||||
});
|
||||
});
|
||||
+2
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
|
||||
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
|
||||
import {logger} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||
import {recordScreenShareStopped} from '@app/features/voice/utils/ScreenShareLifecycleLog';
|
||||
import type {VoiceEngineV2ScreenOptions} from '@fluxer/voice_engine_v2';
|
||||
import type {Room, ScreenShareCaptureOptions, TrackPublishOptions, VideoCodec} from 'livekit-client';
|
||||
|
||||
@@ -445,6 +446,7 @@ export class VoiceEngineV2AppScreenShareControllerRouting {
|
||||
|
||||
async unpublishViaLiveKitFlows(roomFromPort: Room | null): Promise<void> {
|
||||
const request = this.takeStopRequest(this.executingScreenOperationId());
|
||||
recordScreenShareStopped(request !== null ? 'user' : 'gateway-echo');
|
||||
const room = request !== null ? request.room : roomFromPort;
|
||||
try {
|
||||
await this.adapter.liveKitFlows.setEnabled(room, false, {
|
||||
|
||||
+39
-4
@@ -78,6 +78,15 @@ import {
|
||||
disarmNativeAudio,
|
||||
reconfigureLinuxNativeAudioRouting,
|
||||
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {
|
||||
recordScreenShareEncoderVerification,
|
||||
recordScreenShareEndedModal,
|
||||
recordScreenShareRequestedCodec,
|
||||
recordScreenShareStartError,
|
||||
recordScreenShareStarted,
|
||||
recordScreenShareStopped,
|
||||
type ScreenShareEndedModal,
|
||||
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
|
||||
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {ScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
import {handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
|
||||
@@ -400,7 +409,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.encoderVerification.cleared'});
|
||||
}
|
||||
|
||||
showScreenShareEndedModalInternal(description: string): void {
|
||||
showScreenShareEndedModalInternal(description: string, modal: ScreenShareEndedModal): void {
|
||||
recordScreenShareEndedModal(modal);
|
||||
ModalCommands.pushWithKey(
|
||||
ModalCommands.modal(() => (
|
||||
<GenericErrorModal
|
||||
@@ -491,9 +501,11 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
i18n._(
|
||||
cause === 'stalled' ? SCREEN_SHARE_ENCODER_FAILED_DESCRIPTOR : SCREEN_SHARE_CODEC_POLICY_FAILED_DESCRIPTOR,
|
||||
),
|
||||
cause === 'stalled' ? 'encoder-failed' : 'codec-policy-failed',
|
||||
);
|
||||
if (this.endedScreenShareStopInFlight) return;
|
||||
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
|
||||
recordScreenShareStopped('codec-republish-failed');
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
|
||||
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
|
||||
.catch((error) => {
|
||||
@@ -516,6 +528,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
): void {
|
||||
this.cancelEncoderVerificationInternal();
|
||||
if (!codec) return;
|
||||
recordScreenShareRequestedCodec(codec);
|
||||
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
|
||||
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
|
||||
const sender = track?.sender;
|
||||
@@ -530,8 +543,10 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
const action = resolveScreenShareEncoderVerificationAction(failure);
|
||||
switch (action.kind) {
|
||||
case 'ignore-repeated-stall':
|
||||
recordScreenShareEncoderVerification('ignore-repeated-stall', null);
|
||||
return;
|
||||
case 'recover-stalled':
|
||||
recordScreenShareEncoderVerification('recover-stalled', null);
|
||||
logger.warn('Screen share encoder verification failed', {
|
||||
codec: action.codec,
|
||||
failureReason: 'screen-share-encode-stalled',
|
||||
@@ -546,12 +561,14 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
);
|
||||
return;
|
||||
case 'accept-negotiated':
|
||||
recordScreenShareEncoderVerification('accept-negotiated', action.negotiated);
|
||||
logger.info('Screen share publisher negotiated a different codec inside the publish policy', {
|
||||
requested: action.requested,
|
||||
negotiated: action.negotiated,
|
||||
});
|
||||
return;
|
||||
case 'correct-negotiated':
|
||||
recordScreenShareEncoderVerification('correct-negotiated', action.negotiated);
|
||||
logger.warn('Screen share is sending a codec outside the publish policy', {
|
||||
requested: action.requested,
|
||||
negotiated: action.negotiated,
|
||||
@@ -599,7 +616,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
trigger,
|
||||
readyState: mediaStreamTrack.readyState,
|
||||
});
|
||||
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR));
|
||||
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR), 'source-stopped');
|
||||
recordScreenShareStopped('media-track-ended');
|
||||
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
|
||||
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
|
||||
.catch((error) => {
|
||||
@@ -663,6 +681,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
}
|
||||
|
||||
handleLocalScreenShareTrackUnpublished(room: Room, playSound: boolean, publication?: LocalTrackPublication): void {
|
||||
recordScreenShareStopped(this.isScreenSharePending ? 'user' : 'server-unpublish');
|
||||
this.clearScreenShareKeepAliveSinkInternal();
|
||||
this.cleanupActiveScreenShareEndListenerInternal();
|
||||
const participant = room.localParticipant;
|
||||
@@ -866,7 +885,17 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<void> {
|
||||
assert.equal(typeof enabled, 'boolean');
|
||||
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
|
||||
if (enabled) {
|
||||
recordScreenShareStarted();
|
||||
}
|
||||
try {
|
||||
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
|
||||
} catch (error) {
|
||||
if (enabled) {
|
||||
recordScreenShareStartError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!enabled && options?.preserveStreamAudioPreferences !== true) {
|
||||
VoiceSettingsCommands.update({
|
||||
shareAppAudio: true,
|
||||
@@ -908,7 +937,13 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
|
||||
options?: DeviceScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
): Promise<void> {
|
||||
await this.liveKitFlows.startDeviceScreenShare(room, options, publishOptions);
|
||||
recordScreenShareStarted();
|
||||
try {
|
||||
await this.liveKitFlows.startDeviceScreenShare(room, options, publishOptions);
|
||||
} catch (error) {
|
||||
recordScreenShareStartError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async replaceActiveDisplayScreenShare(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type PerTrackStats,
|
||||
type TransportInfo,
|
||||
VoiceEngineV2AppStatsHostAdapter,
|
||||
type VoiceEngineV2AppStatsHostAdapterScheduler,
|
||||
} from '@app/features/voice/engine/v2/VoiceEngineV2AppStatsHostAdapter';
|
||||
@@ -335,3 +336,124 @@ describe('VoiceEngineV2AppStatsHostAdapter firefox outbound rows', () => {
|
||||
expect(rows[classification.localScreenShareTrackIndex!].mid).toBe('4');
|
||||
});
|
||||
});
|
||||
|
||||
function chromiumWatchedScreenShareReports(): Map<string, unknown> {
|
||||
return new Map<string, unknown>([
|
||||
[
|
||||
'C1',
|
||||
{
|
||||
type: 'codec',
|
||||
id: 'C1',
|
||||
mimeType: 'video/H264',
|
||||
payloadType: 108,
|
||||
sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
},
|
||||
],
|
||||
[
|
||||
'IT01V',
|
||||
{
|
||||
type: 'inbound-rtp',
|
||||
id: 'IT01V',
|
||||
kind: 'video',
|
||||
ssrc: 2001,
|
||||
mid: '5',
|
||||
codecId: 'C1',
|
||||
trackIdentifier: 'screen-share-track',
|
||||
bytesReceived: 3_500_000,
|
||||
packetsReceived: 4210,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
keyFramesDecoded: 0,
|
||||
pliCount: 7,
|
||||
frameWidth: 1920,
|
||||
frameHeight: 1080,
|
||||
},
|
||||
],
|
||||
[
|
||||
'CP1',
|
||||
{
|
||||
type: 'candidate-pair',
|
||||
id: 'CP1',
|
||||
state: 'succeeded',
|
||||
nominated: true,
|
||||
selected: true,
|
||||
localCandidateId: 'LC1',
|
||||
remoteCandidateId: 'RC1',
|
||||
currentRoundTripTime: 0.023,
|
||||
},
|
||||
],
|
||||
[
|
||||
'LC1',
|
||||
{
|
||||
type: 'local-candidate',
|
||||
id: 'LC1',
|
||||
candidateType: 'relay',
|
||||
protocol: 'udp',
|
||||
relayProtocol: 'tls',
|
||||
url: 'turn:turn.fluxer.example:443?transport=tcp',
|
||||
networkType: 'wifi',
|
||||
},
|
||||
],
|
||||
['RC1', {type: 'remote-candidate', id: 'RC1', candidateType: 'host', protocol: 'udp'}],
|
||||
[
|
||||
'T1',
|
||||
{type: 'transport', id: 'T1', dtlsState: 'connected', iceState: 'connected', selectedCandidatePairId: 'CP1'},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
async function collectWatchedScreenShareStats(): Promise<{
|
||||
rows: Array<PerTrackStats>;
|
||||
transport: TransportInfo | null;
|
||||
}> {
|
||||
const scheduler = createScheduler();
|
||||
const subscriber = {
|
||||
getStats() {
|
||||
return Promise.resolve(chromiumWatchedScreenShareReports());
|
||||
},
|
||||
};
|
||||
const adapter = new VoiceEngineV2AppStatsHostAdapter({now: () => 1000, scheduler});
|
||||
adapter.setRoom({
|
||||
engine: {pcManager: {subscriber, mode: 'subscriber-primary'}},
|
||||
numParticipants: 2,
|
||||
} as unknown as Room);
|
||||
adapter.startStatsTracking();
|
||||
scheduler.runStatsTick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const rows = adapter.perTrackStats;
|
||||
const transport = adapter.subscriberTransport;
|
||||
adapter.cleanup();
|
||||
return {rows, transport};
|
||||
}
|
||||
|
||||
describe('VoiceEngineV2AppStatsHostAdapter watched screen share rows', () => {
|
||||
it('collects the inbound packet, byte and frame counters that tell a stalled decode apart from a dead feed', async () => {
|
||||
const {rows} = await collectWatchedScreenShareStats();
|
||||
|
||||
expect(rows.map((row) => [row.packetsReceived, row.bytesReceived, row.framesReceived, row.framesDecoded])).toEqual([
|
||||
[4210, 3_500_000, 0, 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects the codec sdp fmtp line of the receive row', async () => {
|
||||
const {rows} = await collectWatchedScreenShareStats();
|
||||
|
||||
expect(rows[0].codecSdpFmtpLine).toBe('level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
|
||||
});
|
||||
|
||||
it('collects the relay protocol of the selected local candidate, which the local protocol never shows', async () => {
|
||||
const {transport} = await collectWatchedScreenShareStats();
|
||||
|
||||
expect(transport?.localProtocol).toBe('udp');
|
||||
expect(transport?.localRelayProtocol).toBe('tls');
|
||||
expect(transport?.localCandidateUrlHost).toBe('turn.fluxer.example');
|
||||
});
|
||||
|
||||
it('collects the peer connection mode alongside the transport block', async () => {
|
||||
const {transport} = await collectWatchedScreenShareStats();
|
||||
|
||||
expect(transport?.peerConnectionMode).toBe('subscriber-primary');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,11 +59,15 @@ export interface PerTrackStats {
|
||||
trackIdentifier?: string;
|
||||
mediaSourceId?: string;
|
||||
codec?: string;
|
||||
codecSdpFmtpLine?: string;
|
||||
payloadType?: number;
|
||||
bitrateKbps: number;
|
||||
bitrateWindowMs?: number;
|
||||
packetsLost?: number;
|
||||
packetsLossPercent?: number;
|
||||
packetsReceived?: number;
|
||||
bytesReceived?: number;
|
||||
framesReceived?: number;
|
||||
jitterMs?: number;
|
||||
framesPerSecond?: number;
|
||||
sourceFramesPerSecond?: number;
|
||||
@@ -121,6 +125,9 @@ export interface TransportInfo {
|
||||
candidatePairState?: string;
|
||||
localCandidateType?: string;
|
||||
localProtocol?: string;
|
||||
localRelayProtocol?: string;
|
||||
localCandidateUrlHost?: string;
|
||||
peerConnectionMode?: string;
|
||||
localNetworkType?: string;
|
||||
remoteCandidateType?: string;
|
||||
remoteProtocol?: string;
|
||||
@@ -156,6 +163,7 @@ type RoomWithEngine = Room & {
|
||||
pcManager?: {
|
||||
publisher?: StatsSource;
|
||||
subscriber?: StatsSource;
|
||||
mode?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -191,6 +199,7 @@ interface RTCStatsEntry {
|
||||
availableIncomingBitrate?: number;
|
||||
codecId?: string;
|
||||
mimeType?: string;
|
||||
sdpFmtpLine?: string;
|
||||
payloadType?: number;
|
||||
ssrc?: number;
|
||||
rid?: string;
|
||||
@@ -205,6 +214,7 @@ interface RTCStatsEntry {
|
||||
width?: number;
|
||||
height?: number;
|
||||
frames?: number;
|
||||
framesReceived?: number;
|
||||
framesEncoded?: number;
|
||||
framesDecoded?: number;
|
||||
framesDropped?: number;
|
||||
@@ -223,6 +233,8 @@ interface RTCStatsEntry {
|
||||
remoteCandidateId?: string;
|
||||
candidateType?: string;
|
||||
protocol?: string;
|
||||
relayProtocol?: string;
|
||||
url?: string;
|
||||
networkType?: string;
|
||||
nominated?: boolean;
|
||||
selected?: boolean;
|
||||
@@ -374,6 +386,9 @@ function buildInboundTrackExtras(
|
||||
decoderAcceleration: VideoAccelerationStatus | undefined,
|
||||
): Partial<PerTrackStats> {
|
||||
return {
|
||||
packetsReceived: report.packetsReceived,
|
||||
bytesReceived: report.bytesReceived,
|
||||
framesReceived: report.framesReceived,
|
||||
keyFramesDecoded: report.keyFramesDecoded,
|
||||
decoderImplementation: report.decoderImplementation,
|
||||
powerEfficientDecoder: report.powerEfficientDecoder,
|
||||
@@ -428,6 +443,7 @@ function buildPerTrackStat(args: {
|
||||
(isOutbound && report.mid ? midToSenderTrackId.get(report.mid) : undefined),
|
||||
mediaSourceId: report.mediaSourceId,
|
||||
codec: codec?.mimeType,
|
||||
codecSdpFmtpLine: codec?.sdpFmtpLine,
|
||||
payloadType: codec?.payloadType,
|
||||
bitrateKbps: Math.round(bitrate.bitrateKbps),
|
||||
bitrateWindowMs: bitrate.windowMs,
|
||||
@@ -466,10 +482,20 @@ function buildPerTrackStat(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function getCandidateUrlHost(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
const schemeIndex = url.indexOf(':');
|
||||
const authority = (schemeIndex === -1 ? url : url.slice(schemeIndex + 1)).split('?')[0];
|
||||
const portIndex = authority.lastIndexOf(':');
|
||||
const host = portIndex > 0 ? authority.slice(0, portIndex) : authority;
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function buildTransportInfo(
|
||||
activePair: RTCStatsEntry | null,
|
||||
transportReport: RTCStatsEntry | null,
|
||||
reportsById: Map<string, RTCStatsEntry>,
|
||||
peerConnectionMode: string | undefined,
|
||||
): TransportInfo | null {
|
||||
if (!activePair && !transportReport) return null;
|
||||
const local = activePair?.localCandidateId ? reportsById.get(activePair.localCandidateId) : undefined;
|
||||
@@ -478,6 +504,9 @@ function buildTransportInfo(
|
||||
candidatePairState: activePair?.state,
|
||||
localCandidateType: local?.candidateType,
|
||||
localProtocol: local?.protocol,
|
||||
localRelayProtocol: local?.relayProtocol,
|
||||
localCandidateUrlHost: getCandidateUrlHost(local?.url),
|
||||
peerConnectionMode,
|
||||
localNetworkType: local?.networkType,
|
||||
remoteCandidateType: remote?.candidateType,
|
||||
remoteProtocol: remote?.protocol,
|
||||
@@ -503,6 +532,7 @@ async function collectFromStatsSource(
|
||||
now: number,
|
||||
rtpCounters: Map<string, VoiceStatsRtpCounter>,
|
||||
activeCounterIds: Set<string>,
|
||||
peerConnectionMode?: string,
|
||||
): Promise<{
|
||||
tracks: Array<PerTrackStats>;
|
||||
rtt: number;
|
||||
@@ -548,7 +578,7 @@ async function collectFromStatsSource(
|
||||
const selectedPair = reportsById.get(transportReport.selectedCandidatePairId);
|
||||
if (selectedPair?.type === 'candidate-pair') activePair = selectedPair;
|
||||
}
|
||||
const transport = buildTransportInfo(activePair, transportReport, reportsById);
|
||||
const transport = buildTransportInfo(activePair, transportReport, reportsById, peerConnectionMode);
|
||||
return {tracks, rtt, transport};
|
||||
}
|
||||
|
||||
@@ -866,6 +896,7 @@ export class VoiceEngineV2AppStatsHostAdapter extends Store {
|
||||
if (!engine?.pcManager) return;
|
||||
const publisher = engine.pcManager.publisher;
|
||||
const subscriber = engine.pcManager.subscriber;
|
||||
const peerConnectionMode = engine.pcManager.mode;
|
||||
if (!publisher && !subscriber) return;
|
||||
const now = this.now();
|
||||
const tracks: Array<PerTrackStats> = [];
|
||||
@@ -875,13 +906,27 @@ export class VoiceEngineV2AppStatsHostAdapter extends Store {
|
||||
let publisherTransport: TransportInfo | null = null;
|
||||
let subscriberTransport: TransportInfo | null = null;
|
||||
if (publisher) {
|
||||
const result = await collectFromStatsSource(publisher, 'publisher', now, rtpCounters, activeCounterIds);
|
||||
const result = await collectFromStatsSource(
|
||||
publisher,
|
||||
'publisher',
|
||||
now,
|
||||
rtpCounters,
|
||||
activeCounterIds,
|
||||
peerConnectionMode,
|
||||
);
|
||||
tracks.push(...result.tracks);
|
||||
if (result.rtt > rtt) rtt = result.rtt;
|
||||
publisherTransport = result.transport;
|
||||
}
|
||||
if (subscriber) {
|
||||
const result = await collectFromStatsSource(subscriber, 'subscriber', now, rtpCounters, activeCounterIds);
|
||||
const result = await collectFromStatsSource(
|
||||
subscriber,
|
||||
'subscriber',
|
||||
now,
|
||||
rtpCounters,
|
||||
activeCounterIds,
|
||||
peerConnectionMode,
|
||||
);
|
||||
tracks.push(...result.tracks);
|
||||
if (result.rtt > rtt) rtt = result.rtt;
|
||||
subscriberTransport = result.transport;
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {voiceMediaGraphStore} from '@app/features/voice/engine/VoiceMediaGraphStore';
|
||||
import {ScreenShareWatchErrorCode, ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
|
||||
import type {RemoteTrackPublication} from 'livekit-client';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function inboundStatsReport(): RTCStatsReport {
|
||||
return new Map<string, unknown>([
|
||||
[
|
||||
'IT01V',
|
||||
{
|
||||
type: 'inbound-rtp',
|
||||
id: 'IT01V',
|
||||
kind: 'video',
|
||||
codecId: 'C1',
|
||||
packetsReceived: 4210,
|
||||
bytesReceived: 3_500_000,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
keyFramesDecoded: 0,
|
||||
framesDropped: 0,
|
||||
pliCount: 7,
|
||||
firCount: 0,
|
||||
nackCount: 2,
|
||||
freezeCount: 0,
|
||||
decoderImplementation: 'ExternalDecoder (D3D11VideoDecoder)',
|
||||
powerEfficientDecoder: true,
|
||||
frameWidth: 1920,
|
||||
frameHeight: 1080,
|
||||
},
|
||||
],
|
||||
[
|
||||
'C1',
|
||||
{
|
||||
type: 'codec',
|
||||
id: 'C1',
|
||||
mimeType: 'video/H264',
|
||||
sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
},
|
||||
],
|
||||
]) as unknown as RTCStatsReport;
|
||||
}
|
||||
|
||||
function publicationWithStats(report: RTCStatsReport): RemoteTrackPublication {
|
||||
return {
|
||||
videoTrack: {
|
||||
getRTCStatsReport: () => Promise.resolve(report),
|
||||
},
|
||||
} as unknown as RemoteTrackPublication;
|
||||
}
|
||||
|
||||
async function settleStatsSnapshot(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function fireWatchDeadlines(streamKey: string): void {
|
||||
const snapshot = voiceMediaGraphStore.getGraphSnapshot();
|
||||
const dueAt = voiceMediaGraphStore.nowMs() + 60_000;
|
||||
for (const [key, deadline] of snapshot.deadlinesByKey) {
|
||||
if (deadline.streamKey !== streamKey) continue;
|
||||
voiceMediaGraphStore.transition({type: 'time.deadlineFired', key, at: dueAt});
|
||||
}
|
||||
}
|
||||
|
||||
function historyFor(streamKey: string) {
|
||||
return ScreenShareWatchFailures.getFailureHistory().filter((record) => record.streamKey === streamKey);
|
||||
}
|
||||
|
||||
function watchDeadlineFor(streamKey: string) {
|
||||
const snapshot = voiceMediaGraphStore.getGraphSnapshot();
|
||||
for (const deadline of snapshot.deadlinesByKey.values()) {
|
||||
if (deadline.kind === 'watchAttempt' && deadline.streamKey === streamKey) return deadline;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('ScreenShareWatchFailures failure history', () => {
|
||||
beforeEach(() => {
|
||||
voiceMediaGraphStore.reset();
|
||||
});
|
||||
|
||||
it('keeps a reported failure after the viewer stops watching', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-stop';
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.reportFailure({
|
||||
streamKey,
|
||||
participantIdentity: 'user_1_connection-stop',
|
||||
trackSid: 'TR_stop',
|
||||
code: ScreenShareWatchErrorCode.RemoteTrackSubscriptionFailed,
|
||||
reason: 'remote-track-subscription-failed',
|
||||
});
|
||||
expect(ScreenShareWatchFailures.getFailure({streamKey})).not.toBeNull();
|
||||
|
||||
ScreenShareWatchFailures.markWatchStopped(streamKey);
|
||||
|
||||
expect(ScreenShareWatchFailures.getFailure({streamKey})).toBeNull();
|
||||
expect(historyFor(streamKey).map((record) => [record.code, record.trackSid, record.participantIdentity])).toEqual([
|
||||
[ScreenShareWatchErrorCode.RemoteTrackSubscriptionFailed, 'TR_stop', 'user_1_connection-stop'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a failure raised by a watch deadline after the viewer stops watching', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-deadline';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef: {current: null}});
|
||||
|
||||
fireWatchDeadlines(streamKey);
|
||||
|
||||
const graphFailure = ScreenShareWatchFailures.getFailure({streamKey});
|
||||
expect(graphFailure).not.toBeNull();
|
||||
|
||||
ScreenShareWatchFailures.markWatchStopped(streamKey);
|
||||
|
||||
expect(ScreenShareWatchFailures.getFailure({streamKey})).toBeNull();
|
||||
expect(historyFor(streamKey).map((record) => record.code)).toEqual([graphFailure!.code]);
|
||||
});
|
||||
|
||||
it('records the inbound counters of the watched publication alongside the failure', async () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-counters';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {
|
||||
videoRef: {current: null},
|
||||
publication: publicationWithStats(inboundStatsReport()),
|
||||
});
|
||||
|
||||
ScreenShareWatchFailures.reportFailure({
|
||||
streamKey,
|
||||
trackSid: 'TR_counters',
|
||||
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
|
||||
reason: 'first-frame-timeout',
|
||||
});
|
||||
await settleStatsSnapshot();
|
||||
|
||||
expect(historyFor(streamKey)[0].inbound).toEqual({
|
||||
packetsReceived: 4210,
|
||||
bytesReceived: 3_500_000,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
keyFramesDecoded: 0,
|
||||
framesDropped: 0,
|
||||
pliCount: 7,
|
||||
firCount: 0,
|
||||
nackCount: 2,
|
||||
freezeCount: 0,
|
||||
decoderImplementation: 'ExternalDecoder (D3D11VideoDecoder)',
|
||||
powerEfficientDecoder: true,
|
||||
mimeType: 'video/H264',
|
||||
sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
frameWidth: 1920,
|
||||
frameHeight: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('records the size and readiness of the attached video element', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-tile';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
const video = {readyState: 0, videoWidth: 0, videoHeight: 0, clientWidth: 960, clientHeight: 540};
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {
|
||||
videoRef: {current: video as unknown as HTMLVideoElement},
|
||||
});
|
||||
|
||||
ScreenShareWatchFailures.reportFailure({
|
||||
streamKey,
|
||||
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
|
||||
reason: 'first-frame-timeout',
|
||||
});
|
||||
|
||||
expect(historyFor(streamKey)[0].tile).toMatchObject({
|
||||
hasVideoElement: true,
|
||||
readyState: 0,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0,
|
||||
clientWidth: 960,
|
||||
clientHeight: 540,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the running watch deadline alone when the watch target is registered', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-target';
|
||||
const attemptKey = `${streamKey}:1:watch`;
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
ScreenShareWatchFailures.ensureAttempt({streamKey}, attemptKey);
|
||||
const deadline = watchDeadlineFor(streamKey);
|
||||
expect(deadline).not.toBeNull();
|
||||
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {videoRef: {current: null}});
|
||||
ScreenShareWatchFailures.setWatchTarget(streamKey, {
|
||||
videoRef: {current: null},
|
||||
publication: publicationWithStats(inboundStatsReport()),
|
||||
});
|
||||
|
||||
expect(watchDeadlineFor(streamKey)).toBe(deadline);
|
||||
});
|
||||
|
||||
it('keeps only the most recent failures once the ring is full', () => {
|
||||
const streamKey = 'guild-a:channel-a:connection-ring';
|
||||
ScreenShareWatchFailures.markWatchStarted(streamKey);
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
ScreenShareWatchFailures.reportFailure({
|
||||
streamKey,
|
||||
trackSid: `TR_ring_${index}`,
|
||||
code: ScreenShareWatchErrorCode.FirstFrameTimeout,
|
||||
reason: 'first-frame-timeout',
|
||||
});
|
||||
}
|
||||
|
||||
const history = historyFor(streamKey);
|
||||
expect(history.length).toBe(16);
|
||||
expect([history[0].trackSid, history[history.length - 1].trackSid]).toEqual(['TR_ring_4', 'TR_ring_19']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RemoteTrackPublication} from 'livekit-client';
|
||||
import {runInAction} from 'mobx';
|
||||
import {
|
||||
selectVoiceMediaGraphAttempt,
|
||||
@@ -15,6 +16,9 @@ import type {ScreenSharePublicationOperation} from '../utils/ScreenShareSubscrip
|
||||
|
||||
export type {ScreenSharePublicationOperation};
|
||||
|
||||
const FAILURE_HISTORY_LIMIT = 16;
|
||||
const HARVESTED_FAILURE_ID_LIMIT = 64;
|
||||
|
||||
export const ScreenShareWatchErrorCode = {
|
||||
SubscriptionSetSubscribedFailed: -2101,
|
||||
SubscriptionSetEnabledFailed: -2102,
|
||||
@@ -76,6 +80,158 @@ export interface ScreenShareWatchAttempt {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface ScreenShareWatchFailureInboundSnapshot {
|
||||
packetsReceived: number | null;
|
||||
bytesReceived: number | null;
|
||||
framesReceived: number | null;
|
||||
framesDecoded: number | null;
|
||||
keyFramesDecoded: number | null;
|
||||
framesDropped: number | null;
|
||||
pliCount: number | null;
|
||||
firCount: number | null;
|
||||
nackCount: number | null;
|
||||
freezeCount: number | null;
|
||||
decoderImplementation: string | null;
|
||||
powerEfficientDecoder: boolean | null;
|
||||
mimeType: string | null;
|
||||
sdpFmtpLine: string | null;
|
||||
frameWidth: number | null;
|
||||
frameHeight: number | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareWatchFailureTileSnapshot {
|
||||
hasVideoElement: boolean;
|
||||
readyState: number | null;
|
||||
videoWidth: number | null;
|
||||
videoHeight: number | null;
|
||||
clientWidth: number | null;
|
||||
clientHeight: number | null;
|
||||
visibilityState: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareWatchFailureRecord {
|
||||
code: ScreenShareWatchErrorCode;
|
||||
reason: ScreenShareWatchFailureReason;
|
||||
reportedAt: number;
|
||||
streamKey: string | null;
|
||||
trackSid: string | null;
|
||||
participantIdentity: string | null;
|
||||
inbound: ScreenShareWatchFailureInboundSnapshot | null;
|
||||
tile: ScreenShareWatchFailureTileSnapshot | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareWatchVideoElementRef {
|
||||
current: HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
export interface ScreenShareWatchTarget {
|
||||
videoRef?: ScreenShareWatchVideoElementRef | null;
|
||||
publication?: RemoteTrackPublication | null;
|
||||
}
|
||||
|
||||
interface InboundStatsEntry {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
mediaType?: string;
|
||||
codecId?: string;
|
||||
packetsReceived?: number;
|
||||
bytesReceived?: number;
|
||||
framesReceived?: number;
|
||||
framesDecoded?: number;
|
||||
keyFramesDecoded?: number;
|
||||
framesDropped?: number;
|
||||
pliCount?: number;
|
||||
firCount?: number;
|
||||
nackCount?: number;
|
||||
freezeCount?: number;
|
||||
decoderImplementation?: string;
|
||||
powerEfficientDecoder?: boolean;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
mimeType?: string;
|
||||
sdpFmtpLine?: string;
|
||||
}
|
||||
|
||||
function numberOrNull(value: number | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function stringOrNull(value: string | undefined): string | null {
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
function findInboundVideoEntry(report: RTCStatsReport): InboundStatsEntry | null {
|
||||
for (const raw of report.values()) {
|
||||
const entry = raw as InboundStatsEntry;
|
||||
if (entry.type !== 'inbound-rtp') continue;
|
||||
const kind = entry.kind ?? entry.mediaType;
|
||||
if (kind !== 'video') continue;
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildInboundSnapshot(report: RTCStatsReport): ScreenShareWatchFailureInboundSnapshot | null {
|
||||
const inbound = findInboundVideoEntry(report);
|
||||
if (!inbound) return null;
|
||||
const codec = inbound.codecId ? (report.get(inbound.codecId) as InboundStatsEntry | undefined) : undefined;
|
||||
return {
|
||||
packetsReceived: numberOrNull(inbound.packetsReceived),
|
||||
bytesReceived: numberOrNull(inbound.bytesReceived),
|
||||
framesReceived: numberOrNull(inbound.framesReceived),
|
||||
framesDecoded: numberOrNull(inbound.framesDecoded),
|
||||
keyFramesDecoded: numberOrNull(inbound.keyFramesDecoded),
|
||||
framesDropped: numberOrNull(inbound.framesDropped),
|
||||
pliCount: numberOrNull(inbound.pliCount),
|
||||
firCount: numberOrNull(inbound.firCount),
|
||||
nackCount: numberOrNull(inbound.nackCount),
|
||||
freezeCount: numberOrNull(inbound.freezeCount),
|
||||
decoderImplementation: stringOrNull(inbound.decoderImplementation),
|
||||
powerEfficientDecoder: typeof inbound.powerEfficientDecoder === 'boolean' ? inbound.powerEfficientDecoder : null,
|
||||
mimeType: stringOrNull(codec?.mimeType),
|
||||
sdpFmtpLine: stringOrNull(codec?.sdpFmtpLine),
|
||||
frameWidth: numberOrNull(inbound.frameWidth),
|
||||
frameHeight: numberOrNull(inbound.frameHeight),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTileSnapshot(videoRef: ScreenShareWatchVideoElementRef | null | undefined) {
|
||||
const video = videoRef?.current ?? null;
|
||||
const visibilityState = typeof document === 'undefined' ? null : document.visibilityState;
|
||||
if (!video) {
|
||||
return {
|
||||
hasVideoElement: false,
|
||||
readyState: null,
|
||||
videoWidth: null,
|
||||
videoHeight: null,
|
||||
clientWidth: null,
|
||||
clientHeight: null,
|
||||
visibilityState,
|
||||
};
|
||||
}
|
||||
return {
|
||||
hasVideoElement: true,
|
||||
readyState: numberOrNull(video.readyState),
|
||||
videoWidth: numberOrNull(video.videoWidth),
|
||||
videoHeight: numberOrNull(video.videoHeight),
|
||||
clientWidth: numberOrNull(video.clientWidth),
|
||||
clientHeight: numberOrNull(video.clientHeight),
|
||||
visibilityState,
|
||||
};
|
||||
}
|
||||
|
||||
function readInboundStatsReport(publication: RemoteTrackPublication | null | undefined) {
|
||||
try {
|
||||
return publication?.videoTrack?.getRTCStatsReport?.();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function failureHistoryId(failure: ScreenShareWatchFailure): string {
|
||||
return `${failure.code}:${failure.streamKey ?? ''}:${failure.trackSid ?? ''}:${failure.reportedAt}`;
|
||||
}
|
||||
|
||||
export function getScreenShareWatchFailureForPublicationOperation(
|
||||
operation: ScreenSharePublicationOperation,
|
||||
): Pick<ScreenShareWatchFailure, 'code' | 'reason'> {
|
||||
@@ -144,6 +300,11 @@ export function selectScreenShareWatchTimeoutFailureCode({
|
||||
}
|
||||
|
||||
class ScreenShareWatchFailuresStore {
|
||||
private readonly failureHistory: Array<ScreenShareWatchFailureRecord> = [];
|
||||
private readonly harvestedFailureIds = new Set<string>();
|
||||
private readonly watchTargetsByStreamKey = new Map<string, ScreenShareWatchTarget>();
|
||||
private graphUnsubscribe: (() => void) | null = null;
|
||||
|
||||
private get graph(): VoiceMediaGraphSnapshot<ScreenShareWatchFailure> {
|
||||
return voiceMediaGraphStore.graph as VoiceMediaGraphSnapshot<ScreenShareWatchFailure>;
|
||||
}
|
||||
@@ -152,8 +313,71 @@ class ScreenShareWatchFailuresStore {
|
||||
voiceMediaGraphStore.transitionTypedFailure(event);
|
||||
}
|
||||
|
||||
private rememberFailure(failure: ScreenShareWatchFailure): void {
|
||||
const id = failureHistoryId(failure);
|
||||
if (this.harvestedFailureIds.has(id)) return;
|
||||
if (this.harvestedFailureIds.size >= HARVESTED_FAILURE_ID_LIMIT) {
|
||||
const oldest = this.harvestedFailureIds.values().next().value;
|
||||
if (oldest !== undefined) this.harvestedFailureIds.delete(oldest);
|
||||
}
|
||||
this.harvestedFailureIds.add(id);
|
||||
const target = failure.streamKey ? this.watchTargetsByStreamKey.get(failure.streamKey) : undefined;
|
||||
const record: ScreenShareWatchFailureRecord = {
|
||||
code: failure.code,
|
||||
reason: failure.reason,
|
||||
reportedAt: failure.reportedAt,
|
||||
streamKey: failure.streamKey ?? null,
|
||||
trackSid: failure.trackSid ?? null,
|
||||
participantIdentity: failure.participantIdentity ?? null,
|
||||
inbound: null,
|
||||
tile: buildTileSnapshot(target?.videoRef),
|
||||
};
|
||||
if (this.failureHistory.length >= FAILURE_HISTORY_LIMIT) this.failureHistory.shift();
|
||||
this.failureHistory.push(record);
|
||||
void this.attachInboundSnapshot(record, target?.publication);
|
||||
}
|
||||
|
||||
private async attachInboundSnapshot(
|
||||
record: ScreenShareWatchFailureRecord,
|
||||
publication: RemoteTrackPublication | null | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const report = await readInboundStatsReport(publication);
|
||||
if (!report) return;
|
||||
record.inbound = buildInboundSnapshot(report);
|
||||
} catch {
|
||||
record.inbound = null;
|
||||
}
|
||||
}
|
||||
|
||||
private harvestGraphFailures(): void {
|
||||
for (const failure of this.graph.failuresByKey.values()) {
|
||||
this.rememberFailure(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureGraphSubscription(): void {
|
||||
if (this.graphUnsubscribe !== null) return;
|
||||
this.graphUnsubscribe = voiceMediaGraphStore.subscribe(() => {
|
||||
this.harvestGraphFailures();
|
||||
});
|
||||
}
|
||||
|
||||
private releaseGraphSubscriptionIfIdle(): void {
|
||||
if (this.watchTargetsByStreamKey.size > 0) return;
|
||||
if (this.graphUnsubscribe === null) return;
|
||||
this.graphUnsubscribe();
|
||||
this.graphUnsubscribe = null;
|
||||
}
|
||||
|
||||
getFailureHistory(): Array<ScreenShareWatchFailureRecord> {
|
||||
this.harvestGraphFailures();
|
||||
return this.failureHistory.map((record) => ({...record}));
|
||||
}
|
||||
|
||||
markWatchStarted(streamKey: string): number {
|
||||
if (!streamKey) return 0;
|
||||
this.harvestGraphFailures();
|
||||
let nextGeneration = 0;
|
||||
runInAction(() => {
|
||||
this.transition({type: 'watch.started', streamKey, at: voiceMediaGraphStore.nowMs()});
|
||||
@@ -164,6 +388,7 @@ class ScreenShareWatchFailuresStore {
|
||||
|
||||
markWatchStopped(streamKey: string): void {
|
||||
if (!streamKey) return;
|
||||
this.harvestGraphFailures();
|
||||
runInAction(() => {
|
||||
this.transition({type: 'watch.stopped', streamKey});
|
||||
});
|
||||
@@ -177,6 +402,19 @@ class ScreenShareWatchFailuresStore {
|
||||
return selectVoiceMediaGraphAttempt(this.graph, streamKey);
|
||||
}
|
||||
|
||||
setWatchTarget(streamKey: string, watchTarget: ScreenShareWatchTarget): void {
|
||||
if (!streamKey) return;
|
||||
this.watchTargetsByStreamKey.set(streamKey, watchTarget);
|
||||
this.ensureGraphSubscription();
|
||||
}
|
||||
|
||||
clearWatchTarget(streamKey: string): void {
|
||||
if (!streamKey) return;
|
||||
this.harvestGraphFailures();
|
||||
this.watchTargetsByStreamKey.delete(streamKey);
|
||||
this.releaseGraphSubscriptionIfIdle();
|
||||
}
|
||||
|
||||
ensureAttempt(
|
||||
target: ScreenShareWatchFailureTarget & {streamKey: string},
|
||||
attemptKey: string,
|
||||
@@ -196,12 +434,14 @@ class ScreenShareWatchFailuresStore {
|
||||
|
||||
releaseAttempt(target: ScreenShareWatchFailureTarget & {streamKey: string}, attemptKey: string): void {
|
||||
if (!target.streamKey || !attemptKey) return;
|
||||
this.harvestGraphFailures();
|
||||
runInAction(() => {
|
||||
this.transition({type: 'watch.attemptReleased', streamKey: target.streamKey, attemptKey});
|
||||
});
|
||||
}
|
||||
|
||||
markRenderedVideoFrame(target: ScreenShareWatchFailureTarget & {streamKey: string}, attemptKey: string): void {
|
||||
this.harvestGraphFailures();
|
||||
const existingAttempt = selectVoiceMediaGraphAttempt(this.graph, target.streamKey);
|
||||
if (existingAttempt && existingAttempt.attemptKey !== attemptKey) return;
|
||||
const generation =
|
||||
@@ -226,6 +466,7 @@ class ScreenShareWatchFailuresStore {
|
||||
runInAction(() => {
|
||||
this.transition({type: 'failure.reported', failure: normalizedFailure, generation: normalizedFailure.generation});
|
||||
});
|
||||
this.rememberFailure(normalizedFailure);
|
||||
return normalizedFailure;
|
||||
}
|
||||
|
||||
@@ -238,12 +479,14 @@ class ScreenShareWatchFailuresStore {
|
||||
}
|
||||
|
||||
clearFailure(target: ScreenShareWatchFailureTarget): void {
|
||||
this.harvestGraphFailures();
|
||||
runInAction(() => {
|
||||
this.transition({type: 'failure.cleared', target});
|
||||
});
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.harvestGraphFailures();
|
||||
runInAction(() => {
|
||||
this.transition({type: 'failureWatch.clearAll'});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const mediaPermission = vi.hoisted(() => ({
|
||||
isInitialized: () => true,
|
||||
isMicrophoneGranted: () => true,
|
||||
isCameraGranted: () => true,
|
||||
getMicrophonePermissionState: (): PermissionState => 'granted',
|
||||
getCameraPermissionState: (): PermissionState => 'granted',
|
||||
addChangeListener: (listener: () => void): (() => void) => {
|
||||
listener();
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/permissions/system/state/MediaPermission', () => ({default: mediaPermission}));
|
||||
|
||||
const makeDevice = (kind: MediaDeviceKind, deviceId: string, label: string): MediaDeviceInfo =>
|
||||
({
|
||||
deviceId,
|
||||
groupId: `${deviceId}-group`,
|
||||
kind,
|
||||
label,
|
||||
toJSON: () => ({deviceId, kind, label}),
|
||||
}) as MediaDeviceInfo;
|
||||
|
||||
const getUserMediaCalls: Array<MediaStreamConstraints> = [];
|
||||
let browserExposesLabels = false;
|
||||
|
||||
const fakeMediaDevices = {
|
||||
enumerateDevices: async (): Promise<Array<MediaDeviceInfo>> => [
|
||||
makeDevice('audioinput', 'mic-1', browserExposesLabels ? 'Studio Mic' : ''),
|
||||
makeDevice('videoinput', 'cam-1', browserExposesLabels ? 'Studio Camera' : ''),
|
||||
],
|
||||
getUserMedia: async (constraints: MediaStreamConstraints): Promise<MediaStream> => {
|
||||
getUserMediaCalls.push(constraints);
|
||||
return {getTracks: () => []} as unknown as MediaStream;
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
|
||||
let originalNavigator: PropertyDescriptor | undefined;
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
getUserMediaCalls.length = 0;
|
||||
browserExposesLabels = false;
|
||||
originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {userAgent: 'node', mediaDevices: fakeMediaDevices},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {matchMedia: () => ({matches: false})},
|
||||
});
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNavigator) {
|
||||
Object.defineProperty(globalThis, 'navigator', originalNavigator);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'navigator');
|
||||
}
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
|
||||
const runStartupPreload = async () => {
|
||||
const {startMediaDeviceStartupPreload} = await import('@app/features/voice/utils/MediaDeviceStartupPreload');
|
||||
const {default: VoiceDevicePermissionState} = await import('@app/features/voice/engine/VoiceDevicePermissionState');
|
||||
const stopPreload = startMediaDeviceStartupPreload();
|
||||
const state = await VoiceDevicePermissionState.ensureDevices();
|
||||
stopPreload();
|
||||
return state;
|
||||
};
|
||||
|
||||
describe('startMediaDeviceStartupPreload', () => {
|
||||
test('never opens capture for a granted microphone and camera when the browser hides device labels', async () => {
|
||||
const state = await runStartupPreload();
|
||||
expect(getUserMediaCalls).toEqual([]);
|
||||
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
|
||||
});
|
||||
|
||||
test('confirms a granted microphone and camera from exposed device labels without opening capture', async () => {
|
||||
browserExposesLabels = true;
|
||||
const state = await runStartupPreload();
|
||||
expect(getUserMediaCalls).toEqual([]);
|
||||
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'granted'});
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,9 @@ export function startMediaDeviceStartupPreload(): () => void {
|
||||
let lastPermissionStateKey: string | null = null;
|
||||
const preloadDevices = () => {
|
||||
if (stopped) return;
|
||||
const requestPermissionTypes: Array<VoiceMediaPermissionType> = [];
|
||||
if (MediaPermission.isMicrophoneGranted()) requestPermissionTypes.push('audio');
|
||||
if (MediaPermission.isCameraGranted()) requestPermissionTypes.push('video');
|
||||
const grantedPermissionTypes: Array<VoiceMediaPermissionType> = [];
|
||||
if (MediaPermission.isMicrophoneGranted()) grantedPermissionTypes.push('audio');
|
||||
if (MediaPermission.isCameraGranted()) grantedPermissionTypes.push('video');
|
||||
const permissionStateKey = [
|
||||
MediaPermission.isInitialized() ? 'initialized' : 'pending',
|
||||
MediaPermission.getMicrophonePermissionState() ?? 'unknown',
|
||||
@@ -22,16 +22,18 @@ export function startMediaDeviceStartupPreload(): () => void {
|
||||
].join(':');
|
||||
const deviceState = VoiceDevicePermissionState.getState();
|
||||
const forceRefresh = lastPermissionStateKey !== null && lastPermissionStateKey !== permissionStateKey;
|
||||
const requestedPermissionStatesSettled = requestPermissionTypes.every(
|
||||
const grantedPermissionStatesSettled = grantedPermissionTypes.every(
|
||||
(type) => deviceState.permissionStatus[type] !== 'idle',
|
||||
);
|
||||
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && requestedPermissionStatesSettled) {
|
||||
if (!forceRefresh && lastPermissionStateKey === permissionStateKey && grantedPermissionStatesSettled) {
|
||||
return;
|
||||
}
|
||||
lastPermissionStateKey = permissionStateKey;
|
||||
void VoiceDevicePermissionState.ensureDevices({forceRefresh, requestPermissionTypes}).catch((error) => {
|
||||
logger.debug('Failed to preload media devices', {error});
|
||||
});
|
||||
void VoiceDevicePermissionState.ensureDevices({forceRefresh, confirmPermissionTypes: grantedPermissionTypes}).catch(
|
||||
(error) => {
|
||||
logger.debug('Failed to preload media devices', {error});
|
||||
},
|
||||
);
|
||||
};
|
||||
const disposePermissionListener = MediaPermission.addChangeListener(preloadDevices);
|
||||
return () => {
|
||||
|
||||
@@ -1,12 +1,138 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getNativeAudioCaptureDiagnosticState} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import type {VoiceEngineV2AppSourceLifecycleBridge} from '@app/features/voice/engine/v2/VoiceEngineV2AppSourceLifecycleBridge';
|
||||
import {
|
||||
armNativeSystemAudioForNextCapture,
|
||||
captureNativeAudioTrackForLinuxRouting,
|
||||
getNativeAudioCaptureDiagnosticState,
|
||||
resetNativeAudioCaptureBridgeForTests,
|
||||
setNativeAudioCaptureBridgeLifecycleBridge,
|
||||
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {recordBridgeFrame, startBridgeStats} from '@app/features/voice/utils/native_audio_capture_bridge/bridgeStats';
|
||||
import type {
|
||||
NativeAudioBridgeEndedCapture,
|
||||
NativeAudioBridgeStats,
|
||||
} from '@app/features/voice/utils/native_audio_capture_bridge/shared';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const electronApi = {current: null as Record<string, unknown> | null};
|
||||
|
||||
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
|
||||
getElectronAPI: () => electronApi.current,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/SelfWindowScreenShareAudioMix', () => ({
|
||||
mixTrackWithSelfWindowScreenShareAudio: async (primaryTrack: MediaStreamTrack) => ({
|
||||
track: primaryTrack,
|
||||
cleanup: async () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const CAPTURE_ID = 'native-audio:stop-cause';
|
||||
|
||||
class FakeMediaStreamTrack extends EventTarget {
|
||||
readyState: 'live' | 'ended' = 'live';
|
||||
|
||||
constructor(readonly kind: 'audio' | 'video') {
|
||||
super();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.readyState = 'ended';
|
||||
}
|
||||
}
|
||||
|
||||
class FakeGeneratorTrack extends FakeMediaStreamTrack {
|
||||
readonly writable = new WritableStream();
|
||||
|
||||
constructor(_options: {kind: 'audio'}) {
|
||||
super('audio');
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMediaStream {
|
||||
private readonly tracks: Array<FakeMediaStreamTrack>;
|
||||
|
||||
constructor(tracks: Array<FakeMediaStreamTrack> = []) {
|
||||
this.tracks = [...tracks];
|
||||
}
|
||||
|
||||
getTracks(): Array<FakeMediaStreamTrack> {
|
||||
return [...this.tracks];
|
||||
}
|
||||
|
||||
getVideoTracks(): Array<FakeMediaStreamTrack> {
|
||||
return this.tracks.filter((track) => track.kind === 'video');
|
||||
}
|
||||
|
||||
getAudioTracks(): Array<FakeMediaStreamTrack> {
|
||||
return this.tracks.filter((track) => track.kind === 'audio');
|
||||
}
|
||||
|
||||
addTrack(track: FakeMediaStreamTrack): void {
|
||||
this.tracks.push(track);
|
||||
}
|
||||
|
||||
removeTrack(track: FakeMediaStreamTrack): void {
|
||||
const index = this.tracks.indexOf(track);
|
||||
if (index >= 0) this.tracks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const stopNativeCapture = vi.fn(async () => undefined);
|
||||
const reportLifecycle = vi.fn(() => true);
|
||||
|
||||
function createNativeAudioApi() {
|
||||
return {
|
||||
getAvailability: async () => ({
|
||||
available: true,
|
||||
backend: 'windows-wasapi-loopback',
|
||||
capabilities: {process: true, system: true, systemExcludesSelf: true},
|
||||
}),
|
||||
start: async () => ({captureId: CAPTURE_ID, sampleRate: 48000, channels: 2}),
|
||||
stop: stopNativeCapture,
|
||||
onFrame: () => () => undefined,
|
||||
onEnd: () => () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function installLifecycleBridge(): void {
|
||||
setNativeAudioCaptureBridgeLifecycleBridge({
|
||||
bind: () => true,
|
||||
unbind: () => undefined,
|
||||
reportLifecycle,
|
||||
} as unknown as VoiceEngineV2AppSourceLifecycleBridge);
|
||||
}
|
||||
|
||||
async function flushCleanup(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function startDisplayShareWithNativeAudio(): Promise<{
|
||||
videoTrack: FakeMediaStreamTrack;
|
||||
audioTrack: FakeMediaStreamTrack;
|
||||
}> {
|
||||
const videoTrack = new FakeMediaStreamTrack('video');
|
||||
const capturedStream = new FakeMediaStream([videoTrack]);
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {mediaDevices: {getDisplayMedia: async () => capturedStream}},
|
||||
});
|
||||
expect(await armNativeSystemAudioForNextCapture()).toBe(true);
|
||||
const stream = (await navigator.mediaDevices.getDisplayMedia({video: true})) as unknown as FakeMediaStream;
|
||||
const audioTrack = stream.getAudioTracks()[0];
|
||||
expect(audioTrack).toBeInstanceOf(FakeGeneratorTrack);
|
||||
return {videoTrack, audioTrack};
|
||||
}
|
||||
|
||||
function currentBridgeStats(): NativeAudioBridgeStats {
|
||||
return getNativeAudioCaptureDiagnosticState().bridgeStats as NativeAudioBridgeStats;
|
||||
}
|
||||
|
||||
function currentLifecycleFaults(): Array<{message: string}> {
|
||||
return getNativeAudioCaptureDiagnosticState().lifecycleFaults as Array<{message: string}>;
|
||||
}
|
||||
|
||||
describe('getNativeAudioCaptureDiagnosticState', () => {
|
||||
it('keeps the six fields real dumps already carry and appends the retained histories', () => {
|
||||
@@ -41,3 +167,93 @@ describe('getNativeAudioCaptureDiagnosticState', () => {
|
||||
expect(Array.isArray(getNativeAudioCaptureDiagnosticState().lifecycleFaults)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('native audio capture teardown cause', () => {
|
||||
beforeEach(() => {
|
||||
resetNativeAudioCaptureBridgeForTests();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {MediaStreamTrackGenerator: FakeGeneratorTrack, AudioData: class {}},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'MediaStream', {configurable: true, value: FakeMediaStream});
|
||||
electronApi.current = {platform: 'win32', nativeAudio: createNativeAudioApi()};
|
||||
stopNativeCapture.mockClear();
|
||||
reportLifecycle.mockClear();
|
||||
installLifecycleBridge();
|
||||
});
|
||||
|
||||
it('records caller-stopped with no fault when the share is stopped by hand', async () => {
|
||||
const {audioTrack} = await startDisplayShareWithNativeAudio();
|
||||
|
||||
audioTrack.stop();
|
||||
await flushCleanup();
|
||||
|
||||
expect(currentLifecycleFaults()).toEqual([]);
|
||||
expect(reportLifecycle).not.toHaveBeenCalled();
|
||||
expect(currentBridgeStats().endDetail).toBe('caller-stopped');
|
||||
expect(stopNativeCapture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('names the audio track when the capture track ends on its own', async () => {
|
||||
const {audioTrack} = await startDisplayShareWithNativeAudio();
|
||||
|
||||
audioTrack.dispatchEvent(new Event('ended'));
|
||||
await flushCleanup();
|
||||
|
||||
expect(currentLifecycleFaults().map((fault) => fault.message)).toEqual(['native-audio-tap-audio-track-ended']);
|
||||
expect(reportLifecycle).toHaveBeenCalledWith({
|
||||
captureId: CAPTURE_ID,
|
||||
kind: 'error',
|
||||
message: 'native-audio-tap-audio-track-ended',
|
||||
});
|
||||
expect(currentBridgeStats().endDetail).toBe('audio-track-ended');
|
||||
});
|
||||
|
||||
it('names the video track when the shared screen stops sending frames', async () => {
|
||||
const {videoTrack} = await startDisplayShareWithNativeAudio();
|
||||
|
||||
videoTrack.dispatchEvent(new Event('ended'));
|
||||
await flushCleanup();
|
||||
|
||||
expect(currentLifecycleFaults().map((fault) => fault.message)).toEqual(['native-audio-tap-video-track-ended']);
|
||||
expect(currentBridgeStats().endDetail).toBe('video-track-ended');
|
||||
});
|
||||
|
||||
it('tears down once and keeps the track-ended cause when a stop follows it', async () => {
|
||||
const {audioTrack} = await startDisplayShareWithNativeAudio();
|
||||
|
||||
audioTrack.dispatchEvent(new Event('ended'));
|
||||
audioTrack.stop();
|
||||
await flushCleanup();
|
||||
|
||||
expect(currentLifecycleFaults()).toHaveLength(1);
|
||||
expect(currentBridgeStats().endDetail).toBe('audio-track-ended');
|
||||
expect(stopNativeCapture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('names the audio track when a capture mixed with self-window audio ends on its own', async () => {
|
||||
electronApi.current = {platform: 'linux', nativeAudio: createNativeAudioApi()};
|
||||
const track = (await captureNativeAudioTrackForLinuxRouting(
|
||||
{include: []},
|
||||
{includeSelfWindowAudio: true},
|
||||
)) as unknown as FakeMediaStreamTrack | null;
|
||||
expect(track).toBeInstanceOf(FakeGeneratorTrack);
|
||||
|
||||
track?.dispatchEvent(new Event('ended'));
|
||||
await flushCleanup();
|
||||
|
||||
expect(currentBridgeStats().endDetail).toBe('audio-track-ended');
|
||||
});
|
||||
|
||||
it('tears down once and keeps caller-stopped when the track ends after the stop', async () => {
|
||||
const {audioTrack} = await startDisplayShareWithNativeAudio();
|
||||
|
||||
audioTrack.stop();
|
||||
audioTrack.dispatchEvent(new Event('ended'));
|
||||
await flushCleanup();
|
||||
|
||||
expect(currentLifecycleFaults()).toEqual([]);
|
||||
expect(currentBridgeStats().endDetail).toBe('caller-stopped');
|
||||
expect(stopNativeCapture).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,15 +113,15 @@ function recordLifecycleFault(captureId: string, message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function unbindNativeAudioCaptureLifecycle(captureId: string, faulted: boolean): void {
|
||||
function unbindNativeAudioCaptureLifecycle(captureId: string, fault: string | null): void {
|
||||
if (!lifecycleBoundCaptureIds.has(captureId)) return;
|
||||
const bridge = sourceLifecycleBridge;
|
||||
if (faulted) {
|
||||
recordLifecycleFault(captureId, 'native-audio-tap-track-ended');
|
||||
if (fault) {
|
||||
recordLifecycleFault(captureId, fault);
|
||||
}
|
||||
if (bridge) {
|
||||
if (faulted) {
|
||||
bridge.reportLifecycle({captureId, kind: 'error', message: 'native-audio-tap-track-ended'});
|
||||
if (fault) {
|
||||
bridge.reportLifecycle({captureId, kind: 'error', message: fault});
|
||||
}
|
||||
bridge.unbind(captureId);
|
||||
}
|
||||
@@ -195,8 +195,13 @@ export function getNativeAudioCaptureDiagnosticState(): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function cleanupBridgeAsync(bridge: ActiveNativeAudioBridge, stopRemote: boolean, logContext: string): void {
|
||||
void bridge.cleanup(stopRemote).catch((error) => {
|
||||
function cleanupBridgeAsync(
|
||||
bridge: ActiveNativeAudioBridge,
|
||||
stopRemote: boolean,
|
||||
logContext: string,
|
||||
endDetail?: string,
|
||||
): void {
|
||||
void bridge.cleanup(stopRemote, endDetail).catch((error) => {
|
||||
logger.warn(`Failed to clean up native audio bridge after ${logContext}`, {
|
||||
captureId: bridge.captureId,
|
||||
error,
|
||||
@@ -204,18 +209,23 @@ function cleanupBridgeAsync(bridge: ActiveNativeAudioBridge, stopRemote: boolean
|
||||
});
|
||||
}
|
||||
|
||||
function cleanupManagedBridgeById(captureId: string, stopRemote: boolean, logContext: string): boolean {
|
||||
function cleanupManagedBridgeById(
|
||||
captureId: string,
|
||||
stopRemote: boolean,
|
||||
logContext: string,
|
||||
endDetail?: string,
|
||||
): boolean {
|
||||
if (activeBridge?.captureId === captureId) {
|
||||
const bridge = activeBridge;
|
||||
activeBridge = supersededBridge;
|
||||
supersededBridge = null;
|
||||
cleanupBridgeAsync(bridge, stopRemote, logContext);
|
||||
cleanupBridgeAsync(bridge, stopRemote, logContext, endDetail);
|
||||
return true;
|
||||
}
|
||||
if (supersededBridge?.captureId === captureId) {
|
||||
const bridge = supersededBridge;
|
||||
supersededBridge = null;
|
||||
cleanupBridgeAsync(bridge, stopRemote, logContext);
|
||||
cleanupBridgeAsync(bridge, stopRemote, logContext, endDetail);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -242,12 +252,13 @@ function attachNativeAudioCleanup(
|
||||
captureId: string,
|
||||
handle: NativeAudioBridgeHandle,
|
||||
): ActiveNativeAudioBridge['cleanup'] {
|
||||
const tracks = [...stream.getVideoTracks(), handle.track];
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
const tracks = [...videoTracks, handle.track];
|
||||
const cleanupListeners: Array<() => void> = [];
|
||||
const restoreStops: Array<() => void> = [];
|
||||
let cleanedUp = false;
|
||||
bindNativeAudioCaptureLifecycle(captureId);
|
||||
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
|
||||
const cleanup = async (stopRemote: boolean = true, endDetail?: string): Promise<void> => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
for (const removeListener of cleanupListeners.splice(0)) {
|
||||
@@ -259,20 +270,22 @@ function attachNativeAudioCleanup(
|
||||
if (activeBridge?.captureId === captureId) {
|
||||
activeBridge = null;
|
||||
}
|
||||
unbindNativeAudioCaptureLifecycle(captureId, false);
|
||||
await handle.cleanup(stopRemote);
|
||||
unbindNativeAudioCaptureLifecycle(captureId, null);
|
||||
await handle.cleanup(stopRemote, endDetail);
|
||||
};
|
||||
const requestCleanup = (): void => {
|
||||
unbindNativeAudioCaptureLifecycle(captureId, true);
|
||||
if (!cleanupManagedBridgeById(captureId, true, 'track ended')) {
|
||||
cleanupBridgeAsync({captureId, cleanup}, true, 'track ended');
|
||||
const requestCleanup = (fault: string | null, endDetail: string, logContext: string): void => {
|
||||
unbindNativeAudioCaptureLifecycle(captureId, fault);
|
||||
if (!cleanupManagedBridgeById(captureId, true, logContext, endDetail)) {
|
||||
cleanupBridgeAsync({captureId, cleanup}, true, logContext, endDetail);
|
||||
}
|
||||
};
|
||||
for (const track of tracks) {
|
||||
const onEnded = (): void => requestCleanup();
|
||||
const trackKind = track === handle.track ? 'audio' : 'video';
|
||||
const onEnded = (): void =>
|
||||
requestCleanup(`native-audio-tap-${trackKind}-track-ended`, `${trackKind}-track-ended`, 'track ended');
|
||||
track.addEventListener('ended', onEnded);
|
||||
cleanupListeners.push(() => track.removeEventListener('ended', onEnded));
|
||||
restoreStops.push(patchTrackStopForCleanup(track, requestCleanup));
|
||||
restoreStops.push(patchTrackStopForCleanup(track, () => requestCleanup(null, 'caller-stopped', 'caller stop')));
|
||||
}
|
||||
return cleanup;
|
||||
}
|
||||
@@ -303,11 +316,11 @@ async function createNativeAudioBridgeWithSelfWindowAudio(captureId: string): Pr
|
||||
throw error;
|
||||
}
|
||||
let cleanedUp = false;
|
||||
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
|
||||
const cleanup = async (stopRemote: boolean = true, endDetail?: string): Promise<void> => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
await mixedTrack.cleanup();
|
||||
await nativeHandle.cleanup(stopRemote);
|
||||
await nativeHandle.cleanup(stopRemote, endDetail);
|
||||
};
|
||||
return {
|
||||
track: mixedTrack.track,
|
||||
@@ -421,7 +434,7 @@ function createDirectNativeAudioFramePump(
|
||||
cleanedUp = true;
|
||||
unsubscribeFrame();
|
||||
unsubscribeEnd();
|
||||
unbindNativeAudioCaptureLifecycle(captureId, true);
|
||||
unbindNativeAudioCaptureLifecycle(captureId, 'native-audio-tap-track-ended');
|
||||
onEnd?.(message);
|
||||
});
|
||||
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
|
||||
@@ -429,7 +442,7 @@ function createDirectNativeAudioFramePump(
|
||||
cleanedUp = true;
|
||||
unsubscribeFrame();
|
||||
unsubscribeEnd();
|
||||
unbindNativeAudioCaptureLifecycle(captureId, false);
|
||||
unbindNativeAudioCaptureLifecycle(captureId, null);
|
||||
if (stopRemote) {
|
||||
await nativeAudioApi.stop(captureId).catch((error) => {
|
||||
logger.warn('Failed to stop native screen-share audio frame pump', {captureId, error});
|
||||
|
||||
@@ -2,15 +2,29 @@
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
confirmDecodeStall,
|
||||
findSoftwareVideoDecoder,
|
||||
findStalledVideoDecoder,
|
||||
isSoftwareVideoImplementation,
|
||||
type StalledVideoDecoderInfo,
|
||||
} from './ScreenShareCodecDiagnostics';
|
||||
|
||||
function createStatsReport(entries: Array<Record<string, unknown>>): RTCStatsReport {
|
||||
return new Map(entries.map((entry) => [entry.id as string, entry])) as unknown as RTCStatsReport;
|
||||
}
|
||||
|
||||
function createStallInfo(overrides: Partial<StalledVideoDecoderInfo> = {}): StalledVideoDecoderInfo {
|
||||
return {
|
||||
codec: 'h264',
|
||||
mimeType: 'video/H264',
|
||||
packetsReceived: 400,
|
||||
bytesReceived: 250_000,
|
||||
framesDecoded: 0,
|
||||
framesReceived: 20,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('isSoftwareVideoImplementation', () => {
|
||||
it('detects common software encoder and decoder implementations', () => {
|
||||
expect(isSoftwareVideoImplementation('libvpx')).toBe(true);
|
||||
@@ -133,4 +147,76 @@ describe('findStalledVideoDecoder', () => {
|
||||
]);
|
||||
expect(findStalledVideoDecoder(stats)).toBeNull();
|
||||
});
|
||||
it('does not treat a first keyframe that is still arriving as a decoder stall', () => {
|
||||
const stats = createStatsReport([
|
||||
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
|
||||
{
|
||||
id: 'inbound-1',
|
||||
type: 'inbound-rtp',
|
||||
kind: 'video',
|
||||
codecId: 'codec-1',
|
||||
packetsReceived: 40,
|
||||
bytesReceived: 60_000,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
},
|
||||
]);
|
||||
expect(findStalledVideoDecoder(stats)).toBeNull();
|
||||
});
|
||||
it('does not treat packets that carry no video frames as a decoder stall', () => {
|
||||
const stats = createStatsReport([
|
||||
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
|
||||
{
|
||||
id: 'inbound-1',
|
||||
type: 'inbound-rtp',
|
||||
kind: 'video',
|
||||
codecId: 'codec-1',
|
||||
packetsReceived: 50,
|
||||
bytesReceived: 0,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
},
|
||||
]);
|
||||
expect(findStalledVideoDecoder(stats)).toBeNull();
|
||||
});
|
||||
it('treats whole frames that never decode as a decoder stall', () => {
|
||||
const stats = createStatsReport([
|
||||
{id: 'codec-1', type: 'codec', mimeType: 'video/H264'},
|
||||
{
|
||||
id: 'inbound-1',
|
||||
type: 'inbound-rtp',
|
||||
kind: 'video',
|
||||
codecId: 'codec-1',
|
||||
packetsReceived: 40,
|
||||
bytesReceived: 60_000,
|
||||
framesReceived: 40,
|
||||
framesDecoded: 0,
|
||||
},
|
||||
]);
|
||||
expect(findStalledVideoDecoder(stats)).toMatchObject({codec: 'h264', framesReceived: 40, framesDecoded: 0});
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmDecodeStall', () => {
|
||||
it('confirms a stall when more frames arrived and none of them decoded', () => {
|
||||
const second = createStallInfo({framesReceived: 45});
|
||||
expect(confirmDecodeStall(createStallInfo({framesReceived: 20}), second)).toBe(second);
|
||||
});
|
||||
it('does not confirm a stall when the second sample decoded a frame', () => {
|
||||
expect(confirmDecodeStall(createStallInfo(), createStallInfo({framesReceived: 45, framesDecoded: 3}))).toBeNull();
|
||||
});
|
||||
it('does not confirm a stall when the second sample is missing', () => {
|
||||
expect(confirmDecodeStall(createStallInfo(), null)).toBeNull();
|
||||
});
|
||||
it('does not confirm a stall when no new frames arrived between the samples', () => {
|
||||
expect(confirmDecodeStall(createStallInfo({framesReceived: 20}), createStallInfo({framesReceived: 20}))).toBeNull();
|
||||
});
|
||||
it('does not confirm a stall across two different codecs', () => {
|
||||
expect(
|
||||
confirmDecodeStall(
|
||||
createStallInfo({framesReceived: 20}),
|
||||
createStallInfo({codec: 'vp9', mimeType: 'video/VP9', framesReceived: 45}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface StalledVideoDecoderInfo {
|
||||
packetsReceived: number;
|
||||
bytesReceived: number;
|
||||
framesDecoded: number;
|
||||
framesReceived: number | null;
|
||||
framesReceived: number;
|
||||
}
|
||||
|
||||
function isSoftwareVideoStats(implementation: string | null, powerEfficient: boolean | null): boolean {
|
||||
@@ -127,11 +127,10 @@ export function findStalledVideoDecoder(stats: RTCStatsReport): StalledVideoDeco
|
||||
if (getStatsKind(report, codecs) !== 'video') continue;
|
||||
const framesDecoded = finiteNumber(report.framesDecoded);
|
||||
if (framesDecoded === null || framesDecoded > 0) continue;
|
||||
const framesReceived = finiteNumber(report.framesReceived);
|
||||
if (framesReceived === null || framesReceived < 1) continue;
|
||||
const packetsReceived = finiteNumber(report.packetsReceived) ?? 0;
|
||||
const bytesReceived = finiteNumber(report.bytesReceived) ?? 0;
|
||||
const framesReceived = finiteNumber(report.framesReceived);
|
||||
const hasVideoPayload = packetsReceived >= 10 || bytesReceived >= 8192 || (framesReceived ?? 0) >= 2;
|
||||
if (!hasVideoPayload) continue;
|
||||
const mimeType = report.codecId ? codecs.get(report.codecId)?.mimeType : undefined;
|
||||
const codec = getVideoCodecFromMimeType(mimeType);
|
||||
if (!codec) continue;
|
||||
@@ -147,20 +146,49 @@ export function findStalledVideoDecoder(stats: RTCStatsReport): StalledVideoDeco
|
||||
return null;
|
||||
}
|
||||
|
||||
export function confirmDecodeStall(
|
||||
first: StalledVideoDecoderInfo | null,
|
||||
second: StalledVideoDecoderInfo | null,
|
||||
): StalledVideoDecoderInfo | null {
|
||||
if (!first || !second) return null;
|
||||
if (first.codec !== second.codec || first.mimeType !== second.mimeType) return null;
|
||||
if (first.framesDecoded !== 0 || second.framesDecoded !== 0) return null;
|
||||
if (second.framesReceived <= first.framesReceived) return null;
|
||||
return second;
|
||||
}
|
||||
|
||||
export function scheduleScreenShareDecoderVerification(
|
||||
getStats: () => Promise<RTCStatsReport | undefined>,
|
||||
onComplete?: () => void,
|
||||
onDecodeFailure?: (failure: StalledVideoDecoderInfo) => void,
|
||||
): NodeJS.Timeout {
|
||||
return setTimeout(async () => {
|
||||
): () => void {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleConfirmation = (first: StalledVideoDecoderInfo): void => {
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
try {
|
||||
const stats = await getStats();
|
||||
const confirmed = confirmDecodeStall(first, stats ? findStalledVideoDecoder(stats) : null);
|
||||
if (cancelled || !confirmed) return;
|
||||
logger.warn('Screen share video decode is stalled', confirmed);
|
||||
onDecodeFailure?.(confirmed);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to confirm the screen share decode stall', {error});
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
}, DECODER_VERIFICATION_DELAY_MS);
|
||||
};
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
let firstStall: StalledVideoDecoderInfo | null = null;
|
||||
try {
|
||||
const stats = await getStats();
|
||||
if (!stats) return;
|
||||
const stalledDecoder = findStalledVideoDecoder(stats);
|
||||
if (stalledDecoder) {
|
||||
logger.warn('Screen share video decode is stalled', stalledDecoder);
|
||||
onDecodeFailure?.(stalledDecoder);
|
||||
}
|
||||
firstStall = findStalledVideoDecoder(stats);
|
||||
const decoder = findSoftwareVideoDecoder(stats);
|
||||
if (!decoder) return;
|
||||
logger.warn('Screen share is using a software decoder', decoder);
|
||||
@@ -168,7 +196,19 @@ export function scheduleScreenShareDecoderVerification(
|
||||
} catch (error) {
|
||||
logger.debug('Failed to verify screen share decoder', {error});
|
||||
} finally {
|
||||
onComplete?.();
|
||||
if (!cancelled) {
|
||||
if (firstStall) {
|
||||
scheduleConfirmation(firstStall);
|
||||
} else {
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, DECODER_VERIFICATION_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import {
|
||||
getRecentScreenShares,
|
||||
recordScreenShareEncoderVerification,
|
||||
recordScreenShareEndedModal,
|
||||
recordScreenShareRequestedCodec,
|
||||
recordScreenShareStartError,
|
||||
recordScreenShareStarted,
|
||||
recordScreenShareStopped,
|
||||
resetRecentScreenSharesForTests,
|
||||
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('ScreenShareLifecycleLog', () => {
|
||||
beforeEach(() => {
|
||||
resetRecentScreenSharesForTests();
|
||||
ActiveScreenShareSource.clear();
|
||||
});
|
||||
|
||||
it('keeps the first trigger that stopped a share instead of the later unpublish', () => {
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStopped('media-track-ended');
|
||||
recordScreenShareStopped('server-unpublish');
|
||||
|
||||
const [entry] = getRecentScreenShares();
|
||||
expect(entry.stopTrigger).toBe('media-track-ended');
|
||||
expect(entry.stoppedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('tells a user stop apart from a share that stopped on its own', () => {
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStopped('user');
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStopped('gateway-echo');
|
||||
|
||||
expect(getRecentScreenShares().map((entry) => entry.stopTrigger)).toEqual(['user', 'gateway-echo']);
|
||||
});
|
||||
|
||||
it('records the codec verification and the modal a stopped share showed', () => {
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareRequestedCodec('h264');
|
||||
recordScreenShareEncoderVerification('correct-negotiated', ['vp9']);
|
||||
recordScreenShareEndedModal('codec-policy-failed');
|
||||
recordScreenShareStopped('codec-republish-failed');
|
||||
|
||||
const [entry] = getRecentScreenShares();
|
||||
expect(entry.requestedCodec).toBe('h264');
|
||||
expect(entry.negotiatedCodecs).toEqual(['vp9']);
|
||||
expect(entry.encoderVerification).toBe('correct-negotiated');
|
||||
expect(entry.modalShown).toBe('codec-policy-failed');
|
||||
expect(entry.stopTrigger).toBe('codec-republish-failed');
|
||||
});
|
||||
|
||||
it('freezes the published source when the share stops', () => {
|
||||
ActiveScreenShareSource.setPublishedSource('app', 'window:7');
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStopped('user');
|
||||
ActiveScreenShareSource.clear();
|
||||
|
||||
expect(getRecentScreenShares()[0].sourceKind).toBe('app');
|
||||
});
|
||||
|
||||
it('reports the live published source while a share is still running', () => {
|
||||
recordScreenShareStarted();
|
||||
ActiveScreenShareSource.setPublishedSource('display', 'screen:1');
|
||||
|
||||
expect(getRecentScreenShares()[0].sourceKind).toBe('display');
|
||||
});
|
||||
|
||||
it('records a start failure without marking it as a stop', () => {
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStartError(new Error('portal refused'));
|
||||
|
||||
const [entry] = getRecentScreenShares();
|
||||
expect(entry.startError).toBe('Error: portal refused');
|
||||
expect(entry.stopTrigger).toBeNull();
|
||||
expect(entry.stoppedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a closed share untouched when a later trigger arrives', () => {
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStartError(new Error('portal refused'));
|
||||
recordScreenShareStopped('user');
|
||||
|
||||
expect(getRecentScreenShares()[0].stopTrigger).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps only the eight most recent shares', () => {
|
||||
for (let index = 0; index < 11; index += 1) {
|
||||
recordScreenShareStarted();
|
||||
recordScreenShareStopped('user');
|
||||
}
|
||||
|
||||
expect(getRecentScreenShares()).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ActiveScreenShareSource, {
|
||||
type PublishedScreenShareSource,
|
||||
} from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import type {VideoCodec} from 'livekit-client';
|
||||
|
||||
const MAX_RETAINED_SCREEN_SHARES = 8;
|
||||
|
||||
export type ScreenShareStopTrigger =
|
||||
| 'user'
|
||||
| 'media-track-ended'
|
||||
| 'server-unpublish'
|
||||
| 'gateway-echo'
|
||||
| 'codec-republish-failed';
|
||||
|
||||
export type ScreenShareEndedModal = 'source-stopped' | 'encoder-failed' | 'codec-policy-failed';
|
||||
|
||||
export type ScreenShareEncoderVerification =
|
||||
| 'recover-stalled'
|
||||
| 'ignore-repeated-stall'
|
||||
| 'accept-negotiated'
|
||||
| 'correct-negotiated';
|
||||
|
||||
export interface ScreenShareLifecycleEntry {
|
||||
startedAt: number;
|
||||
sourceKind: PublishedScreenShareSource | null;
|
||||
requestedCodec: VideoCodec | null;
|
||||
negotiatedCodecs: ReadonlyArray<VideoCodec> | null;
|
||||
encoderVerification: ScreenShareEncoderVerification | null;
|
||||
startError: string | null;
|
||||
stoppedAt: number | null;
|
||||
stopTrigger: ScreenShareStopTrigger | null;
|
||||
modalShown: ScreenShareEndedModal | null;
|
||||
}
|
||||
|
||||
let entries: Array<ScreenShareLifecycleEntry> = [];
|
||||
|
||||
function getOpenEntry(): ScreenShareLifecycleEntry | null {
|
||||
const entry = entries.at(-1);
|
||||
if (!entry || entry.stoppedAt != null || entry.startError != null) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function recordScreenShareStarted(): void {
|
||||
entries.push({
|
||||
startedAt: Date.now(),
|
||||
sourceKind: null,
|
||||
requestedCodec: null,
|
||||
negotiatedCodecs: null,
|
||||
encoderVerification: null,
|
||||
startError: null,
|
||||
stoppedAt: null,
|
||||
stopTrigger: null,
|
||||
modalShown: null,
|
||||
});
|
||||
if (entries.length > MAX_RETAINED_SCREEN_SHARES) {
|
||||
entries = entries.slice(-MAX_RETAINED_SCREEN_SHARES);
|
||||
}
|
||||
}
|
||||
|
||||
export function recordScreenShareStartError(error: unknown): void {
|
||||
const entry = getOpenEntry();
|
||||
if (!entry) return;
|
||||
entry.sourceKind = ActiveScreenShareSource.getPublishedSource();
|
||||
entry.startError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
}
|
||||
|
||||
export function recordScreenShareRequestedCodec(codec: VideoCodec): void {
|
||||
const entry = getOpenEntry();
|
||||
if (!entry) return;
|
||||
entry.requestedCodec = codec;
|
||||
}
|
||||
|
||||
export function recordScreenShareEncoderVerification(
|
||||
verification: ScreenShareEncoderVerification,
|
||||
negotiatedCodecs: ReadonlyArray<VideoCodec> | null,
|
||||
): void {
|
||||
const entry = getOpenEntry();
|
||||
if (!entry) return;
|
||||
entry.encoderVerification = verification;
|
||||
entry.negotiatedCodecs = negotiatedCodecs;
|
||||
}
|
||||
|
||||
export function recordScreenShareEndedModal(modal: ScreenShareEndedModal): void {
|
||||
const entry = getOpenEntry();
|
||||
if (!entry) return;
|
||||
entry.modalShown = modal;
|
||||
}
|
||||
|
||||
export function recordScreenShareStopped(trigger: ScreenShareStopTrigger): void {
|
||||
const entry = getOpenEntry();
|
||||
if (!entry) return;
|
||||
entry.sourceKind = ActiveScreenShareSource.getPublishedSource();
|
||||
entry.stoppedAt = Date.now();
|
||||
entry.stopTrigger = trigger;
|
||||
}
|
||||
|
||||
export function getRecentScreenShares(): Array<ScreenShareLifecycleEntry> {
|
||||
const open = getOpenEntry();
|
||||
return entries.map((entry) => ({
|
||||
...entry,
|
||||
sourceKind: entry === open ? ActiveScreenShareSource.getPublishedSource() : entry.sourceKind,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resetRecentScreenSharesForTests(): void {
|
||||
entries = [];
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {StatsForNerdsData} from '@app/features/voice/utils/VoiceStatsForNerdsPresenter';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const failureRecord = {
|
||||
code: -2303,
|
||||
reason: 'first-frame-timeout',
|
||||
reportedAt: 1000,
|
||||
streamKey: 'guild-a:channel-a:connection-a',
|
||||
trackSid: 'TR_screen_share',
|
||||
participantIdentity: 'user_1_connection-a',
|
||||
inbound: {
|
||||
packetsReceived: 4210,
|
||||
bytesReceived: 3_500_000,
|
||||
framesReceived: 0,
|
||||
framesDecoded: 0,
|
||||
keyFramesDecoded: 0,
|
||||
framesDropped: 0,
|
||||
pliCount: 7,
|
||||
firCount: 0,
|
||||
nackCount: 2,
|
||||
freezeCount: 0,
|
||||
decoderImplementation: 'ExternalDecoder (D3D11VideoDecoder)',
|
||||
powerEfficientDecoder: true,
|
||||
mimeType: 'video/H264',
|
||||
sdpFmtpLine: 'profile-level-id=42e01f',
|
||||
frameWidth: 1920,
|
||||
frameHeight: 1080,
|
||||
},
|
||||
tile: {
|
||||
hasVideoElement: true,
|
||||
readyState: 0,
|
||||
videoWidth: 0,
|
||||
videoHeight: 0,
|
||||
clientWidth: 960,
|
||||
clientHeight: 540,
|
||||
visibilityState: 'visible',
|
||||
},
|
||||
};
|
||||
|
||||
const localCodecs = [{name: 'h264', type: 'video', payload_type: 108, priority: 900, encode: true, decode: true}];
|
||||
|
||||
const subscriptionDebugReport = {capturedAt: '2026-09-11T00:00:00.000Z', failures: []};
|
||||
|
||||
vi.mock('@app/features/app/config/Config', () => ({default: {PUBLIC_BUILD_VERSION: 'test'}}));
|
||||
|
||||
vi.mock('@app/features/devtools/utils/DesktopTroubleshootingUtils', () => ({
|
||||
getCachedDesktopTroubleshootingSettings: () => null,
|
||||
getDesktopTroubleshootingSettings: async () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/ui/utils/NativeUtils', () => ({
|
||||
getElectronAPI: () => null,
|
||||
getNativePlatform: async () => 'web',
|
||||
isDesktop: () => false,
|
||||
supportsDesktopScreenShareAudioCapture: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/diagnostics/VoiceSubscriptionDebugReport', () => ({
|
||||
collectVoiceSubscriptionDebugReport: () => subscriptionDebugReport,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/MediaEngineFacade', () => ({
|
||||
default: {
|
||||
room: null,
|
||||
connected: false,
|
||||
connecting: false,
|
||||
guildId: null,
|
||||
channelId: null,
|
||||
connectionId: null,
|
||||
voiceServerEndpoint: null,
|
||||
voiceStats: null,
|
||||
perTrackStats: [],
|
||||
statsTimeSeries: [],
|
||||
publisherTransport: null,
|
||||
subscriberTransport: null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/ScreenShareCodecNegotiation', () => ({
|
||||
default: {
|
||||
getSelectedCodec: () => 'h264',
|
||||
getLocalCodecAdvertisements: () => localCodecs,
|
||||
getRemoteDecodeCodecsByIdentity: () => ({'user_1_connection-a': ['h264', 'vp8']}),
|
||||
},
|
||||
getScreenShareCodecPreferenceOrder: () => ['h264', 'vp8'],
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/voice_screen_share_manager/NativeEngineAudioTrackPump', () => ({
|
||||
getNativeEngineAudioTrackPumpStats: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/engine/voice_screen_share_manager/shared', () => ({
|
||||
getPublishedScreenShareMaxBitrateBps: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/state/ScreenShareWatchFailures', () => ({
|
||||
ScreenShareWatchFailures: {getFailureHistory: () => [failureRecord]},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({
|
||||
default: new Proxy({}, {get: () => () => null}),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/CodecCapabilityDetector', () => ({
|
||||
getCameraPublishCodecPolicy: () => ({primary: 'h264'}),
|
||||
getCodecCapabilityReport: () => ({}),
|
||||
getLiveKitSupportedCodecs: () => ['h264', 'vp8'],
|
||||
selectOptimalScreenShareCodec: () => 'h264',
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/GpuEncoderCapabilities', () => ({loadGpuEncoderReport: async () => null}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/NativeAudioCaptureBridge', () => ({
|
||||
getNativeAudioBridgeStats: () => null,
|
||||
getNativeAudioCaptureDiagnosticState: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/ScreenShareEnvironment', () => ({getDisplayShareEnvironment: async () => ({})}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/ScreenShareOptions', () => ({
|
||||
getScreenShareBitrateBps: () => 2_500_000,
|
||||
resolveStreamingModeSettings: () => ({resolution: 1080, frameRate: 60}),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/VideoDecoderCapabilities', () => ({
|
||||
getScreenShareDecodeFailures: () => new Set(['vp9']),
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/VideoQualityEntitlement', () => ({hasHigherVideoQuality: () => false}));
|
||||
|
||||
vi.mock('@app/features/voice/utils/VoiceStatsForNerdsPresenter', () => ({
|
||||
buildVoiceStatsForNerdsPresentation: () => ({}),
|
||||
collectScreenShareAudioPublicationDiagnostics: () => [],
|
||||
}));
|
||||
|
||||
const {buildStatsForNerdsCopyPayload} = await import('@app/features/voice/utils/StatsForNerdsCopy');
|
||||
|
||||
describe('buildStatsForNerdsCopyPayload watch failure diagnostics', () => {
|
||||
it('copies the screen share failure ring with its codes, track sids and inbound counters', async () => {
|
||||
const payload = await buildStatsForNerdsCopyPayload({} as StatsForNerdsData);
|
||||
|
||||
expect(payload.screenShareWatchFailures).toEqual([failureRecord]);
|
||||
});
|
||||
|
||||
it('copies the screen share negotiation block with the selected codec and decode sets', async () => {
|
||||
const payload = await buildStatsForNerdsCopyPayload({} as StatsForNerdsData);
|
||||
|
||||
expect(payload.screenShareNegotiation).toEqual({
|
||||
selectedCodec: 'h264',
|
||||
localCodecs,
|
||||
remoteDecodeCodecsByIdentity: {'user_1_connection-a': ['h264', 'vp8']},
|
||||
decodeFailures: ['vp9'],
|
||||
});
|
||||
});
|
||||
|
||||
it('copies the voice subscription debug report that was only reachable from the debug console', async () => {
|
||||
const payload = await buildStatsForNerdsCopyPayload({} as StatsForNerdsData);
|
||||
|
||||
expect(payload.voiceSubscriptionDebug).toEqual(subscriptionDebugReport);
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
isDesktop,
|
||||
supportsDesktopScreenShareAudioCapture,
|
||||
} from '@app/features/ui/utils/NativeUtils';
|
||||
import {collectVoiceSubscriptionDebugReport} from '@app/features/voice/diagnostics/VoiceSubscriptionDebugReport';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import ScreenShareCodecNegotiation, {
|
||||
getScreenShareCodecPreferenceOrder,
|
||||
} from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {getNativeEngineAudioTrackPumpStats} from '@app/features/voice/engine/voice_screen_share_manager/NativeEngineAudioTrackPump';
|
||||
import {getPublishedScreenShareMaxBitrateBps} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import {ScreenShareWatchFailures} from '@app/features/voice/state/ScreenShareWatchFailures';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
type CodecCapabilityReport,
|
||||
@@ -31,7 +33,9 @@ import {
|
||||
getNativeAudioCaptureDiagnosticState,
|
||||
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
|
||||
import {getDisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {getRecentScreenShares} from '@app/features/voice/utils/ScreenShareLifecycleLog';
|
||||
import {getScreenShareBitrateBps, resolveStreamingModeSettings} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {getScreenShareDecodeFailures} from '@app/features/voice/utils/VideoDecoderCapabilities';
|
||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {
|
||||
buildVoiceStatsForNerdsPresentation,
|
||||
@@ -54,6 +58,14 @@ function safeError(error: unknown): Record<string, unknown> {
|
||||
return {message: String(error)};
|
||||
}
|
||||
|
||||
function safeCollect<T>(collect: () => T): T | {error: Record<string, unknown>} {
|
||||
try {
|
||||
return collect();
|
||||
} catch (error) {
|
||||
return {error: safeError(error)};
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
label: string,
|
||||
task: Promise<T>,
|
||||
@@ -584,6 +596,15 @@ export async function buildStatsForNerdsCopyPayload(data: StatsForNerdsData): Pr
|
||||
mediaDevices,
|
||||
voiceSettings,
|
||||
voiceSession: summarizeRoom(),
|
||||
recentScreenShares: getRecentScreenShares(),
|
||||
screenShareWatchFailures: safeCollect(() => ScreenShareWatchFailures.getFailureHistory()),
|
||||
screenShareNegotiation: safeCollect(() => ({
|
||||
selectedCodec: ScreenShareCodecNegotiation.getSelectedCodec(),
|
||||
localCodecs: ScreenShareCodecNegotiation.getLocalCodecAdvertisements(),
|
||||
remoteDecodeCodecsByIdentity: ScreenShareCodecNegotiation.getRemoteDecodeCodecsByIdentity(),
|
||||
decodeFailures: [...getScreenShareDecodeFailures()],
|
||||
})),
|
||||
voiceSubscriptionDebug: safeCollect(() => collectVoiceSubscriptionDebugReport()),
|
||||
desktop,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,12 +28,16 @@ export function hasDeviceLabels(devices: ReadonlyArray<MediaDeviceInfo>): boolea
|
||||
|
||||
export type VoiceMediaPermissionType = 'audio' | 'video';
|
||||
export type VoiceMediaPermissionStatus = 'idle' | 'loading' | 'granted' | 'denied';
|
||||
type VoiceMediaPermissionIntent = 'confirm' | 'request';
|
||||
|
||||
const MAX_ENUMERATION_CHAIN_PASSES = 3;
|
||||
const VOICE_MEDIA_PERMISSION_TYPES: ReadonlyArray<VoiceMediaPermissionType> = ['audio', 'video'];
|
||||
const VOICE_MEDIA_PERMISSION_INTENTS: ReadonlyArray<VoiceMediaPermissionIntent> = ['confirm', 'request'];
|
||||
const MAX_ENUMERATION_CHAIN_PASSES = 1 + VOICE_MEDIA_PERMISSION_TYPES.length * VOICE_MEDIA_PERMISSION_INTENTS.length;
|
||||
|
||||
export interface EnsureVoiceDevicesOptions {
|
||||
requestPermissions?: boolean;
|
||||
requestPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
|
||||
confirmPermissionTypes?: ReadonlyArray<VoiceMediaPermissionType>;
|
||||
forceRefresh?: boolean;
|
||||
}
|
||||
|
||||
@@ -393,8 +397,8 @@ class VoiceDeviceManager {
|
||||
};
|
||||
private listeners = new Set<Listener>();
|
||||
private enumerationChainPromise: Promise<VoiceDeviceState> | null = null;
|
||||
private scheduledEnumerationPermissionTypes = new Set<VoiceMediaPermissionType>();
|
||||
private enumerationChainPermissionTypes = new Set<VoiceMediaPermissionType>();
|
||||
private scheduledEnumerationPermissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
|
||||
private enumerationChainPermissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
|
||||
private hasEnumeratedDevices = false;
|
||||
|
||||
constructor() {
|
||||
@@ -416,11 +420,10 @@ class VoiceDeviceManager {
|
||||
}
|
||||
|
||||
public async ensureDevices(options: EnsureVoiceDevicesOptions = {}): Promise<VoiceDeviceState> {
|
||||
const requestPermissionTypes = this.resolveRequestedPermissionTypes(options);
|
||||
const requestPermissions = requestPermissionTypes.length > 0;
|
||||
const permissionIntents = this.resolvePermissionIntents(options);
|
||||
const forceRefresh = options.forceRefresh ?? false;
|
||||
logger.debug('ensureDevices called', {
|
||||
requestPermissionTypes,
|
||||
permissionIntents: Object.fromEntries(permissionIntents),
|
||||
forceRefresh,
|
||||
hasEnumeratingPromise: !!this.enumerationChainPromise,
|
||||
currentState: {
|
||||
@@ -428,39 +431,50 @@ class VoiceDeviceManager {
|
||||
permissionStatus: this.state.permissionStatus,
|
||||
},
|
||||
});
|
||||
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(requestPermissions)) {
|
||||
if (!forceRefresh && !this.enumerationChainPromise && this.canUseCachedState(permissionIntents.size > 0)) {
|
||||
logger.debug('Using cached device state');
|
||||
return this.state;
|
||||
}
|
||||
if (this.enumerationChainPromise) {
|
||||
this.scheduleMissingPermissionTypes(requestPermissionTypes);
|
||||
this.scheduleMissingPermissionIntents(permissionIntents);
|
||||
logger.debug('Joining existing enumeration promise');
|
||||
return this.enumerationChainPromise;
|
||||
}
|
||||
logger.debug('Creating new enumeration promise');
|
||||
return this.startEnumerationChain(requestPermissionTypes);
|
||||
return this.startEnumerationChain(permissionIntents);
|
||||
}
|
||||
|
||||
private scheduleMissingPermissionTypes(requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>): void {
|
||||
private scheduleMissingPermissionIntents(
|
||||
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
|
||||
): void {
|
||||
for (const [type, intent] of permissionIntents) {
|
||||
const chainIntent = this.enumerationChainPermissionIntents.get(type);
|
||||
if (chainIntent === 'request' || chainIntent === intent) continue;
|
||||
this.enumerationChainPermissionIntents.set(type, intent);
|
||||
this.scheduledEnumerationPermissionIntents.set(type, intent);
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePermissionIntents(
|
||||
options: EnsureVoiceDevicesOptions,
|
||||
): Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent> {
|
||||
const permissionIntents = new Map<VoiceMediaPermissionType, VoiceMediaPermissionIntent>();
|
||||
for (const type of options.confirmPermissionTypes ?? []) {
|
||||
permissionIntents.set(type, 'confirm');
|
||||
}
|
||||
const requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType> =
|
||||
options.requestPermissionTypes ?? (options.requestPermissions === true ? VOICE_MEDIA_PERMISSION_TYPES : []);
|
||||
for (const type of requestPermissionTypes) {
|
||||
if (this.enumerationChainPermissionTypes.has(type)) continue;
|
||||
this.enumerationChainPermissionTypes.add(type);
|
||||
this.scheduledEnumerationPermissionTypes.add(type);
|
||||
permissionIntents.set(type, 'request');
|
||||
}
|
||||
return permissionIntents;
|
||||
}
|
||||
|
||||
private resolveRequestedPermissionTypes(options: EnsureVoiceDevicesOptions): Array<VoiceMediaPermissionType> {
|
||||
if (options.requestPermissionTypes) {
|
||||
return [...new Set(options.requestPermissionTypes)];
|
||||
}
|
||||
return options.requestPermissions === true ? ['audio', 'video'] : [];
|
||||
}
|
||||
|
||||
private canUseCachedState(requestPermissions: boolean): boolean {
|
||||
private canUseCachedState(hasPermissionIntents: boolean): boolean {
|
||||
if (!this.hasEnumeratedDevices) {
|
||||
return false;
|
||||
}
|
||||
return !requestPermissions;
|
||||
return !hasPermissionIntents;
|
||||
}
|
||||
|
||||
private updatePermissionStatusForTypes(
|
||||
@@ -475,42 +489,44 @@ class VoiceDeviceManager {
|
||||
}
|
||||
|
||||
private startEnumerationChain(
|
||||
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
|
||||
): Promise<VoiceDeviceState> {
|
||||
this.enumerationChainPermissionTypes = new Set(requestPermissionTypes);
|
||||
const pendingPromise = this.runEnumerationChain(requestPermissionTypes).finally(() => {
|
||||
this.enumerationChainPermissionIntents = new Map(permissionIntents);
|
||||
const pendingPromise = this.runEnumerationChain(permissionIntents).finally(() => {
|
||||
if (this.enumerationChainPromise !== pendingPromise) return;
|
||||
logger.debug('Enumeration promise completed');
|
||||
this.enumerationChainPromise = null;
|
||||
this.scheduledEnumerationPermissionTypes.clear();
|
||||
this.enumerationChainPermissionTypes.clear();
|
||||
this.scheduledEnumerationPermissionIntents.clear();
|
||||
this.enumerationChainPermissionIntents.clear();
|
||||
});
|
||||
this.enumerationChainPromise = pendingPromise;
|
||||
return pendingPromise;
|
||||
}
|
||||
|
||||
private async runEnumerationChain(
|
||||
initialPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||
initialPermissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
|
||||
): Promise<VoiceDeviceState> {
|
||||
let requestPermissionTypes = [...initialPermissionTypes];
|
||||
let permissionIntents = initialPermissionIntents;
|
||||
let state = this.state;
|
||||
for (let pass = 0; pass < MAX_ENUMERATION_CHAIN_PASSES; pass += 1) {
|
||||
state = await this.enumerateDevices(requestPermissionTypes);
|
||||
if (this.scheduledEnumerationPermissionTypes.size === 0) return state;
|
||||
requestPermissionTypes = [...this.scheduledEnumerationPermissionTypes];
|
||||
this.scheduledEnumerationPermissionTypes.clear();
|
||||
state = await this.enumerateDevices(permissionIntents);
|
||||
if (this.scheduledEnumerationPermissionIntents.size === 0) return state;
|
||||
permissionIntents = new Map(this.scheduledEnumerationPermissionIntents);
|
||||
this.scheduledEnumerationPermissionIntents.clear();
|
||||
}
|
||||
if (this.scheduledEnumerationPermissionTypes.size > 0) {
|
||||
if (this.scheduledEnumerationPermissionIntents.size > 0) {
|
||||
throw new Error(`Voice device enumeration exceeded ${MAX_ENUMERATION_CHAIN_PASSES} bounded passes`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private async enumerateDevices(
|
||||
requestPermissionTypes: ReadonlyArray<VoiceMediaPermissionType>,
|
||||
permissionIntents: ReadonlyMap<VoiceMediaPermissionType, VoiceMediaPermissionIntent>,
|
||||
): Promise<VoiceDeviceState> {
|
||||
const permissionTypes = [...permissionIntents.keys()];
|
||||
const requestPermissionTypes = permissionTypes.filter((type) => permissionIntents.get(type) === 'request');
|
||||
const requestPermissions = requestPermissionTypes.length > 0;
|
||||
logger.debug('enumerateDevices started', {requestPermissionTypes});
|
||||
logger.debug('enumerateDevices started', {permissionIntents: Object.fromEntries(permissionIntents)});
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
logger.debug('Navigator or mediaDevices API not available');
|
||||
return this.state;
|
||||
@@ -535,20 +551,20 @@ class VoiceDeviceManager {
|
||||
hasLabel: !!d.label,
|
||||
})),
|
||||
});
|
||||
const permissionTypesWithLabels = requestPermissionTypes.filter((type) => {
|
||||
const permissionTypesWithLabels = permissionTypes.filter((type) => {
|
||||
const requiredKind = type === 'audio' ? 'audioinput' : 'videoinput';
|
||||
return devices.some((device) => device.kind === requiredKind && device.label !== '');
|
||||
});
|
||||
for (const type of permissionTypesWithLabels) {
|
||||
permissionStatus[type] = 'granted';
|
||||
}
|
||||
if (requestPermissions && permissionTypesWithLabels.length === requestPermissionTypes.length) {
|
||||
const unresolvedRequestTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
|
||||
if (requestPermissions && unresolvedRequestTypes.length === 0) {
|
||||
logger.debug('Devices have labels, permissions already granted');
|
||||
} else if (requestPermissions && isDesktop()) {
|
||||
} else if (unresolvedRequestTypes.length > 0 && isDesktop()) {
|
||||
logger.debug('No labels detected; attempting native permission flow');
|
||||
const unresolvedNativeTypes = requestPermissionTypes.filter((type) => permissionStatus[type] !== 'granted');
|
||||
const nativeResults = await Promise.all(
|
||||
unresolvedNativeTypes.map(async (type) => {
|
||||
unresolvedRequestTypes.map(async (type) => {
|
||||
try {
|
||||
return {
|
||||
type,
|
||||
|
||||
@@ -24,13 +24,17 @@ const makeStream = (kinds: Array<'audio' | 'video'>): MediaStream => {
|
||||
};
|
||||
|
||||
const getUserMediaCalls: Array<MediaStreamConstraints> = [];
|
||||
const enumerationHooks: Array<() => void> = [];
|
||||
let labelledDevices = false;
|
||||
|
||||
const fakeMediaDevices: FakeMediaDevices = {
|
||||
enumerateDevices: async () =>
|
||||
labelledDevices
|
||||
enumerateDevices: async () => {
|
||||
await Promise.resolve();
|
||||
enumerationHooks.shift()?.();
|
||||
return labelledDevices
|
||||
? [makeDevice('audioinput', 'mic-1', 'Studio Mic'), makeDevice('videoinput', 'cam-1', 'Studio Camera')]
|
||||
: [makeDevice('audioinput', 'mic-1', ''), makeDevice('videoinput', 'cam-1', '')],
|
||||
: [makeDevice('audioinput', 'mic-1', ''), makeDevice('videoinput', 'cam-1', '')];
|
||||
},
|
||||
getUserMedia: async (constraints) => {
|
||||
getUserMediaCalls.push(constraints);
|
||||
labelledDevices = true;
|
||||
@@ -43,12 +47,21 @@ const fakeMediaDevices: FakeMediaDevices = {
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
|
||||
const fakeElectron = {
|
||||
platform: 'darwin',
|
||||
checkMediaAccess: vi.fn(async () => 'granted'),
|
||||
requestMediaAccess: vi.fn(async () => true),
|
||||
};
|
||||
|
||||
let originalNavigator: PropertyDescriptor | undefined;
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
getUserMediaCalls.length = 0;
|
||||
enumerationHooks.length = 0;
|
||||
labelledDevices = false;
|
||||
fakeElectron.checkMediaAccess.mockClear();
|
||||
fakeElectron.requestMediaAccess.mockClear();
|
||||
originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
@@ -80,6 +93,13 @@ const loadManager = async () => {
|
||||
return module.voiceDeviceManager;
|
||||
};
|
||||
|
||||
const runAsDesktop = () => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {matchMedia: () => ({matches: false}), electron: fakeElectron},
|
||||
});
|
||||
};
|
||||
|
||||
describe('voiceDeviceManager permission types', () => {
|
||||
test('an audio-only request never asks the browser for video', async () => {
|
||||
const manager = await loadManager();
|
||||
@@ -111,4 +131,63 @@ describe('voiceDeviceManager permission types', () => {
|
||||
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
|
||||
expect(state.inputDevices.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a confirmation never asks the browser for access when device labels are hidden', async () => {
|
||||
const manager = await loadManager();
|
||||
const state = await manager.ensureDevices({confirmPermissionTypes: ['audio', 'video'], forceRefresh: true});
|
||||
expect(getUserMediaCalls).toEqual([]);
|
||||
expect(state.permissionStatus).toEqual({audio: 'idle', video: 'idle'});
|
||||
});
|
||||
|
||||
test('a confirmation marks only the confirmed type granted from exposed device labels', async () => {
|
||||
labelledDevices = true;
|
||||
const manager = await loadManager();
|
||||
const state = await manager.ensureDevices({confirmPermissionTypes: ['audio'], forceRefresh: true});
|
||||
expect(getUserMediaCalls).toEqual([]);
|
||||
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'idle'});
|
||||
});
|
||||
|
||||
test('a request that joins an in-flight confirmation of the same type still asks the browser', async () => {
|
||||
const manager = await loadManager();
|
||||
const confirmation = manager.ensureDevices({confirmPermissionTypes: ['audio'], forceRefresh: true});
|
||||
const request = manager.ensureDevices({requestPermissionTypes: ['audio']});
|
||||
const [, state] = await Promise.all([confirmation, request]);
|
||||
expect(getUserMediaCalls).toEqual([{audio: true, video: false}]);
|
||||
expect(state.permissionStatus.audio).toBe('granted');
|
||||
});
|
||||
|
||||
test('confirmations escalated into requests for every type finish within the enumeration pass bound', async () => {
|
||||
const manager = await loadManager();
|
||||
const joins: Array<Promise<unknown>> = [];
|
||||
enumerationHooks.push(
|
||||
() => joins.push(manager.ensureDevices({confirmPermissionTypes: ['audio']})),
|
||||
() => joins.push(manager.ensureDevices({requestPermissionTypes: ['audio']})),
|
||||
() => joins.push(manager.ensureDevices({confirmPermissionTypes: ['video']})),
|
||||
() => {},
|
||||
() => joins.push(manager.ensureDevices({requestPermissionTypes: ['video']})),
|
||||
);
|
||||
const state = await manager.ensureDevices({forceRefresh: true});
|
||||
await Promise.all(joins);
|
||||
expect(getUserMediaCalls).toEqual([{audio: true, video: false}]);
|
||||
expect(state.permissionStatus).toEqual({audio: 'granted', video: 'granted'});
|
||||
});
|
||||
|
||||
test('a confirmation never runs the native permission flow on desktop', async () => {
|
||||
runAsDesktop();
|
||||
const manager = await loadManager();
|
||||
const state = await manager.ensureDevices({confirmPermissionTypes: ['audio'], forceRefresh: true});
|
||||
expect(fakeElectron.checkMediaAccess).not.toHaveBeenCalled();
|
||||
expect(fakeElectron.requestMediaAccess).not.toHaveBeenCalled();
|
||||
expect(getUserMediaCalls).toEqual([]);
|
||||
expect(state.permissionStatus.audio).toBe('idle');
|
||||
});
|
||||
|
||||
test('a request runs the native permission flow on desktop', async () => {
|
||||
runAsDesktop();
|
||||
const manager = await loadManager();
|
||||
const state = await manager.ensureDevices({requestPermissionTypes: ['audio'], forceRefresh: true});
|
||||
expect(fakeElectron.checkMediaAccess).toHaveBeenCalledWith('microphone');
|
||||
expect(getUserMediaCalls).toEqual([]);
|
||||
expect(state.permissionStatus.audio).toBe('granted');
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -168,10 +168,10 @@ export async function createGeneratorBridge(
|
||||
pumpPendingFrames();
|
||||
}
|
||||
};
|
||||
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
|
||||
const cleanup = async (stopRemote: boolean = true, endDetail?: string): Promise<void> => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
endBridgeStats(captureId, 'cleanup', stopRemote ? 'caller-stopped' : 'remote-ended');
|
||||
endBridgeStats(captureId, 'cleanup', endDetail ?? (stopRemote ? 'caller-stopped' : 'remote-ended'));
|
||||
unsubscribeFrame();
|
||||
unsubscribeEnd();
|
||||
clearPrebufferTimer();
|
||||
|
||||
+2
-2
@@ -128,10 +128,10 @@ export async function createScriptProcessorBridge(
|
||||
right.fill(0);
|
||||
pendingChunks.drainInto(left, right);
|
||||
};
|
||||
const cleanup = async (stopRemote: boolean = true): Promise<void> => {
|
||||
const cleanup = async (stopRemote: boolean = true, endDetail?: string): Promise<void> => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
endBridgeStats(captureId, 'cleanup', stopRemote ? 'caller-stopped' : 'remote-ended');
|
||||
endBridgeStats(captureId, 'cleanup', endDetail ?? (stopRemote ? 'caller-stopped' : 'remote-ended'));
|
||||
unsubscribeFrame();
|
||||
unsubscribeEnd();
|
||||
processor.onaudioprocess = null;
|
||||
|
||||
@@ -32,14 +32,16 @@ export type ArmedNativeAudioCapture =
|
||||
kind: 'self-window-web-audio';
|
||||
};
|
||||
|
||||
export type NativeAudioBridgeCleanup = (stopRemote?: boolean, endDetail?: string) => Promise<void>;
|
||||
|
||||
export interface NativeAudioBridgeHandle {
|
||||
track: MediaStreamTrack;
|
||||
cleanup: (stopRemote?: boolean) => Promise<void>;
|
||||
cleanup: NativeAudioBridgeCleanup;
|
||||
}
|
||||
|
||||
export interface ActiveNativeAudioBridge {
|
||||
captureId: string;
|
||||
cleanup: (stopRemote?: boolean) => Promise<void>;
|
||||
cleanup: NativeAudioBridgeCleanup;
|
||||
linuxRule?: NativeAudioRoutingRule;
|
||||
includeSelfWindowAudio?: boolean;
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ const transformedSource = esbuild.transformSync(source, {
|
||||
target: 'node20',
|
||||
}).code;
|
||||
|
||||
const WGC_DISABLED_FEATURES = [
|
||||
'AllowWgcScreenCapturer',
|
||||
'AllowWgcWindowCapturer',
|
||||
'AllowWgcScreenZeroHz',
|
||||
'AllowWgcWindowZeroHz',
|
||||
'WebRtcWgcRequireBorder',
|
||||
];
|
||||
|
||||
function loadChromiumRuntime(platform = 'win32') {
|
||||
const appendedSwitches = [];
|
||||
const app = {
|
||||
@@ -66,25 +58,20 @@ function loadChromiumRuntime(platform = 'win32') {
|
||||
}
|
||||
|
||||
describe('ChromiumRuntime Windows capture policy', () => {
|
||||
test('adds all known WebRTC WGC capturer features to the Windows disable set', () => {
|
||||
test('leaves the choice of Windows graphics capture to Chromium', () => {
|
||||
const {module} = loadChromiumRuntime('win32');
|
||||
const features = new Set(['ExistingFeature']);
|
||||
const features = new Set(module.BASE_DISABLED_CHROMIUM_FEATURES);
|
||||
|
||||
module.addWindowsWebRtcWgcDisabledFeatures(features);
|
||||
|
||||
for (const feature of WGC_DISABLED_FEATURES) {
|
||||
assert.equal(features.has(feature), true);
|
||||
for (const [name, value] of Object.entries(module)) {
|
||||
if (typeof value === 'function' && name.startsWith('addWindows') && name.endsWith('Features')) {
|
||||
value(features);
|
||||
}
|
||||
}
|
||||
assert.equal(features.has('ExistingFeature'), true);
|
||||
});
|
||||
|
||||
test('does not add WGC feature switches on non-Windows platforms', () => {
|
||||
const {module} = loadChromiumRuntime('linux');
|
||||
const features = new Set(['ExistingFeature']);
|
||||
|
||||
module.addWindowsWebRtcWgcDisabledFeatures(features);
|
||||
|
||||
assert.deepEqual([...features], ['ExistingFeature']);
|
||||
assert.deepEqual(
|
||||
[...features].filter((feature) => feature.includes('Wgc')),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,13 +36,6 @@ const MACOS_PRE_SEQUOIA_SCREEN_CAPTURE_DISABLED_FEATURES = [
|
||||
'WarmScreenCaptureSonoma',
|
||||
'UseSCContentSharingPicker',
|
||||
];
|
||||
const WINDOWS_WEBRTC_WGC_DISABLED_FEATURES = [
|
||||
'AllowWgcScreenCapturer',
|
||||
'AllowWgcWindowCapturer',
|
||||
'AllowWgcScreenZeroHz',
|
||||
'AllowWgcWindowZeroHz',
|
||||
'WebRtcWgcRequireBorder',
|
||||
];
|
||||
const WINDOWS_NVIDIA_HEVC_DECODE_WORKAROUND_DEVICE_IDS = new Set([
|
||||
4928, 4929, 4932, 4934, 4935, 4936, 4937, 4939, 4941, 4942, 4943, 4986, 4987, 4992, 4993, 4994, 5008, 5009, 5010,
|
||||
5011, 5016, 5017, 5018, 5019, 5020, 5021, 5040, 5041, 5042, 5043, 5044, 5046, 5049, 5050, 5051, 5052, 5056, 5058,
|
||||
@@ -312,13 +305,6 @@ export function addMacosPreSequoiaScreenCaptureDisabledFeatures(features: Set<st
|
||||
}
|
||||
}
|
||||
|
||||
export function addWindowsWebRtcWgcDisabledFeatures(features: Set<string>): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
for (const feature of WINDOWS_WEBRTC_WGC_DISABLED_FEATURES) {
|
||||
features.add(feature);
|
||||
}
|
||||
}
|
||||
|
||||
export function addLinuxHardwareVideoEncodeFeatures(features: Set<string>): void {
|
||||
if (process.platform !== 'linux') return;
|
||||
features.add('AcceleratedVideoEncoder');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {describe, test} from 'node:test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const esbuild = require('esbuild');
|
||||
|
||||
const sourcePath = fileURLToPath(new URL('./PlatformInfo.ts', import.meta.url));
|
||||
const source = readFileSync(sourcePath, 'utf8');
|
||||
const transformedSource = esbuild.transformSync(source, {
|
||||
loader: 'ts',
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
}).code;
|
||||
|
||||
function loadPlatformInfo(platform = 'win32') {
|
||||
const app = {
|
||||
getVersion: () => '0.0.0-test',
|
||||
commandLine: {
|
||||
getSwitchValue: () => '',
|
||||
hasSwitch: () => true,
|
||||
},
|
||||
};
|
||||
const osModule = {
|
||||
arch: () => 'x64',
|
||||
release: () => '10.0.26100',
|
||||
};
|
||||
|
||||
function requireStub(specifier) {
|
||||
if (specifier === 'node:module') {
|
||||
return {
|
||||
createRequire: () => (moduleSpecifier) => {
|
||||
throw new Error(`Unexpected native module require: ${moduleSpecifier}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (specifier === 'node:os') return osModule;
|
||||
if (specifier === 'electron') return {app};
|
||||
if (specifier === '@electron/common/BuildChannel') return {BUILD_CHANNEL: 'stable'};
|
||||
if (specifier === '@electron/common/UserDataPath') return {isPortableMode: () => false};
|
||||
if (specifier === '@electron/main/LinuxSandbox') {
|
||||
return {getFlatpakAppId: () => null, isFlatpakRuntime: () => false};
|
||||
}
|
||||
throw new Error(`Unexpected import: ${specifier}`);
|
||||
}
|
||||
|
||||
const module = {exports: {}};
|
||||
const context = vm.createContext({
|
||||
require: requireStub,
|
||||
module,
|
||||
exports: module.exports,
|
||||
process: {
|
||||
...process,
|
||||
platform,
|
||||
env: {},
|
||||
versions: {...process.versions},
|
||||
getSystemVersion: () => '10.0.26100',
|
||||
},
|
||||
console,
|
||||
});
|
||||
vm.runInContext(transformedSource, context, {filename: sourcePath});
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
describe('PlatformInfo Chromium runtime diagnostics', () => {
|
||||
test('never reports Media Foundation H.264 switches that Chromium no longer has', async () => {
|
||||
const module = loadPlatformInfo('win32');
|
||||
|
||||
const info = await module.getDesktopInfo({nativeProbes: false});
|
||||
|
||||
const switches = [...info.chromiumRuntime.switches];
|
||||
|
||||
assert.deepEqual(
|
||||
switches.filter((name) => name.startsWith('enable-h264-mf')),
|
||||
[],
|
||||
);
|
||||
assert.equal(switches.includes('enable-libopenh264'), true);
|
||||
});
|
||||
});
|
||||
@@ -53,8 +53,6 @@ const CHROMIUM_RUNTIME_SWITCHES = [
|
||||
'disable-renderer-backgrounding',
|
||||
'disable_nv12_dxgi_video',
|
||||
'enable-libopenh264',
|
||||
'enable-h264-mf',
|
||||
'enable-h264-mf-zero-copy',
|
||||
'force_high_performance_gpu',
|
||||
'force_low_power_gpu',
|
||||
'openh264-library-path',
|
||||
|
||||
@@ -201,7 +201,7 @@ describe('StreamingPriority GPU scheduling priority', () => {
|
||||
status: 'succeeded',
|
||||
priorityClass: 'high',
|
||||
targets: [
|
||||
{processId: 1000, reasons: ['native-main-encoder-capture']},
|
||||
{processId: 1000, reasons: ['browser-main']},
|
||||
{processId: 2001, reasons: ['renderer']},
|
||||
{processId: 3001, reasons: ['chromium-gpu']},
|
||||
{processId: 3002, reasons: ['chromium-video-encode']},
|
||||
@@ -352,7 +352,7 @@ describe('StreamingPriority GPU scheduling priority', () => {
|
||||
status: 'native-module-unavailable',
|
||||
priorityClass: 'high',
|
||||
targets: [
|
||||
{processId: 1000, reasons: ['native-main-encoder-capture']},
|
||||
{processId: 1000, reasons: ['browser-main']},
|
||||
{processId: 2001, reasons: ['renderer']},
|
||||
{processId: 3001, reasons: ['chromium-gpu']},
|
||||
],
|
||||
|
||||
@@ -16,7 +16,7 @@ const requireModule = createRequire(import.meta.url);
|
||||
|
||||
type WindowsGpuSchedulingPriority = 'high' | 'realtime';
|
||||
type WindowsGpuPriorityTargetReason =
|
||||
| 'native-main-encoder-capture'
|
||||
| 'browser-main'
|
||||
| 'renderer'
|
||||
| 'tracked-renderer'
|
||||
| 'chromium-gpu'
|
||||
@@ -308,7 +308,7 @@ function getChromiumProcessTargetReasons(metric: Electron.ProcessMetric): Array<
|
||||
|
||||
function collectGpuSchedulingPriorityTargets(webContents?: Electron.WebContents): Array<GpuPriorityTarget> {
|
||||
const targets = new Map<number, Set<WindowsGpuPriorityTargetReason>>();
|
||||
addGpuPriorityTarget(targets, process.pid, 'native-main-encoder-capture');
|
||||
addGpuPriorityTarget(targets, process.pid, 'browser-main');
|
||||
const rendererProcessId = getRendererProcessId(webContents);
|
||||
addGpuPriorityTarget(targets, rendererProcessId, 'renderer');
|
||||
for (const trackedWebContents of streamingPriorityWebContents) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
addLinuxScreenCapturePipeWireFeature,
|
||||
addMacosPreSequoiaScreenCaptureDisabledFeatures,
|
||||
addWindowsHardwareVideoEncodeFeatures,
|
||||
addWindowsWebRtcWgcDisabledFeatures,
|
||||
appendConfiguredChromiumSwitches,
|
||||
appendDisabledChromiumFeatures,
|
||||
appendEnabledBlinkFeature,
|
||||
@@ -268,7 +267,6 @@ if (launchConfigurationError) {
|
||||
if (process.platform === 'darwin') {
|
||||
addMacosPreSequoiaScreenCaptureDisabledFeatures(disabledChromiumFeatures);
|
||||
}
|
||||
addWindowsWebRtcWgcDisabledFeatures(disabledChromiumFeatures);
|
||||
appendDisabledChromiumFeatures(disabledChromiumFeatures);
|
||||
if (enabledChromiumFeatures.size > 0) {
|
||||
appendEnabledChromiumFeatures(enabledChromiumFeatures);
|
||||
@@ -279,8 +277,6 @@ if (launchConfigurationError) {
|
||||
}
|
||||
appendLinuxOzonePlatformHint();
|
||||
if (process.platform === 'win32') {
|
||||
app.commandLine.appendSwitch('enable-h264-mf');
|
||||
app.commandLine.appendSwitch('enable-h264-mf-zero-copy');
|
||||
app.setToastActivatorCLSID(WINDOWS_TOAST_ACTIVATOR_CLSID);
|
||||
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
|
||||
}
|
||||
|
||||
@@ -1314,6 +1314,33 @@ console.log('self-hosting guide against deploy/self-hosting');
|
||||
}
|
||||
}
|
||||
|
||||
const COMPOSE_DEFAULT_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
|
||||
const shellComposeNames = shellRows('fluxer_compose_names', 'NAMES', 'the install.sh compose name list');
|
||||
const powershellComposeNames: Array<string> = [];
|
||||
for (const row of powershellRows('FluxerComposeNames', 'the install.ps1 compose name list')) {
|
||||
const parsed = row.match(/^\s*'([^']+)'\s*$/u);
|
||||
if (parsed == null) {
|
||||
problems.push(`the install.ps1 compose name list carries \`${row.trim()}\`, which is not a quoted file name`);
|
||||
continue;
|
||||
}
|
||||
powershellComposeNames.push(parsed[1]);
|
||||
}
|
||||
for (const [script, list] of [
|
||||
['install.sh', shellComposeNames],
|
||||
['install.ps1', powershellComposeNames],
|
||||
] as const) {
|
||||
if (list.join(', ') !== COMPOSE_DEFAULT_NAMES.join(', ')) {
|
||||
problems.push(
|
||||
`${script} resolves compose files as [${list.join(', ')}] and Compose resolves them as [${COMPOSE_DEFAULT_NAMES.join(', ')}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!shellStackFiles.includes('docker-compose.yml') || !COMPOSE_DEFAULT_NAMES.includes('docker-compose.yml')) {
|
||||
problems.push(
|
||||
'the installers no longer download docker-compose.yml, so the name they map onto an instance is unclear',
|
||||
);
|
||||
}
|
||||
|
||||
const PIPE_TO_SHELL =
|
||||
/(?:curl|wget|iwr|Invoke-WebRequest)[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|iex|Invoke-Expression)\b/iu;
|
||||
const docsPages = await walk(DOCS_ROOT);
|
||||
@@ -1419,10 +1446,15 @@ console.log('self-hosting guide against deploy/self-hosting');
|
||||
await writeFile(path.join(instance, '.env'), 'FLUXER_DOMAIN=x.example\nFLUXER_IMAGE_TAG=2026.813.205040\n');
|
||||
await writeFile(path.join(instance, 'docker-compose.yml'), 'name: fluxer\nservices:\n api:\n image: stub\n');
|
||||
|
||||
const plannedRef = (label: string, args: ReadonlyArray<string>): string | null => {
|
||||
const planned = (label: string, args: ReadonlyArray<string>, cwd?: string): string | null => {
|
||||
const run = spawnSync('sh', [path.join(INSTALLER_ROOT, 'install.sh'), ...args], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: {...process.env, PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ''}`},
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
...(cwd == null ? {} : {PWD: cwd}),
|
||||
},
|
||||
});
|
||||
if (run.error != null) {
|
||||
problems.push(`install.sh ${label} could not run: ${run.error.message}`);
|
||||
@@ -1432,7 +1464,15 @@ console.log('self-hosting guide against deploy/self-hosting');
|
||||
problems.push(`install.sh ${label} exited ${String(run.status)}: ${run.stderr.trim()}`);
|
||||
return null;
|
||||
}
|
||||
const line = run.stdout.match(/^ {2}ref\s+(\S+)$/mu);
|
||||
return run.stdout;
|
||||
};
|
||||
|
||||
const plannedRef = (label: string, args: ReadonlyArray<string>): string | null => {
|
||||
const stdout = planned(label, args);
|
||||
if (stdout == null) {
|
||||
return null;
|
||||
}
|
||||
const line = stdout.match(/^ {2}ref\s+(\S+)$/mu);
|
||||
if (line == null) {
|
||||
problems.push(`install.sh ${label} printed no ref line`);
|
||||
return null;
|
||||
@@ -1472,6 +1512,38 @@ console.log('self-hosting guide against deploy/self-hosting');
|
||||
problems.push(`install.sh ${label} plans ref ${resolved}, and the image tag it pairs with wants ${expected}`);
|
||||
}
|
||||
}
|
||||
|
||||
const composeYmlInstance = path.join(sandbox, 'compose-yml-instance');
|
||||
await mkdir(composeYmlInstance, {recursive: true});
|
||||
await writeFile(
|
||||
path.join(composeYmlInstance, '.env'),
|
||||
'FLUXER_DOMAIN=x.example\nFLUXER_IMAGE_TAG=2026.813.205040\n',
|
||||
);
|
||||
await writeFile(path.join(composeYmlInstance, 'compose.yml'), 'name: fluxer\nservices:\n api:\n image: stub\n');
|
||||
const COMPOSE_NAME_CASES: ReadonlyArray<readonly [string, ReadonlyArray<string>, string | undefined]> = [
|
||||
[
|
||||
'under --update against a compose.yml instance',
|
||||
['--update', '--dry-run', '--allow-root', '--dir', composeYmlInstance],
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'under --update standing in a compose.yml instance',
|
||||
['--update', '--dry-run', '--allow-root'],
|
||||
composeYmlInstance,
|
||||
],
|
||||
];
|
||||
for (const [label, args, cwd] of COMPOSE_NAME_CASES) {
|
||||
const stdout = planned(label, args, cwd);
|
||||
if (stdout == null) {
|
||||
continue;
|
||||
}
|
||||
if (!/^ {4}compose\.yml is unchanged$/mu.test(stdout)) {
|
||||
problems.push(`install.sh ${label} does not plan the refreshed stack file onto compose.yml`);
|
||||
}
|
||||
if (/^ {4}docker-compose\.yml is new$/mu.test(stdout)) {
|
||||
problems.push(`install.sh ${label} plans a docker-compose.yml that Compose would never load there`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, {recursive: true, force: true});
|
||||
}
|
||||
|
||||
@@ -108,6 +108,32 @@ $FluxerStackFiles = @(
|
||||
'.env.example'
|
||||
)
|
||||
|
||||
$FluxerComposeNames = @(
|
||||
'compose.yaml'
|
||||
'compose.yml'
|
||||
'docker-compose.yml'
|
||||
'docker-compose.yaml'
|
||||
)
|
||||
|
||||
$script:FluxerComposeBase = 'docker-compose.yml'
|
||||
$script:FluxerComposeBaseFrom = ''
|
||||
|
||||
function Get-FluxerComposeNameIn([string]$Directory) {
|
||||
foreach ($name in $FluxerComposeNames) {
|
||||
if (Test-Path -LiteralPath (Join-Path $Directory $name)) {
|
||||
return $name
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function Get-FluxerPlacedName([string]$Name) {
|
||||
if ($Name -eq 'docker-compose.yml') {
|
||||
return $script:FluxerComposeBase
|
||||
}
|
||||
return $Name
|
||||
}
|
||||
|
||||
# The file Compose bind-mounts from the working directory, with the service that mounts it.
|
||||
# Compose decides whether to recreate a container by comparing its configuration, and the
|
||||
# contents of a bind-mounted file are not part of that comparison, so a changed Caddyfile survives
|
||||
@@ -641,7 +667,7 @@ function Get-FluxerStackFiles([string]$StagingDir, [string]$RefValue) {
|
||||
# for an edit.
|
||||
function Move-FluxerStackFiles([string]$StagingDir, [string]$TargetDir) {
|
||||
foreach ($name in $FluxerStackFiles) {
|
||||
Move-Item -LiteralPath (Join-Path $StagingDir $name) -Destination (Join-Path $TargetDir $name) -Force
|
||||
Move-Item -LiteralPath (Join-Path $StagingDir $name) -Destination (Join-Path $TargetDir (Get-FluxerPlacedName $name)) -Force
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,7 +866,7 @@ function Get-FluxerComposeProject([string]$TargetDir) {
|
||||
if ($null -ne $env:COMPOSE_PROJECT_NAME -and $env:COMPOSE_PROJECT_NAME.Length -gt 0) {
|
||||
return $env:COMPOSE_PROJECT_NAME
|
||||
}
|
||||
foreach ($line in [System.IO.File]::ReadAllText((Join-Path $TargetDir 'docker-compose.yml')).Split("`n")) {
|
||||
foreach ($line in [System.IO.File]::ReadAllText((Join-Path $TargetDir $script:FluxerComposeBase)).Split("`n")) {
|
||||
if ($line -match '^name:\s*(\S+)') {
|
||||
return $Matches[1]
|
||||
}
|
||||
@@ -1046,7 +1072,7 @@ function Restart-FluxerMounts($Entries, [string]$TargetDir) {
|
||||
$services = @(Get-FluxerComposeServices)
|
||||
foreach ($entry in $Entries) {
|
||||
if ($services -notcontains $entry.Service) {
|
||||
Write-FluxerLine "Skipping the restart of $($entry.Service), because the docker-compose.yml in $TargetDir defines no service by that name."
|
||||
Write-FluxerLine "Skipping the restart of $($entry.Service), because the $($script:FluxerComposeBase) in $TargetDir defines no service by that name."
|
||||
continue
|
||||
}
|
||||
Write-FluxerLine "Restarting $($entry.Service), because $($entry.Name) is mounted into it and up -d does not reload a mounted file."
|
||||
@@ -1225,7 +1251,7 @@ function Save-FluxerCurrentFiles([string]$Record, [string]$TargetDir, [string]$E
|
||||
Copy-Item -LiteralPath $EnvPath -Destination $envCopy -Force
|
||||
Set-FluxerPrivateFile $envCopy
|
||||
foreach ($name in $FluxerStackFiles) {
|
||||
$source = Join-Path $TargetDir $name
|
||||
$source = Join-Path $TargetDir (Get-FluxerPlacedName $name)
|
||||
$length = Get-FluxerFileLength $source
|
||||
if ($length -gt 0) {
|
||||
Copy-Item -LiteralPath $source -Destination (Join-Path $Record $name) -Force
|
||||
@@ -1427,7 +1453,7 @@ function Backup-FluxerInstance([string]$Record, [string]$TargetDir, [string]$Pro
|
||||
# The refreshed file is still staged when this runs, so a refusal here leaves the instance exactly
|
||||
# as it was.
|
||||
function Assert-FluxerPostgresMajor([string]$TargetDir, [string]$StagingDir) {
|
||||
$old = Get-FluxerPostgresMajor (Join-Path $TargetDir 'docker-compose.yml')
|
||||
$old = Get-FluxerPostgresMajor (Join-Path $TargetDir $script:FluxerComposeBase)
|
||||
$new = Get-FluxerPostgresMajor (Join-Path $StagingDir 'docker-compose.yml')
|
||||
if ($old.Length -eq 0 -or $new.Length -eq 0 -or $old -eq $new) {
|
||||
return
|
||||
@@ -1515,21 +1541,22 @@ function Show-FluxerUpdatePlan([string]$TargetDir, [string]$EnvPath, [string]$Ba
|
||||
Write-FluxerLine ' File changes:'
|
||||
$changed = 0
|
||||
foreach ($name in $FluxerStackFiles) {
|
||||
$current = Join-Path $TargetDir $name
|
||||
$placed = Get-FluxerPlacedName $name
|
||||
$current = Join-Path $TargetDir $placed
|
||||
if (-not (Test-Path -LiteralPath $current)) {
|
||||
Write-FluxerLine " $name is new"
|
||||
Write-FluxerLine " $placed is new"
|
||||
$changed++
|
||||
} elseif (Test-FluxerSameFile $current (Join-Path $staging $name)) {
|
||||
Write-FluxerLine " $name is unchanged"
|
||||
Write-FluxerLine " $placed is unchanged"
|
||||
} else {
|
||||
Write-FluxerLine " $name changes"
|
||||
Write-FluxerLine " $placed changes"
|
||||
$changed++
|
||||
}
|
||||
}
|
||||
if ($changed -eq 0) {
|
||||
Write-FluxerLine " Note: ref $Ref moves no stack file"
|
||||
}
|
||||
$old = Get-FluxerPostgresMajor (Join-Path $TargetDir 'docker-compose.yml')
|
||||
$old = Get-FluxerPostgresMajor (Join-Path $TargetDir $script:FluxerComposeBase)
|
||||
$new = Get-FluxerPostgresMajor (Join-Path $staging 'docker-compose.yml')
|
||||
if ($old.Length -gt 0 -and $new.Length -gt 0 -and $old -ne $new) {
|
||||
Write-FluxerLine " Refusal: postgres moves from $old to $new, which this script does not do"
|
||||
@@ -1706,7 +1733,7 @@ function Invoke-FluxerRollback([string]$TargetDir, [string]$EnvPath, [string]$Ba
|
||||
$source = Join-Path $record $name
|
||||
$length = Get-FluxerFileLength $source
|
||||
if ($length -gt 0) {
|
||||
Copy-Item -LiteralPath $source -Destination (Join-Path $TargetDir $name) -Force
|
||||
Copy-Item -LiteralPath $source -Destination (Join-Path $TargetDir (Get-FluxerPlacedName $name)) -Force
|
||||
} elseif (Test-Path -LiteralPath $source) {
|
||||
Stop-Fluxer "$source is empty, so restoring it would replace a working file with nothing. Nothing was restored. Take the file from another record or from the ref the record names." $FluxerExitRefused
|
||||
}
|
||||
@@ -1739,13 +1766,20 @@ function Assert-FluxerInstance([string]$TargetDir, [string]$EnvPath) {
|
||||
if (-not (Test-Path -LiteralPath $EnvPath)) {
|
||||
Stop-Fluxer "No .env in $TargetDir. That directory holds no instance. Run install.ps1 with neither -Update nor -Rollback to set one up." $FluxerExitPrerequisite
|
||||
}
|
||||
$compose = Join-Path $TargetDir 'docker-compose.yml'
|
||||
Resolve-FluxerComposeBase $TargetDir $EnvPath
|
||||
$compose = Join-Path $TargetDir $script:FluxerComposeBase
|
||||
if (-not (Test-Path -LiteralPath $compose)) {
|
||||
Stop-Fluxer "No docker-compose.yml in $TargetDir. That directory does not hold an instance." $FluxerExitPrerequisite
|
||||
if ($script:FluxerComposeBaseFrom.Length -gt 0) {
|
||||
Stop-Fluxer "$($script:FluxerComposeBaseFrom) names $($script:FluxerComposeBase) first, and $compose is not there. Put that file back, or name the file the instance runs on first in COMPOSE_FILE." $FluxerExitPrerequisite
|
||||
}
|
||||
Stop-Fluxer "No compose file in $TargetDir. Compose looks for compose.yaml, compose.yml, docker-compose.yml and docker-compose.yaml there, and that directory holds none of them, so it does not hold an instance." $FluxerExitPrerequisite
|
||||
}
|
||||
if ((Get-FluxerFileLength $compose) -eq 0) {
|
||||
Stop-Fluxer "$compose is empty. A redirect that captured a failed download leaves that, and Compose refuses an empty compose file. Put the file back from a backup or from the record of the last upgrade, then run this again." $FluxerExitPrerequisite
|
||||
}
|
||||
if ($script:FluxerComposeBase -ne 'docker-compose.yml') {
|
||||
Write-FluxerLine "Compose loads $($script:FluxerComposeBase) in $TargetDir, so the stack's docker-compose.yml is written to that name."
|
||||
}
|
||||
}
|
||||
|
||||
# Compose reads COMPOSE_FILE from .env and loads every file it names before it answers anything, so
|
||||
@@ -1776,7 +1810,7 @@ function Get-FluxerEnvScalar([string]$EnvPath, [string]$Name) {
|
||||
return $raw
|
||||
}
|
||||
|
||||
function Assert-FluxerComposeFiles([string]$TargetDir, [string]$EnvPath) {
|
||||
function Get-FluxerComposeSetting([string]$EnvPath) {
|
||||
$value = ''
|
||||
$source = 'the environment'
|
||||
if ($null -ne $env:COMPOSE_FILE) {
|
||||
@@ -1786,9 +1820,6 @@ function Assert-FluxerComposeFiles([string]$TargetDir, [string]$EnvPath) {
|
||||
$value = Get-FluxerEnvScalar $EnvPath 'COMPOSE_FILE'
|
||||
$source = $EnvPath
|
||||
}
|
||||
if ($value.Length -eq 0) {
|
||||
return
|
||||
}
|
||||
$separator = ''
|
||||
if ($null -ne $env:COMPOSE_PATH_SEPARATOR) {
|
||||
$separator = [string]$env:COMPOSE_PATH_SEPARATOR
|
||||
@@ -1799,6 +1830,50 @@ function Assert-FluxerComposeFiles([string]$TargetDir, [string]$EnvPath) {
|
||||
if ($separator.Length -eq 0) {
|
||||
$separator = [System.IO.Path]::PathSeparator
|
||||
}
|
||||
return [pscustomobject]@{Value = $value; Source = $source; Separator = $separator}
|
||||
}
|
||||
|
||||
function Resolve-FluxerComposeBase([string]$TargetDir, [string]$EnvPath) {
|
||||
$setting = Get-FluxerComposeSetting $EnvPath
|
||||
$script:FluxerComposeBaseFrom = ''
|
||||
foreach ($name in $setting.Value.Split([string[]]$setting.Separator, [System.StringSplitOptions]::None)) {
|
||||
if ($name.Length -eq 0) {
|
||||
continue
|
||||
}
|
||||
$base = $name
|
||||
foreach ($prefix in @('./', '.\')) {
|
||||
if ($base.StartsWith($prefix, [System.StringComparison]::Ordinal)) {
|
||||
$base = $base.Substring($prefix.Length)
|
||||
}
|
||||
}
|
||||
foreach ($root in @("$TargetDir/", "$TargetDir\")) {
|
||||
if ($base.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$base = $base.Substring($root.Length)
|
||||
}
|
||||
}
|
||||
$script:FluxerComposeBaseFrom = "COMPOSE_FILE from $($setting.Source)"
|
||||
if ($base.Contains('/') -or $base.Contains('\')) {
|
||||
Stop-Fluxer "$($script:FluxerComposeBaseFrom) names $name first, and that file is not directly in $TargetDir. This script refreshes only the files in the directory it acts on, so the upgrade would leave the file Compose loads on the old stack. Move it into $TargetDir and name it there, or upgrade by hand." $FluxerExitPrerequisite
|
||||
}
|
||||
$script:FluxerComposeBase = $base
|
||||
return
|
||||
}
|
||||
$found = Get-FluxerComposeNameIn $TargetDir
|
||||
if ($found.Length -gt 0) {
|
||||
$script:FluxerComposeBase = $found
|
||||
} else {
|
||||
$script:FluxerComposeBase = 'docker-compose.yml'
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FluxerComposeFiles([string]$TargetDir, [string]$EnvPath) {
|
||||
$setting = Get-FluxerComposeSetting $EnvPath
|
||||
$value = $setting.Value
|
||||
$source = $setting.Source
|
||||
$separator = $setting.Separator
|
||||
if ($value.Length -eq 0) {
|
||||
return
|
||||
}
|
||||
foreach ($name in $value.Split([string[]]$separator, [System.StringSplitOptions]::None)) {
|
||||
if ($name.Length -eq 0) {
|
||||
continue
|
||||
@@ -1855,15 +1930,18 @@ function Invoke-FluxerInstall {
|
||||
|
||||
$targetPath = $Dir
|
||||
$adoptedCwd = $false
|
||||
$fellBack = $false
|
||||
if ($targetPath.Length -eq 0) {
|
||||
$here = (Get-Location).Path
|
||||
$hereEnv = Join-Path $here '.env'
|
||||
$hereIsFluxer = (Test-Path -LiteralPath $hereEnv) -and (@(Get-FluxerEnvLines $hereEnv | Where-Object {$_.StartsWith('FLUXER_')}).Count -gt 0)
|
||||
if (($Update -or $Rollback) -and (Get-FluxerFileLength (Join-Path $here 'docker-compose.yml')) -gt 0 -and $hereIsFluxer) {
|
||||
$hereCompose = Get-FluxerComposeNameIn $here
|
||||
if (($Update -or $Rollback) -and $hereCompose.Length -gt 0 -and (Get-FluxerFileLength (Join-Path $here $hereCompose)) -gt 0 -and $hereIsFluxer) {
|
||||
$targetPath = $here
|
||||
$adoptedCwd = $true
|
||||
} else {
|
||||
$targetPath = Join-Path $HOME 'fluxer'
|
||||
$fellBack = $Update -or $Rollback
|
||||
}
|
||||
}
|
||||
$targetDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($targetPath)
|
||||
@@ -1876,6 +1954,9 @@ function Invoke-FluxerInstall {
|
||||
if ($adoptedCwd) {
|
||||
Write-FluxerLine "Acting on the instance in $targetDir, the working directory. Pass -Dir to name another."
|
||||
}
|
||||
if ($fellBack) {
|
||||
Write-FluxerLine "The working directory holds no instance, so this acts on $targetDir. Pass -Dir to name another."
|
||||
}
|
||||
|
||||
if ($Update -or $Rollback) {
|
||||
Assert-FluxerInstance $targetDir $envPath
|
||||
@@ -1886,7 +1967,7 @@ function Invoke-FluxerInstall {
|
||||
Assert-FluxerComposeFiles $targetDir $envPath
|
||||
$project = Get-FluxerComposeProject $targetDir
|
||||
if ($project.Length -eq 0) {
|
||||
Stop-Fluxer "docker-compose.yml in $targetDir declares no project name, so the volume names cannot be derived." $FluxerExitPrerequisite
|
||||
Stop-Fluxer "$($script:FluxerComposeBase) in $targetDir declares no project name, so the volume names cannot be derived." $FluxerExitPrerequisite
|
||||
}
|
||||
Push-Location -LiteralPath $targetDir
|
||||
try {
|
||||
|
||||
@@ -131,6 +131,36 @@ Caddyfile
|
||||
FILES
|
||||
}
|
||||
|
||||
fluxer_compose_names() {
|
||||
cat <<'NAMES'
|
||||
compose.yaml
|
||||
compose.yml
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
NAMES
|
||||
}
|
||||
|
||||
fluxer_compose_name_in() {
|
||||
for fluxer_candidate in $(fluxer_compose_names); do
|
||||
if [ -e "$1/$fluxer_candidate" ]; then
|
||||
printf '%s' "$fluxer_candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
fluxer_compose_base='docker-compose.yml'
|
||||
fluxer_compose_base_from=''
|
||||
|
||||
fluxer_placed_name() {
|
||||
if [ "$1" = 'docker-compose.yml' ]; then
|
||||
printf '%s' "$fluxer_compose_base"
|
||||
else
|
||||
printf '%s' "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
# The file Compose bind-mounts from the working directory, with the service that
|
||||
# mounts it.
|
||||
#
|
||||
@@ -372,15 +402,21 @@ done
|
||||
#
|
||||
# An install writes a new instance, so it never adopts the working directory.
|
||||
if [ -z "$opt_dir" ]; then
|
||||
fluxer_here_compose=$(fluxer_compose_name_in "$(pwd)" || true)
|
||||
if { [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; } &&
|
||||
[ -s "$(pwd)/docker-compose.yml" ] && [ -e "$(pwd)/.env" ] &&
|
||||
grep -q '^FLUXER_' "$(pwd)/.env" 2>/dev/null; then
|
||||
[ -n "$fluxer_here_compose" ] && [ -s "$(pwd)/$fluxer_here_compose" ] &&
|
||||
[ -e "$(pwd)/.env" ] && grep -q '^FLUXER_' "$(pwd)/.env" 2>/dev/null; then
|
||||
opt_dir="$(pwd)"
|
||||
fluxer_say "Acting on the instance in $opt_dir, the working directory. Pass --dir to name another."
|
||||
elif [ -n "${HOME:-}" ]; then
|
||||
opt_dir="$HOME/fluxer"
|
||||
else
|
||||
opt_dir="$(pwd)/fluxer"
|
||||
if [ -n "${HOME:-}" ]; then
|
||||
opt_dir="$HOME/fluxer"
|
||||
else
|
||||
opt_dir="$(pwd)/fluxer"
|
||||
fi
|
||||
if [ "$opt_update" -eq 1 ] || [ "$opt_rollback" -eq 1 ]; then
|
||||
fluxer_say "The working directory holds no instance, so this acts on $opt_dir. Pass --dir to name another."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
case $opt_dir in
|
||||
@@ -749,7 +785,7 @@ fluxer_fetch_stack() {
|
||||
fluxer_place_stack() {
|
||||
while read -r fluxer_file; do
|
||||
[ -n "$fluxer_file" ] || continue
|
||||
mv "$fluxer_scratch/$fluxer_file.part" "$opt_dir/$fluxer_file"
|
||||
mv "$fluxer_scratch/$fluxer_file.part" "$opt_dir/$(fluxer_placed_name "$fluxer_file")"
|
||||
done < "$fluxer_scratch/files"
|
||||
fluxer_say "Stack files in $opt_dir are at ref $opt_ref."
|
||||
}
|
||||
@@ -759,7 +795,7 @@ fluxer_compose_project() {
|
||||
printf '%s' "$COMPOSE_PROJECT_NAME"
|
||||
return 0
|
||||
fi
|
||||
sed -n 's/^name: *//p' "$opt_dir/docker-compose.yml" | head -n 1
|
||||
sed -n 's/^name: *//p' "$opt_dir/$fluxer_compose_base" | head -n 1
|
||||
}
|
||||
|
||||
fluxer_project=''
|
||||
@@ -767,7 +803,7 @@ fluxer_project=''
|
||||
fluxer_set_project() {
|
||||
fluxer_project=$(fluxer_compose_project)
|
||||
if [ -z "$fluxer_project" ]; then
|
||||
fluxer_fail 2 "docker-compose.yml in $opt_dir declares no project name, so the volume names cannot be derived."
|
||||
fluxer_fail 2 "$fluxer_compose_base in $opt_dir declares no project name, so the volume names cannot be derived."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1104,11 +1140,18 @@ fluxer_require_instance() {
|
||||
if [ ! -e "$opt_dir/.env" ]; then
|
||||
fluxer_fail 2 "No .env in $opt_dir. That directory holds no instance. Run install.sh with neither --update nor --rollback to set one up."
|
||||
fi
|
||||
if [ ! -e "$opt_dir/docker-compose.yml" ]; then
|
||||
fluxer_fail 2 "No docker-compose.yml in $opt_dir. That directory does not hold an instance."
|
||||
fluxer_resolve_compose_base
|
||||
if [ ! -e "$opt_dir/$fluxer_compose_base" ]; then
|
||||
if [ -n "$fluxer_compose_base_from" ]; then
|
||||
fluxer_fail 2 "$fluxer_compose_base_from names $fluxer_compose_base first, and $opt_dir/$fluxer_compose_base is not there. Put that file back, or name the file the instance runs on first in COMPOSE_FILE."
|
||||
fi
|
||||
fluxer_fail 2 "No compose file in $opt_dir. Compose looks for compose.yaml, compose.yml, docker-compose.yml and docker-compose.yaml there, and that directory holds none of them, so it does not hold an instance."
|
||||
fi
|
||||
if [ ! -s "$opt_dir/docker-compose.yml" ]; then
|
||||
fluxer_fail 2 "$opt_dir/docker-compose.yml is empty. A redirect that captured a failed download leaves that, and Compose refuses an empty compose file. Put the file back from a backup or from the record of the last upgrade, then run this again."
|
||||
if [ ! -s "$opt_dir/$fluxer_compose_base" ]; then
|
||||
fluxer_fail 2 "$opt_dir/$fluxer_compose_base is empty. A redirect that captured a failed download leaves that, and Compose refuses an empty compose file. Put the file back from a backup or from the record of the last upgrade, then run this again."
|
||||
fi
|
||||
if [ "$fluxer_compose_base" != 'docker-compose.yml' ]; then
|
||||
fluxer_say "Compose loads $fluxer_compose_base in $opt_dir, so the stack's docker-compose.yml is written to that name."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1141,14 +1184,13 @@ fluxer_env_scalar() {
|
||||
printf '%s' "$fluxer_scalar"
|
||||
}
|
||||
|
||||
fluxer_require_compose_files() {
|
||||
fluxer_read_compose_setting() {
|
||||
fluxer_compose_file=${COMPOSE_FILE:-}
|
||||
fluxer_compose_from="the environment"
|
||||
if [ -z "$fluxer_compose_file" ]; then
|
||||
fluxer_compose_file=$(fluxer_env_scalar COMPOSE_FILE)
|
||||
fluxer_compose_from="$opt_dir/.env"
|
||||
fi
|
||||
[ -n "$fluxer_compose_file" ] || return 0
|
||||
fluxer_path_sep=${COMPOSE_PATH_SEPARATOR:-}
|
||||
if [ -z "$fluxer_path_sep" ]; then
|
||||
fluxer_path_sep=$(fluxer_env_scalar COMPOSE_PATH_SEPARATOR)
|
||||
@@ -1156,6 +1198,36 @@ fluxer_require_compose_files() {
|
||||
if [ -z "$fluxer_path_sep" ]; then
|
||||
fluxer_path_sep=':'
|
||||
fi
|
||||
}
|
||||
|
||||
fluxer_resolve_compose_base() {
|
||||
fluxer_read_compose_setting
|
||||
fluxer_compose_base_from=''
|
||||
fluxer_rest=$fluxer_compose_file
|
||||
while [ -n "$fluxer_rest" ]; do
|
||||
fluxer_name=${fluxer_rest%%"$fluxer_path_sep"*}
|
||||
case $fluxer_rest in
|
||||
*"$fluxer_path_sep"*) fluxer_rest=${fluxer_rest#*"$fluxer_path_sep"} ;;
|
||||
*) fluxer_rest='' ;;
|
||||
esac
|
||||
[ -n "$fluxer_name" ] || continue
|
||||
fluxer_compose_base=${fluxer_name#./}
|
||||
fluxer_compose_base=${fluxer_compose_base#"$opt_dir"/}
|
||||
fluxer_compose_base_from="COMPOSE_FILE from $fluxer_compose_from"
|
||||
break
|
||||
done
|
||||
if [ -z "$fluxer_compose_base_from" ]; then
|
||||
fluxer_compose_base=$(fluxer_compose_name_in "$opt_dir" || printf '%s' 'docker-compose.yml')
|
||||
return 0
|
||||
fi
|
||||
case $fluxer_compose_base in
|
||||
*/*) fluxer_fail 2 "$fluxer_compose_base_from names $fluxer_name first, and that file is not directly in $opt_dir. This script refreshes only the files in the directory it acts on, so the upgrade would leave the file Compose loads on the old stack. Move it into $opt_dir and name it there, or upgrade by hand." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
fluxer_require_compose_files() {
|
||||
fluxer_read_compose_setting
|
||||
[ -n "$fluxer_compose_file" ] || return 0
|
||||
fluxer_compose_count=0
|
||||
fluxer_rest=$fluxer_compose_file
|
||||
while [ -n "$fluxer_rest" ]; do
|
||||
@@ -1185,7 +1257,7 @@ Leave the COMPOSE_FILE line as it is. Without $fluxer_name the edge container bi
|
||||
done
|
||||
if [ "$fluxer_compose_count" -gt 1 ] &&
|
||||
! fluxer_version_ge "$fluxer_compose_version" "$FLUXER_MIN_COMPOSE_OVERLAY"; then
|
||||
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from loads $fluxer_compose_count files and this host runs Compose $fluxer_compose_version. Every overlay this script downloads uses the !override tag, which needs Compose $FLUXER_MIN_COMPOSE_OVERLAY or newer. Upgrade Compose, or load only docker-compose.yml."
|
||||
fluxer_fail 2 "COMPOSE_FILE from $fluxer_compose_from loads $fluxer_compose_count files and this host runs Compose $fluxer_compose_version. Every overlay this script downloads uses the !override tag, which needs Compose $FLUXER_MIN_COMPOSE_OVERLAY or newer. Upgrade Compose, or load only $fluxer_compose_base."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1315,10 +1387,11 @@ fluxer_save_current_files() {
|
||||
chmod 600 "$fluxer_record/.env"
|
||||
while read -r fluxer_file; do
|
||||
[ -n "$fluxer_file" ] || continue
|
||||
if [ -s "$opt_dir/$fluxer_file" ]; then
|
||||
cp -p "$opt_dir/$fluxer_file" "$fluxer_record/$fluxer_file"
|
||||
elif [ -e "$opt_dir/$fluxer_file" ]; then
|
||||
fluxer_fail 7 "$opt_dir/$fluxer_file is empty, so the record would hold a file a rollback could not use. Put the file back before upgrading."
|
||||
fluxer_placed="$opt_dir/$(fluxer_placed_name "$fluxer_file")"
|
||||
if [ -s "$fluxer_placed" ]; then
|
||||
cp -p "$fluxer_placed" "$fluxer_record/$fluxer_file"
|
||||
elif [ -e "$fluxer_placed" ]; then
|
||||
fluxer_fail 7 "$fluxer_placed is empty, so the record would hold a file a rollback could not use. Put the file back before upgrading."
|
||||
fi
|
||||
done < "$fluxer_scratch/files"
|
||||
}
|
||||
@@ -1499,7 +1572,7 @@ fluxer_postgres_major() {
|
||||
# The refreshed file is still in the scratch directory when this runs, so a
|
||||
# refusal here leaves the instance exactly as it was.
|
||||
fluxer_guard_postgres_major() {
|
||||
fluxer_old_major=$(fluxer_postgres_major "$opt_dir/docker-compose.yml")
|
||||
fluxer_old_major=$(fluxer_postgres_major "$opt_dir/$fluxer_compose_base")
|
||||
fluxer_new_major=$(fluxer_postgres_major "$fluxer_scratch/docker-compose.yml.part")
|
||||
if [ -z "$fluxer_old_major" ] || [ -z "$fluxer_new_major" ]; then
|
||||
return 0
|
||||
@@ -1550,7 +1623,7 @@ $(fluxer_compose_error ' ')"
|
||||
while read -r fluxer_file fluxer_service; do
|
||||
[ -n "$fluxer_service" ] || continue
|
||||
if ! grep -qxF "$fluxer_service" "$fluxer_scratch/services"; then
|
||||
fluxer_say "Skipping the restart of $fluxer_service, because the docker-compose.yml in $opt_dir defines no service by that name."
|
||||
fluxer_say "Skipping the restart of $fluxer_service, because the $fluxer_compose_base in $opt_dir defines no service by that name."
|
||||
continue
|
||||
fi
|
||||
fluxer_say "Restarting $fluxer_service, because $fluxer_file is mounted into it and up -d does not reload a mounted file."
|
||||
@@ -1656,20 +1729,21 @@ fluxer_plan_update() {
|
||||
fluxer_changed=0
|
||||
while read -r fluxer_file; do
|
||||
[ -n "$fluxer_file" ] || continue
|
||||
if [ ! -e "$opt_dir/$fluxer_file" ]; then
|
||||
fluxer_say " $fluxer_file is new"
|
||||
fluxer_placed=$(fluxer_placed_name "$fluxer_file")
|
||||
if [ ! -e "$opt_dir/$fluxer_placed" ]; then
|
||||
fluxer_say " $fluxer_placed is new"
|
||||
fluxer_changed=1
|
||||
elif cmp -s "$opt_dir/$fluxer_file" "$fluxer_scratch/$fluxer_file.part"; then
|
||||
fluxer_say " $fluxer_file is unchanged"
|
||||
elif cmp -s "$opt_dir/$fluxer_placed" "$fluxer_scratch/$fluxer_file.part"; then
|
||||
fluxer_say " $fluxer_placed is unchanged"
|
||||
else
|
||||
fluxer_say " $fluxer_file changes"
|
||||
fluxer_say " $fluxer_placed changes"
|
||||
fluxer_changed=1
|
||||
fi
|
||||
done < "$fluxer_scratch/files"
|
||||
if [ "$fluxer_changed" -eq 0 ]; then
|
||||
fluxer_say " note ref $opt_ref moves no stack file"
|
||||
fi
|
||||
fluxer_old_major=$(fluxer_postgres_major "$opt_dir/docker-compose.yml")
|
||||
fluxer_old_major=$(fluxer_postgres_major "$opt_dir/$fluxer_compose_base")
|
||||
fluxer_new_major=$(fluxer_postgres_major "$fluxer_scratch/docker-compose.yml.part")
|
||||
if [ -n "$fluxer_old_major" ] && [ -n "$fluxer_new_major" ] && [ "$fluxer_old_major" != "$fluxer_new_major" ]; then
|
||||
fluxer_say " refusal postgres moves from $fluxer_old_major to $fluxer_new_major, which this script does not do"
|
||||
@@ -1862,7 +1936,7 @@ fluxer_run_rollback() {
|
||||
while read -r fluxer_file; do
|
||||
[ -n "$fluxer_file" ] || continue
|
||||
if [ -s "$fluxer_rollback_dir/$fluxer_file" ]; then
|
||||
cp -p "$fluxer_rollback_dir/$fluxer_file" "$opt_dir/$fluxer_file"
|
||||
cp -p "$fluxer_rollback_dir/$fluxer_file" "$opt_dir/$(fluxer_placed_name "$fluxer_file")"
|
||||
elif [ -e "$fluxer_rollback_dir/$fluxer_file" ]; then
|
||||
fluxer_fail 3 "$fluxer_rollback_dir/$fluxer_file is empty, so restoring it would replace a working file with nothing. Nothing was restored. Take the file from another record or from the ref the record names."
|
||||
fi
|
||||
|
||||
@@ -172,10 +172,11 @@ route_cast(Tag, Msg, State) ->
|
||||
case cast_handler(Tag) of
|
||||
voice -> guild_voice_handler:handle_cast(Msg, State);
|
||||
subscription -> guild_subscription_handler:handle_cast(Msg, State);
|
||||
dm_partners -> guild_dm_partners:handle_cast(Msg, State);
|
||||
undefined -> {noreply, State}
|
||||
end.
|
||||
|
||||
-spec cast_handler(atom()) -> voice | subscription | undefined.
|
||||
-spec cast_handler(atom()) -> voice | subscription | dm_partners | undefined.
|
||||
cast_handler(relay_voice_state_update) -> voice;
|
||||
cast_handler(relay_voice_server_update) -> voice;
|
||||
cast_handler(store_pending_connection) -> voice;
|
||||
@@ -183,6 +184,7 @@ cast_handler(add_virtual_channel_access) -> voice;
|
||||
cast_handler(remove_virtual_channel_access) -> voice;
|
||||
cast_handler(cleanup_virtual_access_for_user) -> voice;
|
||||
cast_handler(update_member_subscriptions) -> subscription;
|
||||
cast_handler(update_dm_partners) -> dm_partners;
|
||||
cast_handler(_) -> undefined.
|
||||
|
||||
-spec handle_info(term(), guild_state()) -> info_reply().
|
||||
@@ -545,7 +547,7 @@ dispatch_event(Event, EventData, State) ->
|
||||
Event, NewState
|
||||
),
|
||||
ok = maybe_refresh_permission_cache(Event, ParsedEventData, State, StateAfterPrune),
|
||||
StateAfterPrune.
|
||||
guild_dm_partners:maybe_reevaluate(Event, ParsedEventData, State, StateAfterPrune).
|
||||
|
||||
-spec parse_event_data(term()) -> map().
|
||||
parse_event_data(D) when is_binary(D) -> require_map(json:decode(D));
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<<"members">>, members_normalized, <<"member_role_index">>, members_sorted_ids
|
||||
]).
|
||||
-define(CONNECT_SNAPSHOT_HEAVY_SESSION_KEYS, [active_guilds, user_roles, viewable_channels]).
|
||||
-define(DEFAULT_CONNECT_SNAPSHOT_TRIM_MEMBERS, 5000).
|
||||
|
||||
-export_type([guild_state/0, guild_reply/1, user_id/0]).
|
||||
|
||||
@@ -212,7 +213,11 @@ member_count_at_least(Threshold, State) ->
|
||||
-spec connect_snapshot_trim_member_threshold() -> pos_integer() | undefined.
|
||||
connect_snapshot_trim_member_threshold() ->
|
||||
case
|
||||
application:get_env(fluxer_gateway, connect_snapshot_trim_member_threshold, undefined)
|
||||
application:get_env(
|
||||
fluxer_gateway,
|
||||
connect_snapshot_trim_member_threshold,
|
||||
?DEFAULT_CONNECT_SNAPSHOT_TRIM_MEMBERS
|
||||
)
|
||||
of
|
||||
N when is_integer(N), N > 0 -> N;
|
||||
_ -> undefined
|
||||
@@ -552,9 +557,12 @@ with_trim_threshold(Threshold, Fun) ->
|
||||
application:unset_env(fluxer_gateway, connect_snapshot_trim_member_threshold)
|
||||
end.
|
||||
|
||||
trim_is_off_without_threshold_test() ->
|
||||
trim_defaults_to_large_guilds_test() ->
|
||||
application:unset_env(fluxer_gateway, connect_snapshot_trim_member_threshold),
|
||||
?assertEqual(false, should_trim_connect_snapshot(trim_state(49435))).
|
||||
Default = ?DEFAULT_CONNECT_SNAPSHOT_TRIM_MEMBERS,
|
||||
?assertEqual(true, should_trim_connect_snapshot(trim_state(49435))),
|
||||
?assertEqual(true, should_trim_connect_snapshot(trim_state(Default))),
|
||||
?assertEqual(false, should_trim_connect_snapshot(trim_state(Default - 1))).
|
||||
|
||||
trim_needs_member_count_at_threshold_test() ->
|
||||
with_trim_threshold(20000, fun() ->
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_dm_partners).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([handle_cast/2, maybe_reevaluate/4]).
|
||||
|
||||
-type guild_state() :: map().
|
||||
-type session_id() :: binary().
|
||||
-type user_id() :: integer().
|
||||
-type registration() :: #{
|
||||
user_id := user_id(),
|
||||
pid := pid(),
|
||||
partners := #{user_id() => true},
|
||||
eligible := #{user_id() => true}
|
||||
}.
|
||||
-type registrations() :: #{session_id() => registration()}.
|
||||
-type scope() :: none | all | {user, user_id()}.
|
||||
|
||||
-export_type([guild_state/0]).
|
||||
|
||||
-define(MAX_PARTNERS, 1000).
|
||||
-define(PRUNE_SLACK, 64).
|
||||
|
||||
-spec handle_cast(term(), guild_state()) -> {noreply, guild_state()}.
|
||||
handle_cast({update_dm_partners, SessionId, PartnerIds}, State) when
|
||||
is_binary(SessionId), is_list(PartnerIds)
|
||||
->
|
||||
{noreply, update(SessionId, PartnerIds, State)};
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec maybe_reevaluate(term(), term(), guild_state(), guild_state()) -> guild_state().
|
||||
maybe_reevaluate(Event, Data, PreviousState, State) ->
|
||||
Registrations = registrations(State),
|
||||
case map_size(Registrations) of
|
||||
0 ->
|
||||
State;
|
||||
_ ->
|
||||
reevaluate(
|
||||
effective_scope(scope(Event, Data), PreviousState, State), Registrations, State
|
||||
)
|
||||
end.
|
||||
|
||||
-spec effective_scope(scope(), guild_state(), guild_state()) -> scope().
|
||||
effective_scope(all, PreviousState, State) ->
|
||||
case visibility_inputs(PreviousState) =:= visibility_inputs(State) of
|
||||
true -> none;
|
||||
false -> all
|
||||
end;
|
||||
effective_scope(Scope, _PreviousState, _State) ->
|
||||
Scope.
|
||||
|
||||
-spec visibility_inputs(guild_state()) -> term().
|
||||
visibility_inputs(State) ->
|
||||
Data = map_utils:ensure_map(maps:get(data, State, #{})),
|
||||
Guild = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})),
|
||||
{
|
||||
maps:get(<<"owner_id">>, Guild, undefined),
|
||||
role_inputs(guild_data_index:role_index(Data)),
|
||||
channel_inputs(guild_data_index:channel_index(Data)),
|
||||
maps:get(virtual_channel_access, State, #{})
|
||||
}.
|
||||
|
||||
-spec role_inputs(map()) -> [{term(), term()}].
|
||||
role_inputs(Roles) ->
|
||||
lists:sort([
|
||||
{Id, maps:get(<<"permissions">>, Role, undefined)}
|
||||
|| {Id, Role} <- maps:to_list(Roles), is_map(Role)
|
||||
]).
|
||||
|
||||
-spec channel_inputs(map()) -> [{term(), term(), term(), term()}].
|
||||
channel_inputs(Channels) ->
|
||||
lists:sort([
|
||||
{
|
||||
Id,
|
||||
maps:get(<<"type">>, Channel, undefined),
|
||||
maps:get(<<"parent_id">>, Channel, undefined),
|
||||
maps:get(<<"permission_overwrites">>, Channel, [])
|
||||
}
|
||||
|| {Id, Channel} <- maps:to_list(Channels), is_map(Channel)
|
||||
]).
|
||||
|
||||
-spec update(session_id(), [term()], guild_state()) -> guild_state().
|
||||
update(SessionId, PartnerIds, State) ->
|
||||
Registrations = registrations(State),
|
||||
Next =
|
||||
case session_owner(SessionId, State) of
|
||||
{ok, UserId, Pid} ->
|
||||
update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State);
|
||||
error ->
|
||||
maps:remove(SessionId, Registrations)
|
||||
end,
|
||||
State#{dm_partners => maybe_prune(Next, State)}.
|
||||
|
||||
-spec update_owned(session_id(), user_id(), pid(), [term()], registrations(), guild_state()) ->
|
||||
registrations().
|
||||
update_owned(SessionId, UserId, Pid, PartnerIds, Registrations, State) ->
|
||||
case presence_targets:dm_partner_presence_enabled(UserId) of
|
||||
false ->
|
||||
maps:remove(SessionId, Registrations);
|
||||
true ->
|
||||
Entry = #{
|
||||
user_id => UserId,
|
||||
pid => Pid,
|
||||
partners => partner_set(PartnerIds, UserId),
|
||||
eligible => previous_eligible(
|
||||
maps:get(SessionId, Registrations, undefined), Pid
|
||||
)
|
||||
},
|
||||
put_all(evaluate(#{SessionId => Entry}, State), Registrations)
|
||||
end.
|
||||
|
||||
-spec reevaluate(scope(), registrations(), guild_state()) -> guild_state().
|
||||
reevaluate(none, _Registrations, State) ->
|
||||
State;
|
||||
reevaluate(all, Registrations, State) ->
|
||||
State#{dm_partners => evaluate(live_registrations(Registrations, State), State)};
|
||||
reevaluate({user, UserId}, Registrations, State) ->
|
||||
Affected = maps:fold(
|
||||
fun(SessionId, Entry, Acc) ->
|
||||
case involves_user(UserId, Entry) of
|
||||
true -> Acc#{SessionId => Entry};
|
||||
false -> Acc
|
||||
end
|
||||
end,
|
||||
#{},
|
||||
Registrations
|
||||
),
|
||||
case map_size(Affected) of
|
||||
0 ->
|
||||
State;
|
||||
_ ->
|
||||
Live = live_registrations(Affected, State),
|
||||
Kept = maps:without(
|
||||
maps:keys(maps:without(maps:keys(Live), Affected)), Registrations
|
||||
),
|
||||
State#{dm_partners => maybe_prune(put_all(evaluate(Live, State), Kept), State)}
|
||||
end.
|
||||
|
||||
-spec put_all(registrations(), registrations()) -> registrations().
|
||||
put_all(Entries, Registrations) ->
|
||||
maps:fold(
|
||||
fun(SessionId, Entry, Acc) -> Acc#{SessionId => Entry} end, Registrations, Entries
|
||||
).
|
||||
|
||||
-spec maybe_prune(registrations(), guild_state()) -> registrations().
|
||||
maybe_prune(Registrations, State) ->
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
case map_size(Registrations) > map_size(Sessions) + ?PRUNE_SLACK of
|
||||
true ->
|
||||
maps:filter(
|
||||
fun(SessionId, _Entry) -> maps:is_key(SessionId, Sessions) end, Registrations
|
||||
);
|
||||
false ->
|
||||
Registrations
|
||||
end.
|
||||
|
||||
-spec evaluate(registrations(), guild_state()) -> registrations().
|
||||
evaluate(Entries, _State) when map_size(Entries) =:= 0 ->
|
||||
Entries;
|
||||
evaluate(Entries, State) ->
|
||||
Requests = [
|
||||
{SessionId, UserId, maps:keys(Partners)}
|
||||
|| {SessionId, #{user_id := UserId, partners := Partners}} <- maps:to_list(Entries)
|
||||
],
|
||||
Results = guild_subscription_mutual_channels:filter_session_member_ids(Requests, State),
|
||||
GuildId = maps:get(id, State),
|
||||
maps:map(
|
||||
fun(SessionId, Entry) ->
|
||||
apply_result(GuildId, maps:get(SessionId, Results, []), Entry)
|
||||
end,
|
||||
Entries
|
||||
).
|
||||
|
||||
-spec apply_result(integer(), [user_id()], registration()) -> registration().
|
||||
apply_result(GuildId, EligibleIds, #{pid := Pid, eligible := Previous} = Entry) ->
|
||||
Eligible = presence_targets:map_from_ids(EligibleIds),
|
||||
ok = notify_if_changed(Pid, GuildId, Previous, Eligible),
|
||||
Entry#{eligible := Eligible}.
|
||||
|
||||
-spec notify_if_changed(pid(), integer(), #{user_id() => true}, #{user_id() => true}) -> ok.
|
||||
notify_if_changed(_Pid, _GuildId, Same, Same) ->
|
||||
ok;
|
||||
notify_if_changed(Pid, GuildId, _Previous, Eligible) ->
|
||||
Pid ! {dm_partner_mutual, GuildId, maps:keys(Eligible)},
|
||||
ok.
|
||||
|
||||
-spec live_registrations(registrations(), guild_state()) -> registrations().
|
||||
live_registrations(Registrations, State) ->
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
maps:filter(
|
||||
fun(SessionId, #{user_id := UserId}) ->
|
||||
maps:is_key(SessionId, Sessions) andalso
|
||||
presence_targets:dm_partner_presence_enabled(UserId)
|
||||
end,
|
||||
Registrations
|
||||
).
|
||||
|
||||
-spec involves_user(user_id(), registration()) -> boolean().
|
||||
involves_user(UserId, #{user_id := UserId}) ->
|
||||
true;
|
||||
involves_user(UserId, #{partners := Partners}) ->
|
||||
maps:is_key(UserId, Partners).
|
||||
|
||||
-spec scope(term(), term()) -> scope().
|
||||
scope(guild_member_add, Data) -> member_scope(Data);
|
||||
scope(guild_member_remove, Data) -> member_scope(Data);
|
||||
scope(guild_member_update, Data) -> member_scope(Data);
|
||||
scope(guild_update, _Data) -> all;
|
||||
scope(guild_role_update, _Data) -> all;
|
||||
scope(guild_role_update_bulk, _Data) -> all;
|
||||
scope(guild_role_delete, _Data) -> all;
|
||||
scope(channel_create, _Data) -> all;
|
||||
scope(channel_update, _Data) -> all;
|
||||
scope(channel_update_bulk, _Data) -> all;
|
||||
scope(channel_delete, _Data) -> all;
|
||||
scope(_Event, _Data) -> none.
|
||||
|
||||
-spec member_scope(term()) -> scope().
|
||||
member_scope(#{<<"user">> := #{<<"id">> := RawUserId}}) ->
|
||||
case snowflake_id:parse_maybe(RawUserId) of
|
||||
UserId when is_integer(UserId) -> {user, UserId};
|
||||
_ -> all
|
||||
end;
|
||||
member_scope(_Data) ->
|
||||
all.
|
||||
|
||||
-spec session_owner(session_id(), guild_state()) -> {ok, user_id(), pid()} | error.
|
||||
session_owner(SessionId, State) ->
|
||||
case maps:get(SessionId, maps:get(sessions, State, #{}), undefined) of
|
||||
#{user_id := UserId, pid := Pid} when is_integer(UserId), is_pid(Pid) ->
|
||||
{ok, UserId, Pid};
|
||||
_ ->
|
||||
error
|
||||
end.
|
||||
|
||||
-spec previous_eligible(registration() | undefined, pid()) -> #{user_id() => true}.
|
||||
previous_eligible(#{pid := Pid, eligible := Eligible}, Pid) ->
|
||||
Eligible;
|
||||
previous_eligible(_Previous, _Pid) ->
|
||||
#{}.
|
||||
|
||||
-spec partner_set([term()], user_id()) -> #{user_id() => true}.
|
||||
partner_set(PartnerIds, UserId) ->
|
||||
presence_targets:map_from_ids(
|
||||
lists:sublist([Id || Id <- PartnerIds, is_integer(Id), Id =/= UserId], ?MAX_PARTNERS)
|
||||
).
|
||||
|
||||
-spec registrations(guild_state()) -> registrations().
|
||||
registrations(State) ->
|
||||
case maps:get(dm_partners, State, #{}) of
|
||||
Registrations when is_map(Registrations) -> Registrations;
|
||||
_ -> #{}
|
||||
end.
|
||||
@@ -85,8 +85,9 @@ safe_ets_delete(GuildId) ->
|
||||
get_permissions(GuildId, UserId, ChannelId) when is_integer(GuildId), is_integer(UserId) ->
|
||||
case get_snapshot(GuildId) of
|
||||
{ok, Snapshot} ->
|
||||
Permissions = guild_permissions:get_member_permissions(UserId, ChannelId, Snapshot),
|
||||
{ok, Permissions};
|
||||
safe_member_read(fun() ->
|
||||
{ok, guild_permissions:get_member_permissions(UserId, ChannelId, Snapshot)}
|
||||
end);
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
@@ -97,8 +98,9 @@ get_permissions(_, _, _) ->
|
||||
has_member(GuildId, UserId) when is_integer(GuildId), is_integer(UserId) ->
|
||||
case get_snapshot(GuildId) of
|
||||
{ok, Snapshot} ->
|
||||
Member = guild_permissions:find_member_by_user_id(UserId, Snapshot),
|
||||
{ok, Member =/= undefined};
|
||||
safe_member_read(fun() ->
|
||||
{ok, guild_permissions:find_member_by_user_id(UserId, Snapshot) =/= undefined}
|
||||
end);
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
@@ -109,7 +111,10 @@ has_member(_, _) ->
|
||||
get_member(GuildId, UserId) when is_integer(GuildId), is_integer(UserId) ->
|
||||
case get_snapshot(GuildId) of
|
||||
{ok, Snapshot} ->
|
||||
{ok, guild_permissions:find_member_by_user_id(UserId, Snapshot)};
|
||||
safe_member_read(fun() ->
|
||||
Member = guild_permissions:find_member_by_user_id(UserId, Snapshot),
|
||||
{ok, project_member(Snapshot, Member)}
|
||||
end);
|
||||
{error, not_found} ->
|
||||
{error, not_found}
|
||||
end;
|
||||
@@ -121,13 +126,51 @@ get_snapshot(GuildId) when is_integer(GuildId) ->
|
||||
ensure_table(),
|
||||
case ets:lookup(?TABLE, GuildId) of
|
||||
[{GuildId, Snapshot}] ->
|
||||
{ok, Snapshot};
|
||||
snapshot_with_live_members(Snapshot);
|
||||
[] ->
|
||||
{error, not_found}
|
||||
end;
|
||||
get_snapshot(_) ->
|
||||
{error, not_found}.
|
||||
|
||||
-spec snapshot_with_live_members(guild_state()) -> {ok, guild_state()} | {error, not_found}.
|
||||
snapshot_with_live_members(Snapshot) ->
|
||||
case snapshot_member_table(Snapshot) of
|
||||
Tab when is_reference(Tab) -> live_member_table_snapshot(Tab, Snapshot);
|
||||
undefined -> {ok, Snapshot}
|
||||
end.
|
||||
|
||||
-spec live_member_table_snapshot(ets:tid(), guild_state()) ->
|
||||
{ok, guild_state()} | {error, not_found}.
|
||||
live_member_table_snapshot(Tab, Snapshot) ->
|
||||
case ets:info(Tab, owner) of
|
||||
undefined -> {error, not_found};
|
||||
_ -> {ok, Snapshot}
|
||||
end.
|
||||
|
||||
-spec snapshot_member_table(guild_state()) -> ets:tid() | undefined.
|
||||
snapshot_member_table(#{data := Data}) when is_map(Data) ->
|
||||
data_member_table(Data);
|
||||
snapshot_member_table(_Snapshot) ->
|
||||
undefined.
|
||||
|
||||
-spec safe_member_read(fun(() -> {ok, term()})) -> {ok, term()} | {error, not_found}.
|
||||
safe_member_read(Read) ->
|
||||
try
|
||||
Read()
|
||||
catch
|
||||
error:badarg -> {error, not_found}
|
||||
end.
|
||||
|
||||
-spec project_member(guild_state(), map() | undefined) -> map() | undefined.
|
||||
project_member(_Snapshot, undefined) ->
|
||||
undefined;
|
||||
project_member(Snapshot, Member) ->
|
||||
case snapshot_member_table(Snapshot) of
|
||||
Tab when is_reference(Tab) -> strip_member(Member);
|
||||
undefined -> Member
|
||||
end.
|
||||
|
||||
-spec ensure_table() -> ok.
|
||||
ensure_table() ->
|
||||
guild_ets_utils:ensure_table(?TABLE, [named_table, public, set, {read_concurrency, true}]).
|
||||
@@ -135,26 +178,62 @@ ensure_table() ->
|
||||
-spec strip_data(guild_data()) -> guild_data().
|
||||
strip_data(Data) when is_map(Data) ->
|
||||
Guild = strip_guild(maps:get(<<"guild">>, Data, #{})),
|
||||
Members = memoised_strip_members(maps:get(<<"members">>, Data, #{})),
|
||||
Roles = strip_roles(maps:get(<<"roles">>, Data, [])),
|
||||
Channels = strip_channels(maps:get(<<"channels">>, Data, [])),
|
||||
ChannelIndex = strip_channel_index(maps:get(<<"channel_index">>, Data, #{})),
|
||||
MemberRoleIndex = maps:get(<<"member_role_index">>, Data, #{}),
|
||||
RolePermsCache = maps:get(role_perms_cache, Data, #{}),
|
||||
OverwritePermsCache = maps:get(overwrite_perms_cache, Data, #{}),
|
||||
#{
|
||||
with_member_source(Data, #{
|
||||
<<"guild">> => Guild,
|
||||
<<"members">> => Members,
|
||||
<<"roles">> => Roles,
|
||||
<<"channels">> => Channels,
|
||||
<<"channel_index">> => ChannelIndex,
|
||||
<<"member_role_index">> => MemberRoleIndex,
|
||||
role_perms_cache => RolePermsCache,
|
||||
overwrite_perms_cache => OverwritePermsCache
|
||||
};
|
||||
});
|
||||
strip_data(Data) ->
|
||||
Data.
|
||||
|
||||
-spec with_member_source(guild_data(), guild_data()) -> guild_data().
|
||||
with_member_source(Data, Base) ->
|
||||
case shared_member_table(Data) of
|
||||
{ok, Tab} ->
|
||||
_ = erlang:erase(?STRIPPED_MEMBERS_MEMO),
|
||||
Base#{<<"members">> => #{}, members_ets => Tab};
|
||||
none ->
|
||||
Base#{<<"members">> => memoised_strip_members(maps:get(<<"members">>, Data, #{}))}
|
||||
end.
|
||||
|
||||
-spec shared_member_table(guild_data()) -> {ok, ets:tid()} | none.
|
||||
shared_member_table(Data) ->
|
||||
case {members_ets_enabled(), data_member_table(Data)} of
|
||||
{true, Tab} when is_reference(Tab) -> readable_member_table(Tab);
|
||||
_ -> none
|
||||
end.
|
||||
|
||||
-spec readable_member_table(ets:tid()) -> {ok, ets:tid()} | none.
|
||||
readable_member_table(Tab) ->
|
||||
case {ets:info(Tab, type), ets:info(Tab, protection)} of
|
||||
{set, public} -> {ok, Tab};
|
||||
{set, protected} -> {ok, Tab};
|
||||
_ -> none
|
||||
end.
|
||||
|
||||
-spec data_member_table(guild_data()) -> ets:tid() | undefined.
|
||||
data_member_table(#{members_ets := Tab}) ->
|
||||
Tab;
|
||||
data_member_table(_Data) ->
|
||||
undefined.
|
||||
|
||||
-spec members_ets_enabled() -> boolean().
|
||||
members_ets_enabled() ->
|
||||
case application:get_env(fluxer_gateway, permission_cache_members_ets, true) of
|
||||
false -> false;
|
||||
_ -> true
|
||||
end.
|
||||
|
||||
-spec member_projection_changed(user_id() | undefined, guild_data(), guild_data()) -> boolean().
|
||||
member_projection_changed(UserId, OldData, NewData) when is_integer(UserId) ->
|
||||
strip_member(guild_data_index:get_member(UserId, OldData)) =/=
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
-module(guild_subscription_mutual_channels).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([filter_member_ids/3]).
|
||||
-export([filter_member_ids/3, filter_session_member_ids/2]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
@@ -28,6 +28,82 @@ filter_member_ids(SessionUserId, MemberIds, State) ->
|
||||
),
|
||||
lists:reverse(Kept).
|
||||
|
||||
-spec filter_session_member_ids([{term(), user_id(), [term()]}], guild_state()) ->
|
||||
#{term() => [user_id()]}.
|
||||
filter_session_member_ids([], _State) ->
|
||||
#{};
|
||||
filter_session_member_ids(Requests, State) ->
|
||||
Exceptions = exceptions(State),
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
{Results, _Cache} = lists:foldl(
|
||||
fun({SessionId, SessionUserId, MemberIds}, {Acc, Cache}) ->
|
||||
SessionMap = request_session_map(SessionId, SessionUserId, Sessions, State),
|
||||
{Kept, Cache1} = keep_session_members(
|
||||
MemberIds, SessionUserId, SessionMap, Exceptions, State, Cache
|
||||
),
|
||||
{Acc#{SessionId => Kept}, Cache1}
|
||||
end,
|
||||
{#{}, #{}},
|
||||
Requests
|
||||
),
|
||||
Results.
|
||||
|
||||
-spec request_session_map(term(), user_id(), map(), guild_state()) -> map().
|
||||
request_session_map(SessionId, SessionUserId, Sessions, State) ->
|
||||
case maps:get(SessionId, Sessions, undefined) of
|
||||
#{user_id := SessionUserId, viewable_channels := Map} = SessionData when is_map(Map) ->
|
||||
case maps:get(pending_connect, SessionData, false) of
|
||||
true -> session_channel_map(SessionUserId, State);
|
||||
_ -> Map
|
||||
end;
|
||||
_ ->
|
||||
session_channel_map(SessionUserId, State)
|
||||
end.
|
||||
|
||||
-spec keep_session_members(
|
||||
[term()], user_id(), map(), sets:set(user_id()), guild_state(), #{term() => [integer()]}
|
||||
) -> {[user_id()], #{term() => [integer()]}}.
|
||||
keep_session_members(MemberIds, SessionUserId, SessionMap, Exceptions, State, Cache) ->
|
||||
{Kept, _Seen, Cache1} = lists:foldl(
|
||||
fun
|
||||
(MemberId, Acc) when MemberId =:= SessionUserId; not is_integer(MemberId) ->
|
||||
Acc;
|
||||
(MemberId, {KeptAcc, Seen, CacheAcc}) ->
|
||||
{Key, CacheAcc1} = member_channel_key(MemberId, Exceptions, State, CacheAcc),
|
||||
case maps:find(Key, Seen) of
|
||||
{ok, true} ->
|
||||
{[MemberId | KeptAcc], Seen, CacheAcc1};
|
||||
{ok, false} ->
|
||||
{KeptAcc, Seen, CacheAcc1};
|
||||
error ->
|
||||
Shared = has_shared_channel(maps:get(Key, CacheAcc1), SessionMap),
|
||||
{kept_if(Shared, MemberId, KeptAcc), Seen#{Key => Shared}, CacheAcc1}
|
||||
end
|
||||
end,
|
||||
{[], #{}, Cache},
|
||||
MemberIds
|
||||
),
|
||||
{lists:reverse(Kept), Cache1}.
|
||||
|
||||
-spec kept_if(boolean(), user_id(), [user_id()]) -> [user_id()].
|
||||
kept_if(true, MemberId, Kept) -> [MemberId | Kept];
|
||||
kept_if(false, _MemberId, Kept) -> Kept.
|
||||
|
||||
-spec member_channel_key(user_id(), sets:set(user_id()), guild_state(), #{term() => [integer()]}) ->
|
||||
{term(), #{term() => [integer()]}}.
|
||||
member_channel_key(MemberId, Exceptions, State, Cache) ->
|
||||
Key =
|
||||
case memo_key(MemberId, Exceptions, State) of
|
||||
bypass -> {user, MemberId};
|
||||
{ok, RawRoles} -> {roles, RawRoles}
|
||||
end,
|
||||
case maps:is_key(Key, Cache) of
|
||||
true ->
|
||||
{Key, Cache};
|
||||
false ->
|
||||
{Key, Cache#{Key => guild_visibility:get_user_viewable_channels(MemberId, State)}}
|
||||
end.
|
||||
|
||||
-spec session_channel_map(user_id(), guild_state()) -> map().
|
||||
session_channel_map(SessionUserId, State) ->
|
||||
case guild_visibility_channels:get_cached_viewable_channel_map(SessionUserId, State) of
|
||||
@@ -284,4 +360,23 @@ non_member_candidate_is_dropped_test() ->
|
||||
?assertEqual([], filter_member_ids(10, [777], State)),
|
||||
?assertEqual([], reference_filter_member_ids(10, [777], State)).
|
||||
|
||||
filter_session_member_ids_matches_filter_member_ids_test() ->
|
||||
State = test_state(),
|
||||
Ids = candidate_ids(),
|
||||
Viewers = [10, 30, 7, 99],
|
||||
Requests = [{Viewer, Viewer, Ids} || Viewer <- Viewers],
|
||||
?assertEqual(
|
||||
maps:from_list([{Viewer, filter_member_ids(Viewer, Ids, State)} || Viewer <- Viewers]),
|
||||
filter_session_member_ids(Requests, State)
|
||||
).
|
||||
|
||||
filter_session_member_ids_reads_the_session_viewable_map_test() ->
|
||||
State = (test_state())#{
|
||||
sessions => #{<<"s10">> => #{user_id => 10, viewable_channels => #{600 => true}}}
|
||||
},
|
||||
?assertEqual(
|
||||
#{<<"s10">> => [30, 31, 7, 99, 4242]},
|
||||
filter_session_member_ids([{<<"s10">>, 10, [20, 30, 31, 7, 99, 4242]}], State)
|
||||
).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
group_dm_channel_recipient_ids/2,
|
||||
dm_recipients_from_state/1,
|
||||
dm_channel_recipient_ids/2,
|
||||
direct_dm_partner_ids/1,
|
||||
dm_partner_presence_enabled/1,
|
||||
map_from_ids/1
|
||||
]).
|
||||
|
||||
@@ -32,10 +34,11 @@ accumulate_friend_id(_UserId, _Type, Acc) ->
|
||||
-spec group_dm_recipients_from_state(state()) -> #{channel_id() => #{user_id() => true}}.
|
||||
group_dm_recipients_from_state(State) ->
|
||||
UserId = maps:get(user_id, State, undefined),
|
||||
Eligible = eligible_dm_partner_ids(State),
|
||||
Channels = maps:get(channels, State, #{}),
|
||||
maps:fold(
|
||||
fun(ChannelId, Channel, Acc) ->
|
||||
accumulate_dm_channel(ChannelId, Channel, UserId, Acc)
|
||||
accumulate_dm_channel(ChannelId, Channel, UserId, Eligible, Acc)
|
||||
end,
|
||||
#{},
|
||||
Channels
|
||||
@@ -45,20 +48,83 @@ group_dm_recipients_from_state(State) ->
|
||||
dm_recipients_from_state(State) ->
|
||||
group_dm_recipients_from_state(State).
|
||||
|
||||
-spec accumulate_dm_channel(term(), term(), user_id() | undefined, map()) -> map().
|
||||
accumulate_dm_channel(ChannelId, Channel, UserId, Acc) when
|
||||
-spec accumulate_dm_channel(
|
||||
term(), term(), user_id() | undefined, #{user_id() => true}, map()
|
||||
) -> map().
|
||||
accumulate_dm_channel(ChannelId, Channel, UserId, Eligible, Acc) when
|
||||
is_integer(ChannelId), is_map(Channel)
|
||||
->
|
||||
case is_group_dm_channel_type(maps:get(<<"type">>, Channel, 0)) of
|
||||
true ->
|
||||
RecipientIds = extract_recipient_ids(Channel),
|
||||
Acc#{ChannelId => map_from_ids([Rid || Rid <- RecipientIds, Rid =/= UserId])};
|
||||
false ->
|
||||
case maps:get(<<"type">>, Channel, 0) of
|
||||
3 ->
|
||||
Acc#{ChannelId => map_from_ids(other_recipient_ids(Channel, UserId))};
|
||||
1 ->
|
||||
accumulate_direct_dm(
|
||||
ChannelId, other_recipient_ids(Channel, UserId), Eligible, Acc
|
||||
);
|
||||
_ ->
|
||||
Acc
|
||||
end;
|
||||
accumulate_dm_channel(_ChannelId, _Channel, _UserId, Acc) ->
|
||||
accumulate_dm_channel(_ChannelId, _Channel, _UserId, _Eligible, Acc) ->
|
||||
Acc.
|
||||
|
||||
-spec accumulate_direct_dm(channel_id(), [user_id()], #{user_id() => true}, map()) -> map().
|
||||
accumulate_direct_dm(ChannelId, RecipientIds, Eligible, Acc) ->
|
||||
case [Rid || Rid <- RecipientIds, maps:is_key(Rid, Eligible)] of
|
||||
[] -> Acc;
|
||||
EligibleIds -> Acc#{ChannelId => map_from_ids(EligibleIds)}
|
||||
end.
|
||||
|
||||
-spec other_recipient_ids(map(), user_id() | undefined) -> [user_id()].
|
||||
other_recipient_ids(Channel, UserId) ->
|
||||
[Rid || Rid <- extract_recipient_ids(Channel), Rid =/= UserId].
|
||||
|
||||
-spec eligible_dm_partner_ids(state()) -> #{user_id() => true}.
|
||||
eligible_dm_partner_ids(State) ->
|
||||
case dm_partner_presence_enabled(maps:get(user_id, State, undefined)) of
|
||||
true -> connected_guild_dm_partners(State);
|
||||
false -> #{}
|
||||
end.
|
||||
|
||||
-spec connected_guild_dm_partners(state()) -> #{user_id() => true}.
|
||||
connected_guild_dm_partners(State) ->
|
||||
Guilds = maps:get(guilds, State, #{}),
|
||||
maps:fold(
|
||||
fun(GuildId, PartnerIds, Acc) ->
|
||||
case maps:get(GuildId, Guilds, undefined) of
|
||||
{Pid, _Ref} when is_pid(Pid), is_map(PartnerIds) -> maps:merge(Acc, PartnerIds);
|
||||
_ -> Acc
|
||||
end
|
||||
end,
|
||||
#{},
|
||||
maps:get(dm_mutual_by_guild, State, #{})
|
||||
).
|
||||
|
||||
-spec direct_dm_partner_ids(state()) -> [user_id()].
|
||||
direct_dm_partner_ids(State) ->
|
||||
UserId = maps:get(user_id, State, undefined),
|
||||
PartnerIds = maps:fold(
|
||||
fun(_ChannelId, Channel, Acc) -> accumulate_direct_partner(Channel, UserId, Acc) end,
|
||||
[],
|
||||
maps:get(channels, State, #{})
|
||||
),
|
||||
lists:usort(PartnerIds).
|
||||
|
||||
-spec accumulate_direct_partner(term(), user_id() | undefined, [user_id()]) -> [user_id()].
|
||||
accumulate_direct_partner(#{<<"type">> := 1} = Channel, UserId, Acc) ->
|
||||
other_recipient_ids(Channel, UserId) ++ Acc;
|
||||
accumulate_direct_partner(_Channel, _UserId, Acc) ->
|
||||
Acc.
|
||||
|
||||
-spec dm_partner_presence_enabled(term()) -> boolean().
|
||||
dm_partner_presence_enabled(UserId) when is_integer(UserId) ->
|
||||
case application:get_env(fluxer_gateway, dm_presence_mutual_context, true) of
|
||||
true -> true;
|
||||
{users, UserIds} when is_list(UserIds) -> lists:member(UserId, UserIds);
|
||||
_ -> false
|
||||
end;
|
||||
dm_partner_presence_enabled(_UserId) ->
|
||||
false.
|
||||
|
||||
-spec is_group_dm_channel_type(term()) -> boolean().
|
||||
is_group_dm_channel_type(3) -> true;
|
||||
is_group_dm_channel_type(_) -> false.
|
||||
|
||||
@@ -238,6 +238,10 @@ handle_info({call_reconnect, ChannelId, Attempt}, State) when
|
||||
session_connection:handle_call_reconnect(ChannelId, Attempt, State);
|
||||
handle_info({gateway_timing_update, Timings}, State) ->
|
||||
{noreply, gateway_timings:merge_state(Timings, State)};
|
||||
handle_info({dm_partner_mutual, GuildId, PartnerIds}, State) when
|
||||
is_integer(GuildId), is_list(PartnerIds)
|
||||
->
|
||||
session_dm_partners:handle_mutual(GuildId, PartnerIds, State);
|
||||
handle_info(Msg, State) ->
|
||||
handle_info_lifecycle(Msg, State).
|
||||
|
||||
|
||||
@@ -460,7 +460,8 @@ finalize_guild_monitor(GuildId, GuildPid, Guilds0, State, ReadyFun) ->
|
||||
apply_ready_fun(GuildId, GuildPid, ReadyFun, State) ->
|
||||
case ReadyFun(State) of
|
||||
{noreply, ReadyState} ->
|
||||
{noreply, maybe_replay_guild_subscriptions(GuildId, GuildPid, ReadyState)};
|
||||
ReplayedState = maybe_replay_guild_subscriptions(GuildId, GuildPid, ReadyState),
|
||||
{noreply, session_dm_partners:register_guild(GuildId, GuildPid, ReplayedState)};
|
||||
{stop, normal, ReadyState} ->
|
||||
{stop, normal, ReadyState}
|
||||
end.
|
||||
|
||||
@@ -105,14 +105,33 @@ maybe_flush_pending_presences(relationship_add, Data, State) ->
|
||||
maybe_flush_pending_presences(relationship_update, Data, State) ->
|
||||
maybe_flush_relationship_pending_presences(Data, State);
|
||||
maybe_flush_pending_presences(channel_create, Data, State) ->
|
||||
flush_dm_channel_pending_presences(Data, State);
|
||||
flush_dm_channel_pending_presences(Data, maybe_register_dm_partners(Data, State));
|
||||
maybe_flush_pending_presences(channel_update, Data, State) ->
|
||||
flush_dm_channel_pending_presences(Data, State);
|
||||
flush_dm_channel_pending_presences(Data, maybe_register_dm_partners(Data, State));
|
||||
maybe_flush_pending_presences(channel_delete, Data, State) ->
|
||||
{maybe_register_dm_partners(Data, State), []};
|
||||
maybe_flush_pending_presences(guild_delete, Data, State) ->
|
||||
{forget_deleted_guild(Data, State), []};
|
||||
maybe_flush_pending_presences(channel_recipient_add, Data, State) ->
|
||||
flush_added_recipient_pending_presences(Data, State);
|
||||
maybe_flush_pending_presences(_, _, State) ->
|
||||
{State, []}.
|
||||
|
||||
-spec maybe_register_dm_partners(term(), session_state()) -> session_state().
|
||||
maybe_register_dm_partners(#{<<"type">> := 1}, State) ->
|
||||
session_dm_partners:register_all(State);
|
||||
maybe_register_dm_partners(_Data, State) ->
|
||||
State.
|
||||
|
||||
-spec forget_deleted_guild(term(), session_state()) -> session_state().
|
||||
forget_deleted_guild(#{<<"id">> := RawGuildId}, State) ->
|
||||
case snowflake_id:parse_maybe(RawGuildId) of
|
||||
GuildId when is_integer(GuildId) -> session_dm_partners:forget_guild(GuildId, State);
|
||||
_ -> State
|
||||
end;
|
||||
forget_deleted_guild(_Data, State) ->
|
||||
State.
|
||||
|
||||
-spec flush_dm_channel_pending_presences(map(), session_state()) ->
|
||||
{session_state(), [user_id()]}.
|
||||
flush_dm_channel_pending_presences(Data, State) ->
|
||||
@@ -229,6 +248,7 @@ event_changes_presence_targets(channel_update) -> true;
|
||||
event_changes_presence_targets(channel_delete) -> true;
|
||||
event_changes_presence_targets(channel_recipient_add) -> true;
|
||||
event_changes_presence_targets(channel_recipient_remove) -> true;
|
||||
event_changes_presence_targets(guild_delete) -> true;
|
||||
event_changes_presence_targets(_) -> false.
|
||||
|
||||
-spec sync_presence_targets([user_id()], session_state()) -> session_state().
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(session_dm_partners).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([register_all/1, register_guild/3, handle_mutual/3, forget_guild/2]).
|
||||
|
||||
-type session_state() :: map().
|
||||
-type guild_id() :: integer().
|
||||
-type user_id() :: integer().
|
||||
|
||||
-define(MAX_PARTNERS, 1000).
|
||||
|
||||
-spec register_all(session_state()) -> session_state().
|
||||
register_all(State) ->
|
||||
case enabled(State) of
|
||||
true ->
|
||||
Partners = partner_ids(State),
|
||||
maps:foreach(
|
||||
fun(_GuildId, GuildRef) -> cast_partners(GuildRef, Partners, State) end,
|
||||
maps:get(guilds, State, #{})
|
||||
),
|
||||
State;
|
||||
false ->
|
||||
State
|
||||
end.
|
||||
|
||||
-spec register_guild(guild_id(), pid(), session_state()) -> session_state().
|
||||
register_guild(_GuildId, GuildPid, State) ->
|
||||
case enabled(State) of
|
||||
true ->
|
||||
cast_partners({GuildPid, undefined}, partner_ids(State), State),
|
||||
State;
|
||||
false ->
|
||||
State
|
||||
end.
|
||||
|
||||
-spec handle_mutual(guild_id(), [user_id()], session_state()) -> {noreply, session_state()}.
|
||||
handle_mutual(GuildId, PartnerIds, State) ->
|
||||
case maps:get(GuildId, maps:get(guilds, State, #{}), undefined) of
|
||||
{Pid, _Ref} when is_pid(Pid) ->
|
||||
{noreply, put_guild_partners(GuildId, PartnerIds, State)};
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end.
|
||||
|
||||
-spec forget_guild(guild_id(), session_state()) -> session_state().
|
||||
forget_guild(GuildId, State) ->
|
||||
Current = maps:get(dm_mutual_by_guild, State, #{}),
|
||||
State#{dm_mutual_by_guild => maps:remove(GuildId, Current)}.
|
||||
|
||||
-spec put_guild_partners(guild_id(), [user_id()], session_state()) -> session_state().
|
||||
put_guild_partners(GuildId, PartnerIds, State) ->
|
||||
Current = maps:get(dm_mutual_by_guild, State, #{}),
|
||||
Next =
|
||||
case presence_targets:map_from_ids(PartnerIds) of
|
||||
Empty when map_size(Empty) =:= 0 -> maps:remove(GuildId, Current);
|
||||
Ids -> Current#{GuildId => Ids}
|
||||
end,
|
||||
case Next =:= Current of
|
||||
true ->
|
||||
State;
|
||||
false ->
|
||||
session_dispatch_presence:sync_presence_targets(State#{dm_mutual_by_guild => Next})
|
||||
end.
|
||||
|
||||
-spec cast_partners(term(), [user_id()], session_state()) -> ok.
|
||||
cast_partners({Pid, _Ref}, Partners, State) when is_pid(Pid) ->
|
||||
gen_server:cast(Pid, {update_dm_partners, maps:get(id, State), Partners});
|
||||
cast_partners(_GuildRef, _Partners, _State) ->
|
||||
ok.
|
||||
|
||||
-spec partner_ids(session_state()) -> [user_id()].
|
||||
partner_ids(State) ->
|
||||
lists:sublist(presence_targets:direct_dm_partner_ids(State), ?MAX_PARTNERS).
|
||||
|
||||
-spec enabled(session_state()) -> boolean().
|
||||
enabled(State) ->
|
||||
maps:get(bot, State, false) =/= true andalso
|
||||
presence_targets:dm_partner_presence_enabled(maps:get(user_id, State, undefined)).
|
||||
@@ -0,0 +1,196 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_dm_partners_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(GUILD_ID, 42).
|
||||
-define(VIEWER_ROLE, 1000).
|
||||
-define(OTHER_ROLE, 2000).
|
||||
-define(SESSION, <<"session-10">>).
|
||||
-define(FLAG, dm_presence_mutual_context).
|
||||
|
||||
registration_notifies_mutual_partners_test() ->
|
||||
with_flag(true, fun() ->
|
||||
State = register_partners(state(), [20, 30, 777]),
|
||||
?assertEqual({dm_partner_mutual, ?GUILD_ID, [20]}, receive_mutual()),
|
||||
?assert(maps:is_key(?SESSION, maps:get(dm_partners, State)))
|
||||
end).
|
||||
|
||||
unknown_session_is_not_registered_test() ->
|
||||
with_flag(true, fun() ->
|
||||
{noreply, State} = guild_dm_partners:handle_cast(
|
||||
{update_dm_partners, <<"missing">>, [20]}, state()
|
||||
),
|
||||
?assertEqual(#{}, maps:get(dm_partners, State, #{})),
|
||||
?assertEqual(none, receive_mutual())
|
||||
end).
|
||||
|
||||
disabled_flag_does_not_register_test() ->
|
||||
with_flag(false, fun() ->
|
||||
State = register_partners(state(), [20]),
|
||||
?assertEqual(#{}, maps:get(dm_partners, State, #{})),
|
||||
?assertEqual(none, receive_mutual())
|
||||
end).
|
||||
|
||||
unchanged_eligibility_is_not_sent_again_test() ->
|
||||
with_flag(true, fun() ->
|
||||
State = register_partners(state(), [20]),
|
||||
{dm_partner_mutual, _, [20]} = receive_mutual(),
|
||||
_ = register_partners(State, [20, 777]),
|
||||
?assertEqual(none, receive_mutual())
|
||||
end).
|
||||
|
||||
partner_leaving_the_guild_withdraws_the_partner_test() ->
|
||||
with_flag(true, fun() ->
|
||||
Before = register_partners(state(), [20]),
|
||||
{dm_partner_mutual, _, [20]} = receive_mutual(),
|
||||
After = without_member(20, Before),
|
||||
_ = guild_dm_partners:maybe_reevaluate(
|
||||
guild_member_remove, #{<<"user">> => #{<<"id">> => <<"20">>}}, Before, After
|
||||
),
|
||||
?assertEqual({dm_partner_mutual, ?GUILD_ID, []}, receive_mutual())
|
||||
end).
|
||||
|
||||
guild_wide_event_without_visibility_change_does_nothing_test() ->
|
||||
with_flag(true, fun() ->
|
||||
Before = register_partners(state(), [20]),
|
||||
{dm_partner_mutual, _, [20]} = receive_mutual(),
|
||||
Renamed = with_channels(
|
||||
[
|
||||
(channel(500, viewer_overwrites()))#{<<"name">> => <<"renamed">>},
|
||||
channel(600, other_overwrites())
|
||||
],
|
||||
Before
|
||||
),
|
||||
_ = guild_dm_partners:maybe_reevaluate(channel_update, #{}, Before, Renamed),
|
||||
?assertEqual(none, receive_mutual())
|
||||
end).
|
||||
|
||||
guild_wide_visibility_change_reevaluates_test() ->
|
||||
with_flag(true, fun() ->
|
||||
Before = register_partners(state(), [30]),
|
||||
?assertEqual(none, receive_mutual()),
|
||||
Opened = with_channels(
|
||||
[
|
||||
channel(500, viewer_overwrites() ++ [overwrite(?OTHER_ROLE, 0)]),
|
||||
channel(600, other_overwrites())
|
||||
],
|
||||
Before
|
||||
),
|
||||
_ = guild_dm_partners:maybe_reevaluate(channel_update, #{}, Before, Opened),
|
||||
?assertEqual({dm_partner_mutual, ?GUILD_ID, [30]}, receive_mutual())
|
||||
end).
|
||||
|
||||
disconnected_sessions_are_dropped_on_reevaluation_test() ->
|
||||
with_flag(true, fun() ->
|
||||
Before = register_partners(state(), [20]),
|
||||
{dm_partner_mutual, _, [20]} = receive_mutual(),
|
||||
Gone = Before#{sessions => #{}},
|
||||
After = guild_dm_partners:maybe_reevaluate(guild_role_delete, #{}, Before, Gone#{
|
||||
virtual_channel_access => #{1 => sets:new()}
|
||||
}),
|
||||
?assertEqual(#{}, maps:get(dm_partners, After))
|
||||
end).
|
||||
|
||||
register_partners(State, PartnerIds) ->
|
||||
{noreply, NewState} = guild_dm_partners:handle_cast(
|
||||
{update_dm_partners, ?SESSION, PartnerIds}, State
|
||||
),
|
||||
NewState.
|
||||
|
||||
state() ->
|
||||
Base = #{
|
||||
id => ?GUILD_ID,
|
||||
virtual_channel_access => #{},
|
||||
data => data(
|
||||
[channel(500, viewer_overwrites()), channel(600, other_overwrites())], members()
|
||||
)
|
||||
},
|
||||
Viewable = guild_sessions:build_viewable_channel_map(
|
||||
guild_visibility:get_user_viewable_channels(10, Base)
|
||||
),
|
||||
Base#{
|
||||
sessions => #{
|
||||
?SESSION => #{user_id => 10, pid => self(), viewable_channels => Viewable}
|
||||
}
|
||||
}.
|
||||
|
||||
data(Channels, Members) ->
|
||||
#{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"7">>},
|
||||
<<"roles">> => [role(?GUILD_ID), role(?VIEWER_ROLE), role(?OTHER_ROLE)],
|
||||
<<"members">> => Members,
|
||||
<<"channels">> => Channels,
|
||||
<<"channel_index">> => guild_data_index:build_id_index(Channels)
|
||||
}.
|
||||
|
||||
members() ->
|
||||
maps:from_list(
|
||||
[{7, member(7, [])}] ++
|
||||
[{Id, member(Id, [?VIEWER_ROLE])} || Id <- [10, 20]] ++
|
||||
[{30, member(30, [?OTHER_ROLE])}]
|
||||
).
|
||||
|
||||
without_member(UserId, #{data := Data} = State) ->
|
||||
State#{data => Data#{<<"members">> => maps:remove(UserId, maps:get(<<"members">>, Data))}}.
|
||||
|
||||
with_channels(Channels, #{data := Data} = State) ->
|
||||
State#{
|
||||
data => Data#{
|
||||
<<"channels">> => Channels,
|
||||
<<"channel_index">> => guild_data_index:build_id_index(Channels)
|
||||
}
|
||||
}.
|
||||
|
||||
role(RoleId) ->
|
||||
#{<<"id">> => integer_to_binary(RoleId), <<"permissions">> => <<"0">>}.
|
||||
|
||||
member(UserId, RoleIds) ->
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"roles">> => [integer_to_binary(RoleId) || RoleId <- RoleIds]
|
||||
}.
|
||||
|
||||
viewer_overwrites() ->
|
||||
[overwrite(?VIEWER_ROLE, 0)].
|
||||
|
||||
other_overwrites() ->
|
||||
[overwrite(?OTHER_ROLE, 0)].
|
||||
|
||||
overwrite(TargetId, Type) ->
|
||||
#{
|
||||
<<"id">> => integer_to_binary(TargetId),
|
||||
<<"type">> => Type,
|
||||
<<"allow">> => integer_to_binary(constants:view_channel_permission()),
|
||||
<<"deny">> => <<"0">>
|
||||
}.
|
||||
|
||||
channel(ChannelId, Overwrites) ->
|
||||
#{
|
||||
<<"id">> => integer_to_binary(ChannelId),
|
||||
<<"type">> => 0,
|
||||
<<"permission_overwrites">> => Overwrites
|
||||
}.
|
||||
|
||||
receive_mutual() ->
|
||||
receive
|
||||
{dm_partner_mutual, _, _} = Msg -> Msg
|
||||
after 100 ->
|
||||
none
|
||||
end.
|
||||
|
||||
with_flag(Value, Fun) ->
|
||||
Previous = application:get_env(fluxer_gateway, ?FLAG),
|
||||
application:set_env(fluxer_gateway, ?FLAG, Value),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
restore_flag(Previous)
|
||||
end.
|
||||
|
||||
restore_flag(undefined) ->
|
||||
application:unset_env(fluxer_gateway, ?FLAG);
|
||||
restore_flag({ok, Value}) ->
|
||||
application:set_env(fluxer_gateway, ?FLAG, Value).
|
||||
@@ -0,0 +1,127 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_permission_cache_member_table_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(MEMO, guild_permission_cache_stripped_members).
|
||||
-define(CHANNEL_ID, 500).
|
||||
|
||||
snapshot_points_at_the_member_table_test() ->
|
||||
with_guild(908, [set, public], fun(GuildId, Tab, _Data) ->
|
||||
{ok, Snapshot} = guild_permission_cache:get_snapshot(GuildId),
|
||||
SnapshotData = maps:get(data, Snapshot),
|
||||
?assertEqual(Tab, maps:get(members_ets, SnapshotData)),
|
||||
?assertEqual(#{}, maps:get(<<"members">>, SnapshotData)),
|
||||
?assertEqual(undefined, erlang:get(?MEMO))
|
||||
end).
|
||||
|
||||
member_reads_match_the_stripped_members_snapshot_test() ->
|
||||
Data = member_data(),
|
||||
GuildId = 909,
|
||||
memo_reset(),
|
||||
Tab = member_table(Data, [set, public]),
|
||||
try
|
||||
ok = guild_permission_cache:put_normalized_data(GuildId, Data),
|
||||
Expected = member_reads(GuildId),
|
||||
ok = guild_permission_cache:put_normalized_data(GuildId, Data#{members_ets => Tab}),
|
||||
?assertEqual(Expected, member_reads(GuildId))
|
||||
after
|
||||
cleanup(GuildId, Tab)
|
||||
end.
|
||||
|
||||
deleted_member_table_reports_not_found_test() ->
|
||||
with_guild(910, [set, public], fun(GuildId, Tab, _Data) ->
|
||||
true = ets:delete(Tab),
|
||||
?assertEqual({error, not_found}, guild_permission_cache:get_snapshot(GuildId)),
|
||||
?assertEqual(
|
||||
{error, not_found}, guild_permission_cache:get_permissions(GuildId, 1, ?CHANNEL_ID)
|
||||
),
|
||||
?assertEqual({error, not_found}, guild_permission_cache:has_member(GuildId, 1)),
|
||||
?assertEqual({error, not_found}, guild_permission_cache:get_member(GuildId, 1))
|
||||
end).
|
||||
|
||||
private_member_table_keeps_the_stripped_members_test() ->
|
||||
with_guild(911, [set, private], fun(GuildId, _Tab, Data) ->
|
||||
{ok, Snapshot} = guild_permission_cache:get_snapshot(GuildId),
|
||||
SnapshotData = maps:get(data, Snapshot),
|
||||
?assertNot(maps:is_key(members_ets, SnapshotData)),
|
||||
?assertEqual(
|
||||
map_size(maps:get(<<"members">>, Data)),
|
||||
map_size(maps:get(<<"members">>, SnapshotData))
|
||||
)
|
||||
end).
|
||||
|
||||
disabled_flag_keeps_the_stripped_members_test() ->
|
||||
application:set_env(fluxer_gateway, permission_cache_members_ets, false),
|
||||
try
|
||||
with_guild(912, [set, public], fun(GuildId, _Tab, Data) ->
|
||||
{ok, Snapshot} = guild_permission_cache:get_snapshot(GuildId),
|
||||
SnapshotData = maps:get(data, Snapshot),
|
||||
?assertNot(maps:is_key(members_ets, SnapshotData)),
|
||||
?assertEqual(
|
||||
map_size(maps:get(<<"members">>, Data)),
|
||||
map_size(maps:get(<<"members">>, SnapshotData))
|
||||
)
|
||||
end)
|
||||
after
|
||||
application:unset_env(fluxer_gateway, permission_cache_members_ets)
|
||||
end.
|
||||
|
||||
with_guild(GuildId, Options, Fun) ->
|
||||
memo_reset(),
|
||||
Data = member_data(),
|
||||
Tab = member_table(Data, Options),
|
||||
try
|
||||
ok = guild_permission_cache:put_normalized_data(GuildId, Data#{members_ets => Tab}),
|
||||
Fun(GuildId, Tab, Data)
|
||||
after
|
||||
cleanup(GuildId, Tab)
|
||||
end.
|
||||
|
||||
cleanup(GuildId, Tab) ->
|
||||
try
|
||||
ets:delete(Tab)
|
||||
catch
|
||||
error:badarg -> true
|
||||
end,
|
||||
ok = guild_permission_cache:delete(GuildId),
|
||||
memo_reset().
|
||||
|
||||
member_reads(GuildId) ->
|
||||
[
|
||||
{UserId, guild_permission_cache:get_permissions(GuildId, UserId, ?CHANNEL_ID),
|
||||
guild_permission_cache:has_member(GuildId, UserId),
|
||||
guild_permission_cache:get_member(GuildId, UserId)}
|
||||
|| UserId <- [1, 2, 3]
|
||||
].
|
||||
|
||||
member_data() ->
|
||||
guild_data_index:normalize_data(#{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [#{<<"id">> => <<"42">>, <<"permissions">> => <<"1024">>}],
|
||||
<<"members">> => [member(1, [<<"42">>]), member(2, [])],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => integer_to_binary(?CHANNEL_ID), <<"permission_overwrites">> => []}
|
||||
]
|
||||
}).
|
||||
|
||||
member(UserId, Roles) ->
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId), <<"username">> => <<"u">>},
|
||||
<<"roles">> => Roles,
|
||||
<<"nick">> => <<"n">>
|
||||
}.
|
||||
|
||||
member_table(Data, Options) ->
|
||||
Tab = ets:new(guild_permission_cache_member_table_tests, Options),
|
||||
maps:foreach(
|
||||
fun(UserId, Member) -> true = ets:insert(Tab, {UserId, Member}) end,
|
||||
maps:get(<<"members">>, Data)
|
||||
),
|
||||
Tab.
|
||||
|
||||
memo_reset() ->
|
||||
_ = erlang:erase(?MEMO),
|
||||
ok.
|
||||
@@ -0,0 +1,93 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(presence_targets_dm_partner_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(SELF, 1).
|
||||
-define(GUILD, 900).
|
||||
-define(FLAG, dm_presence_mutual_context).
|
||||
|
||||
eligible_direct_dm_is_a_presence_target_test() ->
|
||||
with_flag(true, fun() ->
|
||||
State = state(#{?GUILD => #{2 => true}}, connected()),
|
||||
?assertEqual(
|
||||
#{100 => #{2 => true}, 300 => #{5 => true, 6 => true}},
|
||||
presence_targets:group_dm_recipients_from_state(State)
|
||||
)
|
||||
end).
|
||||
|
||||
direct_dm_without_mutual_context_is_not_a_target_test() ->
|
||||
with_flag(true, fun() ->
|
||||
State = state(#{}, connected()),
|
||||
?assertEqual(
|
||||
#{300 => #{5 => true, 6 => true}},
|
||||
presence_targets:group_dm_recipients_from_state(State)
|
||||
)
|
||||
end).
|
||||
|
||||
mutual_context_from_a_disconnected_guild_is_ignored_test() ->
|
||||
with_flag(true, fun() ->
|
||||
State = state(#{?GUILD => #{2 => true}}, #{?GUILD => undefined}),
|
||||
?assertEqual(
|
||||
#{300 => #{5 => true, 6 => true}},
|
||||
presence_targets:group_dm_recipients_from_state(State)
|
||||
)
|
||||
end).
|
||||
|
||||
disabled_flag_keeps_direct_dms_out_test() ->
|
||||
with_flag(false, fun() ->
|
||||
State = state(#{?GUILD => #{2 => true, 3 => true}}, connected()),
|
||||
?assertEqual(
|
||||
#{300 => #{5 => true, 6 => true}},
|
||||
presence_targets:group_dm_recipients_from_state(State)
|
||||
)
|
||||
end).
|
||||
|
||||
user_allowlist_gates_direct_dms_test() ->
|
||||
State = state(#{?GUILD => #{2 => true}}, connected()),
|
||||
with_flag({users, [?SELF]}, fun() ->
|
||||
?assert(maps:is_key(100, presence_targets:group_dm_recipients_from_state(State)))
|
||||
end),
|
||||
with_flag({users, [42]}, fun() ->
|
||||
?assertNot(maps:is_key(100, presence_targets:group_dm_recipients_from_state(State)))
|
||||
end).
|
||||
|
||||
direct_dm_partner_ids_lists_one_to_one_partners_only_test() ->
|
||||
?assertEqual([2, 3], presence_targets:direct_dm_partner_ids(state(#{}, connected()))).
|
||||
|
||||
state(MutualByGuild, Guilds) ->
|
||||
#{
|
||||
user_id => ?SELF,
|
||||
guilds => Guilds,
|
||||
dm_mutual_by_guild => MutualByGuild,
|
||||
channels => #{
|
||||
100 => channel(1, [?SELF, 2]),
|
||||
200 => channel(1, [?SELF, 3]),
|
||||
300 => channel(3, [?SELF, 5, 6])
|
||||
}
|
||||
}.
|
||||
|
||||
channel(Type, RecipientIds) ->
|
||||
#{
|
||||
<<"type">> => Type,
|
||||
<<"recipients">> => [#{<<"id">> => integer_to_binary(Id)} || Id <- RecipientIds]
|
||||
}.
|
||||
|
||||
connected() ->
|
||||
#{?GUILD => {self(), make_ref()}}.
|
||||
|
||||
with_flag(Value, Fun) ->
|
||||
Previous = application:get_env(fluxer_gateway, ?FLAG),
|
||||
application:set_env(fluxer_gateway, ?FLAG, Value),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
restore_flag(Previous)
|
||||
end.
|
||||
|
||||
restore_flag(undefined) ->
|
||||
application:unset_env(fluxer_gateway, ?FLAG);
|
||||
restore_flag({ok, Value}) ->
|
||||
application:set_env(fluxer_gateway, ?FLAG, Value).
|
||||
@@ -0,0 +1,97 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(session_dm_partners_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(SELF, 1).
|
||||
-define(GUILD, 900).
|
||||
-define(SESSION_ID, <<"session-1">>).
|
||||
-define(FLAG, dm_presence_mutual_context).
|
||||
|
||||
handle_mutual_stores_partners_for_a_connected_guild_test() ->
|
||||
{noreply, State} = session_dm_partners:handle_mutual(?GUILD, [2], state(connected())),
|
||||
?assertEqual(#{?GUILD => #{2 => true}}, maps:get(dm_mutual_by_guild, State)).
|
||||
|
||||
handle_mutual_ignores_a_guild_that_is_not_connected_test() ->
|
||||
Initial = state(#{}),
|
||||
?assertEqual({noreply, Initial}, session_dm_partners:handle_mutual(?GUILD, [2], Initial)).
|
||||
|
||||
handle_mutual_drops_the_guild_when_nothing_is_mutual_test() ->
|
||||
{noreply, With} = session_dm_partners:handle_mutual(?GUILD, [2], state(connected())),
|
||||
{noreply, Without} = session_dm_partners:handle_mutual(?GUILD, [], With),
|
||||
?assertEqual(#{}, maps:get(dm_mutual_by_guild, Without)).
|
||||
|
||||
forget_guild_drops_its_partners_test() ->
|
||||
{noreply, With} = session_dm_partners:handle_mutual(?GUILD, [2], state(connected())),
|
||||
?assertEqual(
|
||||
#{}, maps:get(dm_mutual_by_guild, session_dm_partners:forget_guild(?GUILD, With))
|
||||
).
|
||||
|
||||
register_all_sends_direct_partners_to_every_connected_guild_test() ->
|
||||
with_flag(true, fun() ->
|
||||
_ = session_dm_partners:register_all(state(connected())),
|
||||
?assertEqual({update_dm_partners, ?SESSION_ID, [2, 3]}, receive_cast())
|
||||
end).
|
||||
|
||||
register_guild_sends_direct_partners_to_that_guild_test() ->
|
||||
with_flag(true, fun() ->
|
||||
_ = session_dm_partners:register_guild(?GUILD, self(), state(#{})),
|
||||
?assertEqual({update_dm_partners, ?SESSION_ID, [2, 3]}, receive_cast())
|
||||
end).
|
||||
|
||||
register_all_is_inert_when_disabled_test() ->
|
||||
with_flag(false, fun() ->
|
||||
_ = session_dm_partners:register_all(state(connected())),
|
||||
?assertEqual(none, receive_cast())
|
||||
end).
|
||||
|
||||
bots_never_register_test() ->
|
||||
with_flag(true, fun() ->
|
||||
_ = session_dm_partners:register_all((state(connected()))#{bot => true}),
|
||||
?assertEqual(none, receive_cast())
|
||||
end).
|
||||
|
||||
state(Guilds) ->
|
||||
#{
|
||||
id => ?SESSION_ID,
|
||||
user_id => ?SELF,
|
||||
presence_pid => undefined,
|
||||
guilds => Guilds,
|
||||
channels => #{
|
||||
100 => channel(1, [?SELF, 2]),
|
||||
200 => channel(1, [?SELF, 3]),
|
||||
300 => channel(3, [?SELF, 5, 6])
|
||||
}
|
||||
}.
|
||||
|
||||
channel(Type, RecipientIds) ->
|
||||
#{
|
||||
<<"type">> => Type,
|
||||
<<"recipients">> => [#{<<"id">> => integer_to_binary(Id)} || Id <- RecipientIds]
|
||||
}.
|
||||
|
||||
connected() ->
|
||||
#{?GUILD => {self(), make_ref()}}.
|
||||
|
||||
receive_cast() ->
|
||||
receive
|
||||
{'$gen_cast', Msg} -> Msg
|
||||
after 100 ->
|
||||
none
|
||||
end.
|
||||
|
||||
with_flag(Value, Fun) ->
|
||||
Previous = application:get_env(fluxer_gateway, ?FLAG),
|
||||
application:set_env(fluxer_gateway, ?FLAG, Value),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
restore_flag(Previous)
|
||||
end.
|
||||
|
||||
restore_flag(undefined) ->
|
||||
application:unset_env(fluxer_gateway, ?FLAG);
|
||||
restore_flag({ok, Value}) ->
|
||||
application:set_env(fluxer_gateway, ?FLAG, Value).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user