fix: give "Show the app" a key the composer doesn't use, and refuse the app's own keys for global shortcuts

This commit is contained in:
Linus Rath
2026-11-04 11:29:34 +01:00
parent b17f36d7ba
commit 4e662d8a60
4 changed files with 47 additions and 12 deletions
+6 -10
View File
@@ -5,16 +5,10 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types";
import { describeAccelerator, showShortcutDefault } from "@/lib/desktop/shortcut-keys";
import { DesktopQuickComposeSetting } from "./desktop-quick-compose-setting";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */
const SHOW_SHORTCUT = "CommandOrControl+Shift+B";
function shortcutLabel(platform: string | undefined): string {
return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B";
}
/**
* The desktop app's own settings: only where there's an app to set. A browser never sees
* them, and they render nothing until the bridge has answered. The General page has how
@@ -46,6 +40,8 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
if (!os || !status) return null;
const { settings } = status;
const platform = bridge?.platform;
const showKeys = describeAccelerator(settings.showShortcut ?? showShortcutDefault(platform), platform);
const change = (patch: Partial<Settings>) => {
void os.setSettings(patch).then(setStatus);
};
@@ -103,12 +99,12 @@ export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notificati
<SettingItem
label={t("show_shortcut_label")}
description={settings.showShortcut && !status.shortcutActive
? t("show_shortcut_taken", { keys: shortcutLabel(bridge?.platform) })
: t("show_shortcut_description", { keys: shortcutLabel(bridge?.platform) })}
? t("show_shortcut_taken", { keys: showKeys })
: t("show_shortcut_description", { keys: showKeys })}
>
<ToggleSwitch
checked={!!settings.showShortcut}
onChange={(on) => change({ showShortcut: on ? SHOW_SHORTCUT : null })}
onChange={(on) => change({ showShortcut: on ? showShortcutDefault(platform) : null })}
/>
</SettingItem>
{status.quickCompose && (
+11
View File
@@ -431,6 +431,17 @@ describe('the settings section', () => {
await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled());
});
it('switching "Show the app" on takes a key the composer doesn\'t use', async () => {
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
const os = bridgeFor(status());
setBridge(os as never);
render(<DesktopOsSettings />);
expect(await screen.findByText('show_shortcut_label')).toBeInTheDocument();
// Close to tray, start with the computer, then this one.
fireEvent.click(screen.getAllByRole('switch')[2]);
await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ showShortcut: 'CommandOrControl+Alt+F11' }));
});
it('says when another app holds the shortcut', async () => {
const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings');
setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never);
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { describeAccelerator, readShortcutPress, shortcutProblem, type ShortcutKeyEvent } from '../shortcut-keys';
import { describeAccelerator, readShortcutPress, shortcutProblem, showShortcutDefault, type ShortcutKeyEvent } from '../shortcut-keys';
const press = (key: string, code: string, modifiers: Partial<ShortcutKeyEvent> = {}): ShortcutKeyEvent => ({
key,
@@ -108,4 +108,17 @@ describe('shortcutProblem', () => {
expect(shortcutProblem('CommandOrControl+Control+N', 'win32')).toBe('incomplete');
expect(shortcutProblem('CommandOrControl+Control+N', 'darwin')).toBeNull();
});
it('refuses the app\'s own keys: a global one would take them from the composer and the menus', () => {
for (const keys of ['CommandOrControl+Shift+B', 'CommandOrControl+Shift+C', 'CommandOrControl+Shift+Enter', 'CommandOrControl+Shift+M', 'CommandOrControl+Shift+N', 'CommandOrControl+Shift+S', 'CommandOrControl+Shift+R', 'CommandOrControl+Shift+F']) {
for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(keys, platform), `${keys} on ${platform}`).toBe('reserved');
}
expect(readShortcutPress(press('B', 'KeyB', { ctrlKey: true, shiftKey: true }), 'win32')).toEqual({ refused: 'reserved' });
});
it('"Show the app" by default is none of them, and on Windows types nothing with AltGr', () => {
for (const platform of ['win32', 'darwin', 'linux']) expect(shortcutProblem(showShortcutDefault(platform), platform)).toBeNull();
expect(showShortcutDefault('win32')).toBe('CommandOrControl+Alt+F11');
expect(showShortcutDefault('linux')).toBe('CommandOrControl+Alt+B');
});
});
+16 -1
View File
@@ -10,6 +10,15 @@
*/
export const QUICK_COMPOSE_DEFAULT = 'CommandOrControl+Alt+N';
/**
* What "Show the app with a keyboard shortcut" takes when switched on: Ctrl+Alt+B (⌥⌘B), and
* on Windows Ctrl+Alt+F11, since Ctrl+Alt is AltGr there and B types on many layouts. The
* shell's showShortcutBuiltIn, which also moves anyone still on the old Ctrl+Shift+B to it.
*/
export function showShortcutDefault(platform: string | undefined): string {
return platform === 'win32' ? 'CommandOrControl+Alt+F11' : 'CommandOrControl+Alt+B';
}
const MAC_SYMBOLS: Record<string, string> = {
control: '⌃',
ctrl: '⌃',
@@ -67,6 +76,12 @@ export interface ShortcutKeyEvent {
* shell's. `cmd` is Cmd on a Mac and Ctrl elsewhere.
*/
const RESERVED = new Set(['cmd+shift+A', 'cmd+shift+C', 'cmd+shift+V', 'cmd+shift+X', 'cmd+shift+Z']);
/**
* The app's own keys with two modifiers, which a global key would take from Bulwark itself:
* the composer's Cc, Bcc and schedule send, and the menus' new message, new window, Save as
* PDF, reply all and forward. Same list as APP_KEYS in the shell's os-rules.ts.
*/
const APP_KEYS = new Set(['cmd+shift+B', 'cmd+shift+C', 'cmd+shift+ENTER', 'cmd+shift+M', 'cmd+shift+N', 'cmd+shift+S', 'cmd+shift+R', 'cmd+shift+F']);
const RESERVED_ON_MAC = new Set([
'cmd+shift+3', 'cmd+shift+4', 'cmd+shift+5', 'cmd+shift+Q',
'cmd+ctrl+Q', 'cmd+ctrl+F',
@@ -119,7 +134,7 @@ export function shortcutProblem(accelerator: string, platform: string | undefine
if (modifiers.has('super')) return 'reserved';
if (modifiers.size < 2 || !(modifiers.has('cmd') || modifiers.has('ctrl'))) return 'incomplete';
const named = `${[...modifiers].sort().join('+')}+${key}`;
return RESERVED.has(named) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null;
return RESERVED.has(named) || APP_KEYS.has(`${[...modifiers].sort().join('+')}+${key.toUpperCase()}`) || (mac && RESERVED_ON_MAC.has(named)) ? 'reserved' : null;
}
/**