Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/desktop/src/keybindings/NativeKeybindingCapture.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 62 additions & 0 deletions apps/desktop/src/keybindings/NativeKeybindingCapture.ts
Original file line number Diff line number Diff line change
@@ -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<Input, "type" | "key" | "meta" | "control" | "alt" | "shift">,
): NativeKeybindingCaptureInput | null {
const key = input.key.toLowerCase();
if (input.type !== "keyDown" || (key !== "escape" && key !== "esc") || !input.meta) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Capture Ctrl-Escape for non-Mac mod bindings

On non-Mac platforms mod is derived from ctrlKey in keybindingFromKeyboardEvent, but both desktop before-input-event paths call this helper, which only accepts input.meta. In the Windows/Linux scenario where Ctrl+Escape needs the native bridge instead of a normal renderer keydown, the helper returns null, while the only synthesized chord would be Meta+Escape and record/run as meta+esc rather than the newly allowed mod+esc; pass the platform or accept control for non-Darwin callers.

Useful? React with 👍 / 👎.

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,
}),
);
}
8 changes: 8 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
}),
),
);
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward preview Command-Escape back to the app

When a preview webview owns focus, this sends the synthetic Escape keydown into the guest page, but T3 app shortcuts are resolved by the parent renderer's window keydown listener (apps/web/src/routes/_chat.tsx) and the existing preview shortcut bridge forwards app shortcuts with mainWindow.value.webContents.sendInputEvent. As a result, a user-assigned mod+esc command such as preview refresh/toggle will not run while focus is inside the preview, even though the commit explicitly tries to support focused preview webviews; route this captured chord to the main window as an app shortcut rather than only to wc.

Useful? React with 👍 / 👎.

}
runFork(forwardShortcut(event, input));
};
yield* Scope.addFinalizer(
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preview/PickPreload.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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)";
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("electron")>()),
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"),
Expand All @@ -30,6 +47,9 @@ vi.mock("electron", async (importOriginal) => ({
},
]),
},
webContents: {
getFocusedWebContents,
},
}));

import * as DesktopAssets from "../app/DesktopAssets.ts";
Expand All @@ -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";
Expand Down Expand Up @@ -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));
}),
);
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,26 @@ 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
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,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS shortcut drops modifier keys

Medium Severity

On macOS, the global shortcut for Command+Escape always sends a fixed input payload that incorrectly sets shiftKey, altKey, and ctrlKey to false. This means any additional modifiers pressed with Command+Escape are ignored, leading to incorrect keybinding capture compared to other platforms.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 70f663e. Configure here.

const DEVELOPMENT_RETRYABLE_LOAD_ERROR_CODES = new Set([
-2, // ERR_FAILED
-7, // ERR_TIMED_OUT
Expand Down Expand Up @@ -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, () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bridge macOS Escape chords with extra modifiers

On macOS this registers only the exact Command+Escape accelerator and the callback always emits the MACOS_MOD_ESCAPE_INPUT payload with ctrl/alt/shift false. The recorder now accepts and serializes extra modifiers on Escape, so Cmd+Shift+Esc/Cmd+Option+Esc keybindings can be saved but never captured or triggered on the platform that needs this native bridge; register and dispatch each modifier variant, or capture the actual flags, instead of hard-coding the unmodified chord.

Useful? React with 👍 / 👎.

const focusedWebContents = Electron.webContents.getFocusedWebContents();
if (focusedWebContents && !focusedWebContents.isDestroyed()) {
focusedWebContents.send(NATIVE_KEYBINDING_CAPTURE_CHANNEL, MACOS_MOD_ESCAPE_INPUT);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cmd+Escape triggers bare Escape handlers

Medium Severity

While the main window is focused on macOS, every physical Command+Escape chord registers a global shortcut and forwards a synthetic keydown whose key is Escape. Several UI layers treat any Escape key as cancel/back (settings navigation, chat selection clear, preview pick teardown) without checking modifiers, so Command+Escape outside keybinding capture can trigger those actions unintentionally.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 70f663e. Configure here.

});
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<void, never> | undefined;
let pendingBoundsPersistFiber: Fiber.Fiber<void, never> | undefined;
let boundsPersistenceEnabled = persistedBounds === null || restoredPersistedBounds;
Expand Down Expand Up @@ -644,6 +687,7 @@ export const make = Effect.gen(function* () {
}

window.on("closed", () => {
unregisterNativeModEscape();
clearDevelopmentLoadRetry();
clearBoundsPersist();
void runPromise(electronWindow.clearMain(Option.some(window)));
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading