diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 4dd8066e3c6..3dc218d8252 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -1,5 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import type * as Electron from "electron"; import { beforeEach, vi } from "vite-plus/test"; @@ -24,6 +27,10 @@ vi.mock("electron", () => ({ import * as ElectronMenu from "./ElectronMenu.ts"; +const TestLayer = ElectronMenu.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), +); + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -42,7 +49,7 @@ describe("ElectronMenu", () => { assert.isTrue(Option.isNone(selectedItemId)); assert.equal(buildFromTemplateMock.mock.calls.length, 0); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("resolves with the clicked leaf item id", () => @@ -69,7 +76,7 @@ describe("ElectronMenu", () => { }); assert.equal(Option.getOrNull(selectedItemId), "copy"); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("resolves with none when the menu closes without a click", () => @@ -93,7 +100,7 @@ describe("ElectronMenu", () => { enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), ); it.effect("defers popupTemplate side effects until the returned Effect runs", () => @@ -114,6 +121,89 @@ describe("ElectronMenu", () => { assert.equal(buildFromTemplateMock.mock.calls.length, 1); assert.equal(popupMock.mock.calls.length, 1); - }).pipe(Effect.provide(ElectronMenu.layer)), + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves application-menu failures as structured defects", () => + Effect.gen(function* () { + const cause = new Error("application menu build failed"); + buildFromTemplateMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "set-application-menu"); + assert.equal(error.platform, "linux"); + assert.isNull(error.windowId); + assert.equal(error.itemCount, 2); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves popup-template failures with window context", () => + Effect.gen(function* () { + const cause = new Error("popup failed"); + buildFromTemplateMock.mockReturnValueOnce({ + popup: () => { + throw cause; + }, + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.popupTemplate({ + window: { id: 41 } as Electron.BrowserWindow, + template: [{ label: "Copy" }], + }), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "popup-template"); + assert.equal(error.windowId, 41); + assert.equal(error.itemCount, 1); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("preserves context-menu failures with normalized item context", () => + Effect.gen(function* () { + const cause = new Error("context menu build failed"); + buildFromTemplateMock.mockImplementationOnce(() => { + throw cause; + }); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + const exit = yield* Effect.exit( + electronMenu.showContextMenu({ + window: { id: 42 } as Electron.BrowserWindow, + items: [{ id: "copy", label: "Copy" }], + position: Option.none(), + }), + ); + + assert.equal(exit._tag, "Failure"); + if (exit._tag === "Failure") { + const error = Cause.squash(exit.cause); + assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError); + assert.equal(error.operation, "show-context-menu"); + assert.equal(error.windowId, 42); + assert.equal(error.itemCount, 1); + assert.strictEqual(error.cause, cause); + } + }).pipe(Effect.provide(TestLayer)), ); }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index d9eb3b22eff..09fb5d1807d 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -4,6 +4,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Electron from "electron"; @@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput { readonly template: readonly Electron.MenuItemConstructorOptions[]; } +const ElectronMenuOperation = Schema.Literals([ + "set-application-menu", + "popup-template", + "show-context-menu", +]); + +export class ElectronMenuOperationError extends Schema.TaggedErrorClass()( + "ElectronMenuOperationError", + { + operation: ElectronMenuOperation, + platform: Schema.String, + windowId: Schema.NullOr(Schema.Number), + itemCount: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const window = this.windowId === null ? "" : ` for window ${this.windowId}`; + return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`; + } +} + export class ElectronMenu extends Context.Service< ElectronMenu, { @@ -142,16 +165,36 @@ export const make = Effect.gen(function* () { return ElectronMenu.of({ setApplicationMenu: (template) => - Effect.sync(() => { - Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); - }), + Effect.try({ + try: () => { + Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template])); + }, + catch: (cause) => + new ElectronMenuOperationError({ + operation: "set-application-menu", + platform, + windowId: null, + itemCount: template.length, + cause, + }), + }).pipe(Effect.orDie), popupTemplate: (input) => - Effect.sync(() => { - if (input.template.length === 0) { - return; - } - Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window }); - }), + input.template.length === 0 + ? Effect.void + : Effect.try({ + try: () => + Electron.Menu.buildFromTemplate([...input.template]).popup({ + window: input.window, + }), + catch: (cause) => + new ElectronMenuOperationError({ + operation: "popup-template", + platform, + windowId: input.window.id, + itemCount: input.template.length, + cause, + }), + }).pipe(Effect.orDie), showContextMenu: (input) => Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); @@ -169,21 +212,39 @@ export const make = Effect.gen(function* () { resume(Effect.succeed(selectedItemId)); }; - const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); - const popupOptions = Option.match(popupPosition, { - onNone: (): Electron.PopupOptions => ({ - window: input.window, - callback: () => complete(Option.none()), - }), - onSome: (position): Electron.PopupOptions => ({ - window: input.window, - x: position.x, - y: position.y, - callback: () => complete(Option.none()), - }), - }); - menu.popup(popupOptions); + try { + const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); + const popupPosition = normalizePosition(input.position); + const popupOptions = Option.match(popupPosition, { + onNone: (): Electron.PopupOptions => ({ + window: input.window, + callback: () => complete(Option.none()), + }), + onSome: (position): Electron.PopupOptions => ({ + window: input.window, + x: position.x, + y: position.y, + callback: () => complete(Option.none()), + }), + }); + menu.popup(popupOptions); + } catch (cause) { + if (completed) { + return; + } + completed = true; + resume( + Effect.die( + new ElectronMenuOperationError({ + operation: "show-context-menu", + platform, + windowId: input.window.id, + itemCount: normalizedItems.length, + cause, + }), + ), + ); + } }), }); });