| ... | ... | @@ -7,12 +7,14 @@ import * as Fiber from "effect/Fiber"; |
| 7 | 7 | import * as Layer from "effect/Layer"; |
| 8 | 8 | import * as Logger from "effect/Logger"; |
| 9 | 9 | import * as Option from "effect/Option"; |
| 10 | import * as Queue from "effect/Queue"; |
| 10 | 11 | import * as Ref from "effect/Ref"; |
| 11 | 12 | import * as References from "effect/References"; |
| 12 | 13 | import * as Schema from "effect/Schema"; |
| 13 | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | 15 | |
| 15 | 16 | import * as Electron from "electron"; |
| 17 | import * as NodeEvents from "node:events"; |
| 16 | 18 | import { vi } from "vite-plus/test"; |
| 17 | 19 | |
| 18 | 20 | vi.mock("electron", async (importOriginal) => ({ |
| ... | ... | @@ -71,6 +73,8 @@ function makeFakeBrowserWindow() { |
| 71 | 73 | let zoomLevel = 0; |
| 72 | 74 | const webContents = { |
| 73 | 75 | copyImageAt: vi.fn(), |
| 76 | focus: vi.fn(), |
| 77 | isDestroyed: vi.fn(() => false), |
| 74 | 78 | getURL: vi.fn(() => "t3code-dev://app/"), |
| 75 | 79 | getZoomLevel: vi.fn(() => zoomLevel), |
| 76 | 80 | setZoomLevel: vi.fn((level: number) => { |
| ... | ... | @@ -214,6 +218,8 @@ function makeTestLayer(input: { |
| 214 | 218 | bounds: DesktopAppSettings.DesktopWindowBounds, |
| 215 | 219 | ) => Effect.Effect<void>; |
| 216 | 220 | readonly openedExternalUrls?: unknown[]; |
| 221 | readonly copiedTexts?: string[]; |
| 222 | readonly onPopupTemplate?: (input: ElectronMenu.ElectronMenuTemplateInput) => Effect.Effect<void>; |
| 217 | 223 | readonly previewZoomReapplies?: number[]; |
| 218 | 224 | readonly onReveal?: (window: Electron.BrowserWindow) => void; |
| 219 | 225 | }) { |
| ... | ... | @@ -281,7 +287,11 @@ function makeTestLayer(input: { |
| 281 | 287 | desktopServerExposureLayer, |
| 282 | 288 | DesktopState.layer, |
| 283 | 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 | 295 | Layer.succeed(ElectronShell.ElectronShell, { |
| 286 | 296 | openExternal: (url) => |
| 287 | 297 | Effect.sync(() => { |
| ... | ... | @@ -289,7 +299,10 @@ function makeTestLayer(input: { |
| 289 | 299 | return true; |
| 290 | 300 | }), |
| 291 | 301 | openSystemSettings: () => Effect.succeed(true), |
| 292 | | copyText: () => Effect.void, |
| 302 | copyText: (text) => |
| 303 | Effect.sync(() => { |
| 304 | input.copiedTexts?.push(text); |
| 305 | }), |
| 293 | 306 | } satisfies ElectronShell.ElectronShell["Service"]), |
| 294 | 307 | electronThemeLayer, |
| 295 | 308 | electronWindowLayer, |
| ... | ... | @@ -412,6 +425,120 @@ const captureOne = DesktopSnapShotId.make("11111111-1111-4111-8111-111111111111" |
| 412 | 425 | const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); |
| 413 | 426 | |
| 414 | 427 | describe("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 | 542 | it("leaves fullscreen before concealing a pending quit", () => { |
| 416 | 543 | const fakeWindow = makeFakeBrowserWindow(); |
| 417 | 544 | |