diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts new file mode 100644 index 00000000000..b4c26252770 --- /dev/null +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { nativeKeybindingCaptureInput } from "./NativeKeybindingCapture.ts"; + +describe("nativeKeybindingCaptureInput", () => { + it("forwards Command-Escape with its modifiers", () => { + expect( + nativeKeybindingCaptureInput({ + type: "keyDown", + key: "Escape", + meta: true, + control: false, + alt: false, + shift: true, + }), + ).toEqual({ + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: true, + }); + }); + + it.each([ + ["bare Escape", { type: "keyDown", key: "Escape", meta: false }], + ["Command keyup", { type: "keyUp", key: "Escape", meta: true }], + ["another Command shortcut", { type: "keyDown", key: "k", meta: true }], + ])("ignores %s", (_name, input) => { + expect( + nativeKeybindingCaptureInput({ + control: false, + alt: false, + shift: false, + ...input, + }), + ).toBeNull(); + }); +}); diff --git a/apps/desktop/src/keybindings/NativeKeybindingCapture.ts b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts new file mode 100644 index 00000000000..b920c4a15e0 --- /dev/null +++ b/apps/desktop/src/keybindings/NativeKeybindingCapture.ts @@ -0,0 +1,62 @@ +import type { Input } from "electron"; + +export const NATIVE_KEYBINDING_CAPTURE_CHANNEL = "desktop:native-keybinding-capture"; + +export interface NativeKeybindingCaptureInput { + readonly key: "Escape"; + readonly metaKey: true; + readonly ctrlKey: boolean; + readonly altKey: boolean; + readonly shiftKey: boolean; +} + +export function nativeKeybindingCaptureInput( + input: Pick, +): NativeKeybindingCaptureInput | null { + const key = input.key.toLowerCase(); + if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !input.meta) { + return null; + } + + return { + key: "Escape", + metaKey: true, + ctrlKey: input.control, + altKey: input.alt, + shiftKey: input.shift, + }; +} + +export function dispatchNativeKeybindingCaptureInput(input: unknown): void { + if ( + typeof input !== "object" || + input === null || + !("key" in input) || + input.key !== "Escape" || + !("metaKey" in input) || + input.metaKey !== true || + !("ctrlKey" in input) || + typeof input.ctrlKey !== "boolean" || + !("altKey" in input) || + typeof input.altKey !== "boolean" || + !("shiftKey" in input) || + typeof input.shiftKey !== "boolean" + ) { + return; + } + + const target = document.activeElement ?? document; + + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: input.key, + code: "Escape", + metaKey: input.metaKey, + ctrlKey: input.ctrlKey, + altKey: input.altKey, + shiftKey: input.shiftKey, + bubbles: true, + cancelable: true, + }), + ); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..87fb0ed57a3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -8,9 +8,17 @@ import { exposeClerkBridge } from "@clerk/electron/preload"; import { contextBridge, ipcRenderer } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; +import { + dispatchNativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "./keybindings/NativeKeybindingCapture.ts"; exposeClerkBridge({ passkeys: true }); +ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => { + dispatchNativeKeybindingCaptureInput(input); +}); + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f1215ee7b60..a57be278e7f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -16,6 +16,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { NATIVE_KEYBINDING_CAPTURE_CHANNEL } from "../keybindings/NativeKeybindingCapture.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; @@ -260,6 +261,27 @@ describe("PreviewManager", () => { expect(loadURL).toHaveBeenCalledOnce(); expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/"); + + const beforeInputEvent = { preventDefault: vi.fn() }; + listeners.get("before-input-event")?.( + beforeInputEvent as never, + { + type: "keyDown", + key: "Escape", + meta: true, + control: false, + alt: false, + shift: false, + } as never, + ); + expect(webviewSend).toHaveBeenCalledWith(NATIVE_KEYBINDING_CAPTURE_CHANNEL, { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }); + expect(beforeInputEvent.preventDefault).toHaveBeenCalledOnce(); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 6c942d4ccb9..15a436c17a8 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -53,6 +53,10 @@ import * as Scope from "effect/Scope"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { + nativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "../keybindings/NativeKeybindingCapture.ts"; import * as BrowserSession from "./BrowserSession.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -1229,6 +1233,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + const captureInput = nativeKeybindingCaptureInput(input); + if (captureInput) { + event.preventDefault(); + wc.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); + } runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 2654b898102..31b8f879259 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -1,5 +1,10 @@ // @effect-diagnostics globalDate:off - This isolated Electron preload does not run inside an Effect runtime. import { ipcRenderer } from "electron"; + +import { + dispatchNativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "../keybindings/NativeKeybindingCapture.ts"; import { getElementContext } from "react-grab/primitives"; import type { DesktopPreviewAnnotationTheme, @@ -22,6 +27,10 @@ import { HUMAN_INPUT_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; + +ipcRenderer.on(NATIVE_KEYBINDING_CAPTURE_CHANNEL, (_event, input: unknown) => { + dispatchNativeKeybindingCaptureInput(input); +}); const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const Z_INDEX_OVERLAY = 2147483646; const PRIMARY = "var(--t3-primary)"; diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..91a9efefc0a 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -14,8 +14,25 @@ import * as TestClock from "effect/testing/TestClock"; import * as Electron from "electron"; import { vi } from "vite-plus/test"; +const { + globalShortcutIsRegistered, + globalShortcutRegister, + globalShortcutUnregister, + getFocusedWebContents, +} = vi.hoisted(() => ({ + globalShortcutIsRegistered: vi.fn<(accelerator: string) => boolean>(() => false), + globalShortcutRegister: vi.fn<(accelerator: string, callback: () => void) => boolean>(() => true), + globalShortcutUnregister: vi.fn<(accelerator: string) => void>(), + getFocusedWebContents: vi.fn<() => Electron.WebContents | null>(() => null), +})); + vi.mock("electron", async (importOriginal) => ({ ...(await importOriginal()), + globalShortcut: { + isRegistered: globalShortcutIsRegistered, + register: globalShortcutRegister, + unregister: globalShortcutUnregister, + }, session: { fromPartition: vi.fn(() => ({ getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/1.2.3"), @@ -30,6 +47,9 @@ vi.mock("electron", async (importOriginal) => ({ }, ]), }, + webContents: { + getFocusedWebContents, + }, })); import * as DesktopAssets from "../app/DesktopAssets.ts"; @@ -42,6 +62,7 @@ import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { NATIVE_KEYBINDING_CAPTURE_CHANNEL } from "../keybindings/NativeKeybindingCapture.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; @@ -432,6 +453,35 @@ describe("DesktopWindow", () => { assert.deepEqual(fakeWindow.setAutoHideCursor.mock.calls, [[false]]); assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); assert.equal(fakeWindow.openDevTools.mock.calls.length, 1); + + const focusedWebContents = { + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }; + getFocusedWebContents.mockReturnValue( + focusedWebContents as unknown as Electron.WebContents, + ); + fakeWindow.windowListeners.get("focus")?.(); + const registration = globalShortcutRegister.mock.calls.at(-1); + if (!registration) { + assert.fail("expected Command-Escape to be registered"); + } + assert.equal(registration[0], "Command+Escape"); + registration[1](); + assert.deepEqual(focusedWebContents.send.mock.calls, [ + [ + NATIVE_KEYBINDING_CAPTURE_CHANNEL, + { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + ], + ]); + fakeWindow.windowListeners.get("blur")?.(); + assert.deepEqual(globalShortcutUnregister.mock.calls.at(-1), ["Command+Escape"]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..fe6053c85bb 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -18,6 +18,11 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { + type NativeKeybindingCaptureInput, + nativeKeybindingCaptureInput, + NATIVE_KEYBINDING_CAPTURE_CHANNEL, +} from "../keybindings/NativeKeybindingCapture.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -25,6 +30,14 @@ const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; const MAIN_WINDOW_BOUNDS_PERSIST_DEBOUNCE_MS = 500; const DEVELOPMENT_LOAD_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const; +const MACOS_MOD_ESCAPE_ACCELERATOR = "Command+Escape"; +const MACOS_MOD_ESCAPE_INPUT: NativeKeybindingCaptureInput = { + key: "Escape", + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, +}; const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([ -2, // ERR_FAILED -7, // ERR_TIMED_OUT @@ -341,6 +354,36 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { window.setAutoHideCursor(false); } + let unregisterNativeModEscape = () => {}; + if (environment.platform === "darwin") { + const registerNativeModEscape = () => { + if (Electron.globalShortcut.isRegistered(MACOS_MOD_ESCAPE_ACCELERATOR)) { + return; + } + const registered = Electron.globalShortcut.register(MACOS_MOD_ESCAPE_ACCELERATOR, () => { + const focusedWebContents = Electron.webContents.getFocusedWebContents(); + if (focusedWebContents && !focusedWebContents.isDestroyed()) { + focusedWebContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, MACOS_MOD_ESCAPE_INPUT); + } + }); + if (!registered) { + void runPromise(logWindowWarning("failed to register Command-Escape shortcut")); + } + }; + unregisterNativeModEscape = () => { + Electron.globalShortcut.unregister(MACOS_MOD_ESCAPE_ACCELERATOR); + }; + window.on("focus", registerNativeModEscape); + window.on("blur", unregisterNativeModEscape); + } else { + window.webContents.on("before-input-event", (event, input) => { + const captureInput = nativeKeybindingCaptureInput(input); + if (captureInput) { + event.preventDefault(); + window.webContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, captureInput); + } + }); + } let boundsPersistFiber: Fiber.Fiber | undefined; let pendingBoundsPersistFiber: Fiber.Fiber | undefined; let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds; @@ -644,6 +687,7 @@ export const make = Effect.gen(function* () { } window.on("closed", () => { + unregisterNativeModEscape(); clearDevelopmentLoadRetry(); clearBoundsPersist(); void runPromise(electronWindow.clearMain(Option.some(window))); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 75235a05307..62b6d74c191 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -64,6 +64,38 @@ describe("KeybindingsSettings.logic", () => { ).toBe("mod+shift+k"); }); + it.each([ + ["MacIntel", { metaKey: true, ctrlKey: false }], + ["Win32", { metaKey: false, ctrlKey: true }], + ])("captures modified Escape as mod+esc on %s", (platform, modifiers) => { + expect( + keybindingFromKeyboardEvent( + { + key: "Escape", + ...modifiers, + altKey: false, + shiftKey: false, + }, + platform, + ), + ).toBe("mod+esc"); + }); + + it("leaves unmodified Escape available to cancel keybinding capture", () => { + expect( + keybindingFromKeyboardEvent( + { + key: "Escape", + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + "MacIntel", + ), + ).toBeNull(); + }); + it("serializes shortcuts and when expressions for upserts", () => { expect( shortcutToKeybindingInput({ diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 67c33c9b942..7c2880fbafa 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -803,11 +803,11 @@ function KeybindingTableRow({ const captureKeybinding = (event: KeyboardEvent) => { if (event.key === "Tab") return; event.preventDefault(); - if (event.key === "Escape") { + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (!next && event.key === "Escape") { setDraft({ keyDraft: row.key, isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); }; @@ -846,8 +846,8 @@ function KeybindingTableRow({ ) : ( ) => { if (event.key === "Tab") return; event.preventDefault(); - if (event.key === "Escape") { + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (!next && event.key === "Escape") { setDraft({ keyDraft: "", isRecording: false }); return; } - const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; setDraft({ keyDraft: next, isRecording: false }); };