Compare commits

...
54 changed files with 1061 additions and 189 deletions
@@ -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;
+4 -1
View File
@@ -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']);
});
});
-2
View File
@@ -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();
@@ -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
@@ -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,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 () => {
@@ -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');
});
});
+75 -3
View File
@@ -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});
}
+100 -19
View File
@@ -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 {
+102 -28
View File
@@ -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
+11 -3
View File
@@ -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() ->
@@ -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)) =/=
@@ -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.
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: bg\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: cs\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: da\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: el\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: en-GB\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: en-US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: es-419\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: es-ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: fi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: he\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: hi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: hr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: hu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: id\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: ko\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: lt\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: nl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: no\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: pl\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: pt-BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: ro\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: ru\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: sv-SE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: th\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: tr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: uk\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: vi\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: zh-CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+2 -2
View File
@@ -4,8 +4,8 @@
msgid ""
msgstr ""
"Project-Id-Version: fluxer-marketing\n"
"POT-Creation-Date: 2026-08-28 00:00+0000\n"
"PO-Revision-Date: 2026-08-28 00:00+0000\n"
"POT-Creation-Date: 2026-09-10 00:00+0000\n"
"PO-Revision-Date: 2026-09-10 00:00+0000\n"
"Language: zh-TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
+87 -1
View File
@@ -10,6 +10,8 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;
pub(crate) const RELEASE_REPOSITORY: &str = "fluxerapp/fluxer";
const RELEASE_COMPARE_URL: &str = "https://github.com/fluxerapp/fluxer/compare";
@@ -321,9 +323,34 @@ struct GitRef {
name: String,
}
const PUBLISH_ATTEMPTS: u64 = 3;
pub async fn run(args: ReleaseArgs) -> Result<()> {
match args.command {
ReleaseCommand::Publish(args) => publish(args),
ReleaseCommand::Publish(args) => retry_publish(
PUBLISH_ATTEMPTS,
|attempt| thread::sleep(Duration::from_secs(attempt * 15)),
|| publish(args.clone()),
),
}
}
fn retry_publish(
attempts: u64,
mut wait: impl FnMut(u64),
mut publish: impl FnMut() -> Result<()>,
) -> Result<()> {
let mut attempt = 1;
loop {
match publish() {
Ok(()) => return Ok(()),
Err(error) if attempt < attempts => {
eprintln!("Release publish attempt {attempt} of {attempts} failed: {error:#}");
wait(attempt);
attempt += 1;
}
Err(error) => return Err(error),
}
}
}
@@ -1010,3 +1037,62 @@ fn release_body(previous_sha: &str, source_sha: &str) -> String {
fn desktop_channel(component: &str) -> Option<&str> {
component.strip_prefix("fluxer-desktop-")
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::anyhow;
#[test]
fn retry_publish_retries_until_a_publish_succeeds() {
let mut calls = 0;
let mut waits = Vec::new();
let result = retry_publish(
3,
|attempt| waits.push(attempt),
|| {
calls += 1;
if calls < 3 {
Err(anyhow!("unexpected end of JSON input"))
} else {
Ok(())
}
},
);
assert!(result.is_ok());
assert_eq!(calls, 3);
assert_eq!(waits, vec![1, 2]);
}
#[test]
fn retry_publish_returns_the_last_error_without_waiting_after_it() {
let mut calls = 0;
let mut waits = Vec::new();
let result = retry_publish(
3,
|attempt| waits.push(attempt),
|| {
calls += 1;
Err(anyhow!("attempt {calls} failed"))
},
);
assert_eq!(result.unwrap_err().to_string(), "attempt 3 failed");
assert_eq!(calls, 3);
assert_eq!(waits, vec![1, 2]);
}
#[test]
fn retry_publish_does_not_retry_a_successful_publish() {
let mut calls = 0;
let result = retry_publish(
3,
|_| panic!("a successful publish must not wait"),
|| {
calls += 1;
Ok(())
},
);
assert!(result.is_ok());
assert_eq!(calls, 1);
}
}