mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(desktop): portal-based Linux global shortcuts and PTT (#3185)
This commit is contained in:
1 parent
5799ef705d
commit
8f58fcc4c4
149 files changed
+26849
-8769
No files matched your search
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
GlobalShortcutsApi,
|
||||
GlobalShortcutsBackend,
|
||||
GlobalShortcutsPortalState,
|
||||
GlobalShortcutsPortalStatus,
|
||||
GlobalShortcutsStatus,
|
||||
} from '@app/types/electron.d';
|
||||
import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
msg: (descriptor: unknown) => descriptor,
|
||||
t: (descriptor: unknown) => descriptor,
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: () => null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
|
||||
|
||||
(globalThis.window as unknown as Record<string, unknown>).__FLUXER_BOOTSTRAP__ = {
|
||||
config: {
|
||||
releaseChannel: 'stable',
|
||||
bootstrapApiEndpoint: BOOTSTRAP_ENDPOINT,
|
||||
bootstrapApiPublicEndpoint: BOOTSTRAP_ENDPOINT,
|
||||
},
|
||||
instance: {
|
||||
api_code_version: Number.MAX_SAFE_INTEGER,
|
||||
endpoints: {
|
||||
api: BOOTSTRAP_ENDPOINT,
|
||||
api_client: BOOTSTRAP_ENDPOINT,
|
||||
api_public: BOOTSTRAP_ENDPOINT,
|
||||
gateway: 'wss://gateway.primary.test',
|
||||
media: 'https://media.primary.test',
|
||||
static_cdn: 'https://cdn.primary.test',
|
||||
marketing: 'https://primary.test',
|
||||
admin: 'https://admin.primary.test',
|
||||
invite: 'https://primary.test/invite',
|
||||
gift: 'https://primary.test/gift',
|
||||
webapp: 'https://app.primary.test',
|
||||
upload_relay: 'https://upload.primary.test',
|
||||
},
|
||||
captcha: {provider: 'none'},
|
||||
features: {
|
||||
voice_enabled: false,
|
||||
stripe_enabled: false,
|
||||
self_hosted: false,
|
||||
presigned_attachment_uploads: false,
|
||||
emails_enabled: false,
|
||||
phone_verification_enabled: false,
|
||||
},
|
||||
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
|
||||
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''},
|
||||
registration: {mode: 'open', admin_registration_urls_enabled: true},
|
||||
community: {single_community: false, single_community_guild_id: null, direct_messages_disabled: false},
|
||||
services: {gif_enabled: true, youtube_enabled: false, bluesky_enabled: false},
|
||||
limits: undefined,
|
||||
push: {public_vapid_key: null},
|
||||
app_public: {
|
||||
branding: {
|
||||
product_name: 'Fluxer',
|
||||
icon_url: null,
|
||||
symbol_url: null,
|
||||
logo_url: null,
|
||||
wordmark_url: null,
|
||||
favicon_url: null,
|
||||
theme_color: null,
|
||||
},
|
||||
setup: {configured: true, admin_url: null},
|
||||
legal: {terms_url: null, privacy_url: null},
|
||||
registration: {collect_date_of_birth: true},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let statusListener: ((status: GlobalShortcutsStatus) => void) | null = null;
|
||||
|
||||
const globalShortcutsApi: Partial<GlobalShortcutsApi> = {
|
||||
sync: async () => {},
|
||||
getStatus: () => new Promise<GlobalShortcutsStatus>(() => {}),
|
||||
onStatus: (callback) => {
|
||||
statusListener = callback;
|
||||
return () => {
|
||||
statusListener = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@app/features/ui/utils/NativeUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@app/features/ui/utils/NativeUtils')>()),
|
||||
getElectronAPI: () => ({globalShortcuts: globalShortcutsApi}),
|
||||
}));
|
||||
|
||||
const PORTAL_ASSIGNED_ACTIONS_KEY = 'GlobalShortcuts:portalAssignedActions:v1';
|
||||
|
||||
interface PortalStatusOptions {
|
||||
recovering?: boolean;
|
||||
trigger?: string | null;
|
||||
error?: string | null;
|
||||
backend?: GlobalShortcutsBackend;
|
||||
directInputEnabled?: boolean;
|
||||
}
|
||||
|
||||
function portalStatus(state: GlobalShortcutsPortalState, options: PortalStatusOptions = {}): GlobalShortcutsStatus {
|
||||
const trigger = options.trigger === undefined ? 'F13' : options.trigger;
|
||||
const portal: GlobalShortcutsPortalStatus = {
|
||||
state,
|
||||
version: 2,
|
||||
canConfigure: true,
|
||||
canRecheck: state === 'unsupported' || state === 'error',
|
||||
portalAppId: 'app.fluxer.FluxerDesktop',
|
||||
shortcuts: [
|
||||
{action: 'voice_push_to_talk', triggerDescription: state === 'bound' ? trigger : null},
|
||||
{action: 'voice_push_to_mute', triggerDescription: null},
|
||||
],
|
||||
error: options.error ?? (state === 'error' ? 'portal-unavailable' : null),
|
||||
recovering: options.recovering ?? false,
|
||||
};
|
||||
return {
|
||||
backend: options.backend ?? (state === 'unsupported' ? 'none' : 'portal'),
|
||||
platform: 'linux',
|
||||
linux: {
|
||||
session: 'wayland',
|
||||
sandbox: 'none',
|
||||
desktop: 'gnome',
|
||||
portal,
|
||||
directInput: {available: true, enabled: options.directInputEnabled ?? false, locked: false},
|
||||
},
|
||||
hooksActive: false,
|
||||
hookError: null,
|
||||
supportsMouseButtons: false,
|
||||
supportsModifierOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
type Modules = {
|
||||
GlobalShortcuts: typeof import('@app/features/input/state/GlobalShortcuts').default;
|
||||
Keybind: typeof import('@app/features/input/state/InputKeybind').default;
|
||||
AppStorage: typeof import('@app/features/platform/state/PersistentStorage').default;
|
||||
reactToPushToTalkModeChanges: typeof import('@app/features/app/keybindings/utils/PushToTalkModeReconcile').reactToPushToTalkModeChanges;
|
||||
};
|
||||
|
||||
let modules: Modules;
|
||||
|
||||
function emit(status: GlobalShortcutsStatus): void {
|
||||
expect(statusListener).not.toBeNull();
|
||||
statusListener?.(status);
|
||||
}
|
||||
|
||||
describe('push-to-talk mode reconcile', () => {
|
||||
beforeAll(async () => {
|
||||
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
|
||||
AppStorage.setJSON(PORTAL_ASSIGNED_ACTIONS_KEY, ['voice_push_to_talk']);
|
||||
const {default: GlobalShortcuts} = await import('@app/features/input/state/GlobalShortcuts');
|
||||
const {default: Keybind} = await import('@app/features/input/state/InputKeybind');
|
||||
const {reactToPushToTalkModeChanges} = await import('@app/features/app/keybindings/utils/PushToTalkModeReconcile');
|
||||
modules = {GlobalShortcuts, Keybind, AppStorage, reactToPushToTalkModeChanges};
|
||||
Keybind.setTransmitMode('voice_push_to_talk');
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
modules.GlobalShortcuts.detach();
|
||||
const {default: Idle} = await import('@app/features/ui/state/Idle');
|
||||
Idle.destroy();
|
||||
});
|
||||
|
||||
it('restores the last settled portal assignment at startup before any status arrives', () => {
|
||||
const {GlobalShortcuts, Keybind} = modules;
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(false);
|
||||
GlobalShortcuts.attach();
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
emit(portalStatus('unknown'));
|
||||
emit(portalStatus('probing'));
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps push-to-talk effective through transient and error portal states', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
emit(portalStatus('bound'));
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
for (const status of [
|
||||
portalStatus('bound', {recovering: true}),
|
||||
portalStatus('bound', {recovering: true, trigger: null}),
|
||||
portalStatus('declined', {recovering: true}),
|
||||
portalStatus('unknown', {recovering: true, backend: 'none'}),
|
||||
portalStatus('probing', {recovering: true}),
|
||||
portalStatus('binding'),
|
||||
portalStatus('error', {recovering: true}),
|
||||
portalStatus('error', {error: 'portal-unavailable'}),
|
||||
portalStatus('error', {error: 'timeout'}),
|
||||
portalStatus('error', {error: 'session-closed'}),
|
||||
portalStatus('error', {error: 'identity'}),
|
||||
portalStatus('error', {error: 'bind-failed'}),
|
||||
portalStatus('unsupported', {recovering: true}),
|
||||
portalStatus('not-set-up'),
|
||||
portalStatus('unknown'),
|
||||
portalStatus('bound'),
|
||||
]) {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
}
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps push-to-talk effective while a recovering session reopens with no listed triggers', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
emit(portalStatus('bound'));
|
||||
for (const status of [
|
||||
portalStatus('bound', {recovering: true}),
|
||||
portalStatus('bound', {recovering: true, trigger: null}),
|
||||
portalStatus('binding', {recovering: true}),
|
||||
portalStatus('bound'),
|
||||
]) {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
}
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('flips push-to-talk only on settled states and preserves self-mute when it does', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
const expectFlip = (status: GlobalShortcutsStatus, effective: boolean): void => {
|
||||
reconcile.mockClear();
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(effective);
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
|
||||
};
|
||||
expectFlip(portalStatus('declined'), false);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('unsupported'), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('unknown', {backend: 'evdev', directInputEnabled: true}), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('bound', {trigger: null}), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
reconcile.mockClear();
|
||||
Keybind.setTransmitMode('voice_activity');
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false});
|
||||
Keybind.setTransmitMode('voice_push_to_talk');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user