authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:07:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:07:57-07:00
logdefd9dfe20f8c333b8eab2769e3205dfb560395c
tree4b48cabf25f8583a20faee207354f25544d79031
parent8c03188c1cad4220baac08b1580823b4baeaae70
signature Signed by PGP key B5690EEEBB952194

[codex] Structure Electron menu failures (#3317)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 179 insertions(+), 28 deletions(-)

apps/desktop/src/electron/ElectronMenu.test.ts+94-4
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
3import * as Option from "effect/Option";6import * as Option from "effect/Option";
4import type * as Electron from "electron";7import type * as Electron from "electron";
5import { beforeEach, vi } from "vite-plus/test";8import { beforeEach, vi } from "vite-plus/test";
...@@ -24,6 +27,10 @@ vi.mock("electron", () => ({...@@ -24,6 +27,10 @@ vi.mock("electron", () => ({
2427
25import * as ElectronMenu from "./ElectronMenu.ts";28import * as ElectronMenu from "./ElectronMenu.ts";
2629
30const TestLayer = ElectronMenu.layer.pipe(
31 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
32);
33
27describe("ElectronMenu", () => {34describe("ElectronMenu", () => {
28 beforeEach(() => {35 beforeEach(() => {
29 buildFromTemplateMock.mockReset();36 buildFromTemplateMock.mockReset();
...@@ -42,7 +49,7 @@ describe("ElectronMenu", () => {...@@ -42,7 +49,7 @@ describe("ElectronMenu", () => {
4249
43 assert.isTrue(Option.isNone(selectedItemId));50 assert.isTrue(Option.isNone(selectedItemId));
44 assert.equal(buildFromTemplateMock.mock.calls.length, 0);51 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
45 }).pipe(Effect.provide(ElectronMenu.layer)),52 }).pipe(Effect.provide(TestLayer)),
46 );53 );
4754
48 it.effect("resolves with the clicked leaf item id", () =>55 it.effect("resolves with the clicked leaf item id", () =>
...@@ -69,7 +76,7 @@ describe("ElectronMenu", () => {...@@ -69,7 +76,7 @@ describe("ElectronMenu", () => {
69 });76 });
7077
71 assert.equal(Option.getOrNull(selectedItemId), "copy");78 assert.equal(Option.getOrNull(selectedItemId), "copy");
72 }).pipe(Effect.provide(ElectronMenu.layer)),79 }).pipe(Effect.provide(TestLayer)),
73 );80 );
7481
75 it.effect("resolves with none when the menu closes without a click", () =>82 it.effect("resolves with none when the menu closes without a click", () =>
...@@ -93,7 +100,7 @@ describe("ElectronMenu", () => {...@@ -93,7 +100,7 @@ describe("ElectronMenu", () => {
93 enabled: true,100 enabled: true,
94 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,101 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
95 });102 });
96 }).pipe(Effect.provide(ElectronMenu.layer)),103 }).pipe(Effect.provide(TestLayer)),
97 );104 );
98105
99 it.effect("defers popupTemplate side effects until the returned Effect runs", () =>106 it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
...@@ -114,6 +121,89 @@ describe("ElectronMenu", () => {...@@ -114,6 +121,89 @@ describe("ElectronMenu", () => {
114121
115 assert.equal(buildFromTemplateMock.mock.calls.length, 1);122 assert.equal(buildFromTemplateMock.mock.calls.length, 1);
116 assert.equal(popupMock.mock.calls.length, 1);123 assert.equal(popupMock.mock.calls.length, 1);
117 }).pipe(Effect.provide(ElectronMenu.layer)),124 }).pipe(Effect.provide(TestLayer)),
125 );
126
127 it.effect("preserves application-menu failures as structured defects", () =>
128 Effect.gen(function* () {
129 const cause = new Error("application menu build failed");
130 buildFromTemplateMock.mockImplementationOnce(() => {
131 throw cause;
132 });
133
134 const electronMenu = yield* ElectronMenu.ElectronMenu;
135 const exit = yield* Effect.exit(
136 electronMenu.setApplicationMenu([{ label: "File" }, { label: "Edit" }]),
137 );
138
139 assert.equal(exit._tag, "Failure");
140 if (exit._tag === "Failure") {
141 const error = Cause.squash(exit.cause);
142 assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
143 assert.equal(error.operation, "set-application-menu");
144 assert.equal(error.platform, "linux");
145 assert.isNull(error.windowId);
146 assert.equal(error.itemCount, 2);
147 assert.strictEqual(error.cause, cause);
148 assert.notInclude(error.message, cause.message);
149 }
150 }).pipe(Effect.provide(TestLayer)),
151 );
152
153 it.effect("preserves popup-template failures with window context", () =>
154 Effect.gen(function* () {
155 const cause = new Error("popup failed");
156 buildFromTemplateMock.mockReturnValueOnce({
157 popup: () => {
158 throw cause;
159 },
160 });
161
162 const electronMenu = yield* ElectronMenu.ElectronMenu;
163 const exit = yield* Effect.exit(
164 electronMenu.popupTemplate({
165 window: { id: 41 } as Electron.BrowserWindow,
166 template: [{ label: "Copy" }],
167 }),
168 );
169
170 assert.equal(exit._tag, "Failure");
171 if (exit._tag === "Failure") {
172 const error = Cause.squash(exit.cause);
173 assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
174 assert.equal(error.operation, "popup-template");
175 assert.equal(error.windowId, 41);
176 assert.equal(error.itemCount, 1);
177 assert.strictEqual(error.cause, cause);
178 }
179 }).pipe(Effect.provide(TestLayer)),
180 );
181
182 it.effect("preserves context-menu failures with normalized item context", () =>
183 Effect.gen(function* () {
184 const cause = new Error("context menu build failed");
185 buildFromTemplateMock.mockImplementationOnce(() => {
186 throw cause;
187 });
188
189 const electronMenu = yield* ElectronMenu.ElectronMenu;
190 const exit = yield* Effect.exit(
191 electronMenu.showContextMenu({
192 window: { id: 42 } as Electron.BrowserWindow,
193 items: [{ id: "copy", label: "Copy" }],
194 position: Option.none(),
195 }),
196 );
197
198 assert.equal(exit._tag, "Failure");
199 if (exit._tag === "Failure") {
200 const error = Cause.squash(exit.cause);
201 assert.instanceOf(error, ElectronMenu.ElectronMenuOperationError);
202 assert.equal(error.operation, "show-context-menu");
203 assert.equal(error.windowId, 42);
204 assert.equal(error.itemCount, 1);
205 assert.strictEqual(error.cause, cause);
206 }
207 }).pipe(Effect.provide(TestLayer)),
118 );208 );
119});209});
apps/desktop/src/electron/ElectronMenu.ts+85-24
...@@ -4,6 +4,7 @@ import * as Context from "effect/Context";...@@ -4,6 +4,7 @@ import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
78
8import * as Electron from "electron";9import * as Electron from "electron";
910
...@@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput {...@@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput {
23 readonly template: readonly Electron.MenuItemConstructorOptions[];24 readonly template: readonly Electron.MenuItemConstructorOptions[];
24}25}
2526
27const ElectronMenuOperation = Schema.Literals([
28 "set-application-menu",
29 "popup-template",
30 "show-context-menu",
31]);
32
33export class ElectronMenuOperationError extends Schema.TaggedErrorClass<ElectronMenuOperationError>()(
34 "ElectronMenuOperationError",
35 {
36 operation: ElectronMenuOperation,
37 platform: Schema.String,
38 windowId: Schema.NullOr(Schema.Number),
39 itemCount: Schema.Number,
40 cause: Schema.Defect(),
41 },
42) {
43 override get message(): string {
44 const window = this.windowId === null ? "" : ` for window ${this.windowId}`;
45 return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`;
46 }
47}
48
26export class ElectronMenu extends Context.Service<49export class ElectronMenu extends Context.Service<
27 ElectronMenu,50 ElectronMenu,
28 {51 {
...@@ -142,16 +165,36 @@ export const make = Effect.gen(function* () {...@@ -142,16 +165,36 @@ export const make = Effect.gen(function* () {
142165
143 return ElectronMenu.of({166 return ElectronMenu.of({
144 setApplicationMenu: (template) =>167 setApplicationMenu: (template) =>
145 Effect.sync(() => {168 Effect.try({
146 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));169 try: () => {
147 }),170 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
171 },
172 catch: (cause) =>
173 new ElectronMenuOperationError({
174 operation: "set-application-menu",
175 platform,
176 windowId: null,
177 itemCount: template.length,
178 cause,
179 }),
180 }).pipe(Effect.orDie),
148 popupTemplate: (input) =>181 popupTemplate: (input) =>
149 Effect.sync(() => {182 input.template.length === 0
150 if (input.template.length === 0) {183 ? Effect.void
151 return;184 : Effect.try({
152 }185 try: () =>
153 Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });186 Electron.Menu.buildFromTemplate([...input.template]).popup({
154 }),187 window: input.window,
188 }),
189 catch: (cause) =>
190 new ElectronMenuOperationError({
191 operation: "popup-template",
192 platform,
193 windowId: input.window.id,
194 itemCount: input.template.length,
195 cause,
196 }),
197 }).pipe(Effect.orDie),
155 showContextMenu: (input) =>198 showContextMenu: (input) =>
156 Effect.callback<Option.Option<string>>((resume) => {199 Effect.callback<Option.Option<string>>((resume) => {
157 const normalizedItems = normalizeContextMenuItems(input.items);200 const normalizedItems = normalizeContextMenuItems(input.items);
...@@ -169,21 +212,39 @@ export const make = Effect.gen(function* () {...@@ -169,21 +212,39 @@ export const make = Effect.gen(function* () {
169 resume(Effect.succeed(selectedItemId));212 resume(Effect.succeed(selectedItemId));
170 };213 };
171214
172 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));215 try {
173 const popupPosition = normalizePosition(input.position);216 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
174 const popupOptions = Option.match(popupPosition, {217 const popupPosition = normalizePosition(input.position);
175 onNone: (): Electron.PopupOptions => ({218 const popupOptions = Option.match(popupPosition, {
176 window: input.window,219 onNone: (): Electron.PopupOptions => ({
177 callback: () => complete(Option.none()),220 window: input.window,
178 }),221 callback: () => complete(Option.none()),
179 onSome: (position): Electron.PopupOptions => ({222 }),
180 window: input.window,223 onSome: (position): Electron.PopupOptions => ({
181 x: position.x,224 window: input.window,
182 y: position.y,225 x: position.x,
183 callback: () => complete(Option.none()),226 y: position.y,
184 }),227 callback: () => complete(Option.none()),
185 });228 }),
186 menu.popup(popupOptions);229 });
230 menu.popup(popupOptions);
231 } catch (cause) {
232 if (completed) {
233 return;
234 }
235 completed = true;
236 resume(
237 Effect.die(
238 new ElectronMenuOperationError({
239 operation: "show-context-menu",
240 platform,
241 windowId: input.window.id,
242 itemCount: normalizedItems.length,
243 cause,
244 }),
245 ),
246 );
247 }
187 }),248 }),
188 });249 });
189});250});