mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 |
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+1
-1
Submodule fluxer_marketing updated: 590850723c...7867cf8951
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user