| ... | @@ -7,12 +7,14 @@ import * as Fiber from "effect/Fiber"; | ... | @@ -7,12 +7,14 @@ import * as Fiber from "effect/Fiber"; |
| 7 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Logger from "effect/Logger"; | 8 | import * as Logger from "effect/Logger"; |
| 9 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| | 10 | import * as Queue from "effect/Queue"; |
| 10 | import * as Ref from "effect/Ref"; | 11 | import * as Ref from "effect/Ref"; |
| 11 | import * as References from "effect/References"; | 12 | import * as References from "effect/References"; |
| 12 | import * as Schema from "effect/Schema"; | 13 | import * as Schema from "effect/Schema"; |
| 13 | import * as TestClock from "effect/testing/TestClock"; | 14 | import * as TestClock from "effect/testing/TestClock"; |
| 14 | | 15 | |
| 15 | import * as Electron from "electron"; | 16 | import * as Electron from "electron"; |
| | 17 | import * as NodeEvents from "node:events"; |
| 16 | import { vi } from "vite-plus/test"; | 18 | import { vi } from "vite-plus/test"; |
| 17 | | 19 | |
| 18 | vi.mock("electron", async (importOriginal) => ({ | 20 | vi.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" |
| 412 | const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); | 425 | const captureTwo = DesktopSnapShotId.make("22222222-2222-4222-8222-222222222222"); |
| 413 | | 426 | |
| 414 | describe("DesktopWindow", () => { | 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 | 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(); |
| 417 | | 544 | |