authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 21:56:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 21:56:52-07:00
logb5f7fa0ede2a0d791226e6474c9cc4374dd89cc9
tree889211657c9b937e4d60ed6c11bb6f68299b05c4
parentbc4b00666272188027d086361fa5ae01cda53bd2
signature Signed by PGP key B5690EEEBB952194

fix(desktop): enable context menus in the browser (#10670)


4 files changed, 200 insertions(+), 39 deletions(-)

apps/desktop/src/electron/ElectronMenu.test.ts+3
...@@ -154,9 +154,11 @@ describe("ElectronMenu", () => {...@@ -154,9 +154,11 @@ describe("ElectronMenu", () => {
154 buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock }));154 buildFromTemplateMock.mockImplementation(() => ({ popup: popupMock }));
155155
156 const electronMenu = yield* ElectronMenu.ElectronMenu;156 const electronMenu = yield* ElectronMenu.ElectronMenu;
157 const frame = { routingId: 7 } as Electron.WebFrameMain;
157 const popup = electronMenu.popupTemplate({158 const popup = electronMenu.popupTemplate({
158 window: {} as Electron.BrowserWindow,159 window: {} as Electron.BrowserWindow,
159 template: [{ label: "Copy" }],160 template: [{ label: "Copy" }],
161 frame,
160 });162 });
161163
162 assert.equal(buildFromTemplateMock.mock.calls.length, 0);164 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
...@@ -166,6 +168,7 @@ describe("ElectronMenu", () => {...@@ -166,6 +168,7 @@ describe("ElectronMenu", () => {
166168
167 assert.equal(buildFromTemplateMock.mock.calls.length, 1);169 assert.equal(buildFromTemplateMock.mock.calls.length, 1);
168 assert.equal(popupMock.mock.calls.length, 1);170 assert.equal(popupMock.mock.calls.length, 1);
171 assert.strictEqual(popupMock.mock.calls[0]?.[0].frame, frame);
169 }).pipe(Effect.provide(TestLayer)),172 }).pipe(Effect.provide(TestLayer)),
170 );173 );
171174
apps/desktop/src/electron/ElectronMenu.ts+2
...@@ -22,6 +22,7 @@ export interface ElectronMenuContextInput {...@@ -22,6 +22,7 @@ export interface ElectronMenuContextInput {
22export interface ElectronMenuTemplateInput {22export interface ElectronMenuTemplateInput {
23 readonly window: Electron.BrowserWindow;23 readonly window: Electron.BrowserWindow;
24 readonly template: readonly Electron.MenuItemConstructorOptions[];24 readonly template: readonly Electron.MenuItemConstructorOptions[];
25 readonly frame?: Electron.WebFrameMain;
25}26}
2627
27const ElectronMenuOperation = Schema.Literals([28const ElectronMenuOperation = Schema.Literals([
...@@ -208,6 +209,7 @@ export const make = Effect.gen(function* () {...@@ -208,6 +209,7 @@ export const make = Effect.gen(function* () {
208 try: () =>209 try: () =>
209 Electron.Menu.buildFromTemplate([...input.template]).popup({210 Electron.Menu.buildFromTemplate([...input.template]).popup({
210 window: input.window,211 window: input.window,
212 ...(input.frame ? { frame: input.frame } : {}),
211 }),213 }),
212 catch: (cause) =>214 catch: (cause) =>
213 new ElectronMenuOperationError({215 new ElectronMenuOperationError({
apps/desktop/src/window/DesktopWindow.test.ts+129-2
...@@ -7,12 +7,14 @@ import * as Fiber from "effect/Fiber";...@@ -7,12 +7,14 @@ import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";8import * as Logger from "effect/Logger";
9import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";
10import * as Ref from "effect/Ref";11import * as Ref from "effect/Ref";
11import * as References from "effect/References";12import * as References from "effect/References";
12import * as Schema from "effect/Schema";13import * as Schema from "effect/Schema";
13import * as TestClock from "effect/testing/TestClock";14import * as TestClock from "effect/testing/TestClock";
1415
15import * as Electron from "electron";16import * as Electron from "electron";
17import * as NodeEvents from "node:events";
16import { vi } from "vite-plus/test";18import { vi } from "vite-plus/test";
1719
18vi.mock("electron", async (importOriginal) => ({20vi.mock("electron", async (importOriginal) => ({
...@@ -71,6 +73,8 @@ function makeFakeBrowserWindow() {...@@ -71,6 +73,8 @@ function makeFakeBrowserWindow() {
71 let zoomLevel = 0;73 let zoomLevel = 0;
72 const webContents = {74 const webContents = {
73 copyImageAt: vi.fn(),75 copyImageAt: vi.fn(),
76 focus: vi.fn(),
77 isDestroyed: vi.fn(() => false),
74 getURL: vi.fn(() => "t3code-dev://app/"),78 getURL: vi.fn(() => "t3code-dev://app/"),
75 getZoomLevel: vi.fn(() => zoomLevel),79 getZoomLevel: vi.fn(() => zoomLevel),
76 setZoomLevel: vi.fn((level: number) => {80 setZoomLevel: vi.fn((level: number) => {
...@@ -214,6 +218,8 @@ function makeTestLayer(input: {...@@ -214,6 +218,8 @@ function makeTestLayer(input: {
214 bounds: DesktopAppSettings.DesktopWindowBounds,218 bounds: DesktopAppSettings.DesktopWindowBounds,
215 ) => Effect.Effect<void>;219 ) => Effect.Effect<void>;
216 readonly openedExternalUrls?: unknown[];220 readonly openedExternalUrls?: unknown[];
221 readonly copiedTexts?: string[];
222 readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect<void>;
217 readonly previewZoomReapplies?: number[];223 readonly previewZoomReapplies?: number[];
218 readonly onReveal?: (window: Electron.BrowserWindow) => void;224 readonly onReveal?: (window: Electron.BrowserWindow) => void;
219}) {225}) {
...@@ -281,7 +287,11 @@ function makeTestLayer(input: {...@@ -281,7 +287,11 @@ function makeTestLayer(input: {
281 desktopServerExposureLayer,287 desktopServerExposureLayer,
282 DesktopState.layer,288 DesktopState.layer,
283 electronAppLayer,289 electronAppLayer,
284 electronMenuLayer,290 Layer.succeed(ElectronMenu.ElectronMenu, {
291 setApplicationMenu: () => Effect.void,
292 showContextMenu: () => Effect.succeed(Option.none()),
293 popupTemplate: input.onPopupTemplate ?? (() => Effect.void),
294 }),
285 Layer.succeed(ElectronShell.ElectronShell, {295 Layer.succeed(ElectronShell.ElectronShell, {
286 openExternal: (url) =>296 openExternal: (url) =>
287 Effect.sync(() => {297 Effect.sync(() => {
...@@ -289,7 +299,10 @@ function makeTestLayer(input: {...@@ -289,7 +299,10 @@ function makeTestLayer(input: {
289 return true;299 return true;
290 }),300 }),
291 openSystemSettings: () => Effect.succeed(true),301 openSystemSettings: () => Effect.succeed(true),
292 copyText: () => Effect.void,302 copyText: (text) =>
303 Effect.sync(() => {
304 input.copiedTexts?.push(text);
305 }),
293 } satisfies ElectronShell.ElectronShell["Service"]),306 } satisfies ElectronShell.ElectronShell["Service"]),
294 electronThemeLayer,307 electronThemeLayer,
295 electronWindowLayer,308 electronWindowLayer,
...@@ -412,6 +425,120 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111"...@@ -412,6 +425,120 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111"
412const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222");425const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222");
413426
414describe("DesktopWindow", () => {427describe("DesktopWindow", () => {
428 it.effect("shows native context menus for browser guests and sign-in popups", () =>
429 Effect.gen(function* () {
430 const host = makeFakeBrowserWindow();
431 const popup = makeFakeBrowserWindow();
432 let focusedContents: unknown = host.window.webContents;
433 const makeContents = () => {
434 const contents = Object.assign(new NodeEvents.EventEmitter(), {
435 isDestroyed: vi.fn(() => false),
436 focus: vi.fn(() => {
437 focusedContents = contents;
438 }),
439 copyImageAt: vi.fn(),
440 replaceMisspelling: vi.fn(),
441 });
442 return contents;
443 };
444 const guest = makeContents();
445 const popupContents = makeContents();
446 const popupWindow = { ...popup.window, webContents: popupContents };
447 const menus = yield* Queue.unbounded<{
448 input: ElectronMenu.ElectronMenuTemplateInput;
449 focusedContents: unknown;
450 }>();
451 const copiedTexts: string[] = [];
452 const layer = makeTestLayer({
453 window: host.window,
454 createCount: yield* Ref.make(0),
455 mainWindow: yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()),
456 copiedTexts,
457 onPopupTemplate: (input) =>
458 Queue.offer(menus, { input, focusedContents }).pipe(Effect.asVoid),
459 });
460
461 yield* Effect.gen(function* () {
462 const desktopWindow = yield* DesktopWindow.DesktopWindow;
463 yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
464 const attach = host.webContentsListeners.get("did-attach-webview");
465 assert.isDefined(attach);
466 attach({}, guest);
467 attach({}, guest);
468 guest.emit("did-create-window", popupWindow);
469 guest.emit("did-create-window", popupWindow);
470
471 for (const [contents, owner] of [
472 [guest, host.window],
473 [popupContents, popupWindow],
474 ] as const) {
475 const frame = { routingId: 7 } as Electron.WebFrameMain;
476 const preventDefault = vi.fn();
477 const params = {
478 frame,
479 x: 12,
480 y: 34,
481 misspelledWord: "helo",
482 dictionarySuggestions: ["hello"],
483 linkURL: "",
484 mediaType: "none",
485 editFlags: { canCut: false, canCopy: true, canPaste: true, canSelectAll: true },
486 };
487 focusedContents = host.window.webContents;
488 contents.emit("context-menu", { preventDefault }, params);
489 const menu = yield* Queue.take(menus);
490 assert.strictEqual(menu.input.window, owner);
491 assert.strictEqual(menu.input.frame, frame);
492 assert.strictEqual(menu.focusedContents, contents);
493 assert.equal(preventDefault.mock.calls.length, 1);
494 assert.deepEqual(
495 menu.input.template.filter((item) => item.role),
496 [
497 { role: "cut", enabled: false },
498 { role: "copy", enabled: true },
499 { role: "paste", enabled: true },
500 { role: "selectAll", enabled: true },
501 ],
502 );
503 const correction = menu.input.template.find((item) => item.label === "hello");
504 assert.isDefined(correction?.click);
505 correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
506 assert.deepEqual(contents.replaceMisspelling.mock.calls, [["hello"]]);
507
508 contents.emit(
509 "context-menu",
510 { preventDefault },
511 {
512 ...params,
513 frame: null,
514 misspelledWord: "",
515 dictionarySuggestions: [],
516 mediaType: "image",
517 linkURL: "https://example.com/image.png",
518 },
519 );
520 const imageMenu = (yield* Queue.take(menus)).input;
521 assert.isUndefined(imageMenu.frame);
522 const copyImage = imageMenu.template.find((item) => item.label === "Copy Image");
523 const copyLink = imageMenu.template.find((item) => item.label === "Copy Link");
524 assert.isDefined(copyImage?.click);
525 assert.isDefined(copyLink?.click);
526 copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
527 copyLink.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
528 assert.deepEqual(contents.copyImageAt.mock.calls, [[12, 34]]);
529 assert.equal(copiedTexts.at(-1), "https://example.com/image.png");
530
531 contents.isDestroyed.mockReturnValue(true);
532 correction.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
533 copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
534 assert.equal(contents.replaceMisspelling.mock.calls.length, 1);
535 assert.equal(contents.copyImageAt.mock.calls.length, 1);
536 assert.equal(yield* Queue.size(menus), 0);
537 }
538 }).pipe(Effect.provide(layer));
539 }),
540 );
541
415 it("leaves fullscreen before concealing a pending quit", () => {542 it("leaves fullscreen before concealing a pending quit", () => {
416 const fakeWindow = makeFakeBrowserWindow();543 const fakeWindow = makeFakeBrowserWindow();
417544
apps/desktop/src/window/DesktopWindow.ts+66-37
...@@ -505,52 +505,81 @@ export const make = Effect.gen(function* () {...@@ -505,52 +505,81 @@ export const make = Effect.gen(function* () {
505 webPreferences.contextIsolation = false;505 webPreferences.contextIsolation = false;
506 });506 });
507507
508 window.webContents.on("context-menu", (event, params) => {508 const contextMenuContents = new WeakSet<Electron.WebContents>();
509 event.preventDefault();509 const installContextMenu = (
510 ownerWindow: Electron.BrowserWindow,
511 contents: Electron.WebContents,
512 ): void => {
513 if (contextMenuContents.has(contents)) return;
514 contextMenuContents.add(contents);
515 contents.on("context-menu", (event, params) => {
516 event.preventDefault();
517 if (contents.isDestroyed() || ownerWindow.isDestroyed()) return;
518 // Native editing roles act on the focused contents, which may still be
519 // the host renderer when the user right-clicks inside a browser guest.
520 contents.focus();
521
522 const menuTemplate: Electron.MenuItemConstructorOptions[] = [];
523
524 if (params.misspelledWord) {
525 for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
526 menuTemplate.push({
527 label: suggestion,
528 click: () => {
529 if (!contents.isDestroyed()) contents.replaceMisspelling(suggestion);
530 },
531 });
532 }
533 if (params.dictionarySuggestions.length === 0) {
534 menuTemplate.push({ label: "No suggestions", enabled: false });
535 }
536 menuTemplate.push({ type: "separator" });
537 }
510538
511 const menuTemplate: Electron.MenuItemConstructorOptions[] = [];539 if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
540 menuTemplate.push(
541 {
542 label: "Copy Link",
543 click: () => {
544 void runPromise(electronShell.copyText(params.linkURL));
545 },
546 },
547 { type: "separator" },
548 );
549 }
512550
513 if (params.misspelledWord) {551 if (params.mediaType === "image") {
514 for (const suggestion of params.dictionarySuggestions.slice(0, 5)) {
515 menuTemplate.push({552 menuTemplate.push({
516 label: suggestion,553 label: "Copy Image",
517 click: () => window.webContents.replaceMisspelling(suggestion),554 click: () => {
555 if (!contents.isDestroyed()) contents.copyImageAt(params.x, params.y);
556 },
518 });557 });
558 menuTemplate.push({ type: "separator" });
519 }559 }
520 if (params.dictionarySuggestions.length === 0) {
521 menuTemplate.push({ label: "No suggestions", enabled: false });
522 }
523 menuTemplate.push({ type: "separator" });
524 }
525560
526 if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
527 menuTemplate.push(561 menuTemplate.push(
528 {562 { role: "cut", enabled: params.editFlags.canCut },
529 label: "Copy Link",563 { role: "copy", enabled: params.editFlags.canCopy },
530 click: () => {564 { role: "paste", enabled: params.editFlags.canPaste },
531 void runPromise(electronShell.copyText(params.linkURL));565 { role: "selectAll", enabled: params.editFlags.canSelectAll },
532 },
533 },
534 { type: "separator" },
535 );566 );
536 }
537
538 if (params.mediaType === "image") {
539 menuTemplate.push({
540 label: "Copy Image",
541 click: () => window.webContents.copyImageAt(params.x, params.y),
542 });
543 menuTemplate.push({ type: "separator" });
544 }
545
546 menuTemplate.push(
547 { role: "cut", enabled: params.editFlags.canCut },
548 { role: "copy", enabled: params.editFlags.canCopy },
549 { role: "paste", enabled: params.editFlags.canPaste },
550 { role: "selectAll", enabled: params.editFlags.canSelectAll },
551 );
552567
553 void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));568 void runPromise(
569 electronMenu.popupTemplate({
570 window: ownerWindow,
571 template: menuTemplate,
572 ...(params.frame ? { frame: params.frame } : {}),
573 }),
574 );
575 });
576 contents.on("did-create-window", (popup) => {
577 installContextMenu(popup, popup.webContents);
578 });
579 };
580 installContextMenu(window, window.webContents);
581 window.webContents.on("did-attach-webview", (_event, contents) => {
582 installContextMenu(window, contents);
554 });583 });
555584
556 window.webContents.setWindowOpenHandler(({ url }) => {585 window.webContents.setWindowOpenHandler(({ url }) => {