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 @@
11import { assert, describe, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import * as Cause from "effect/Cause";
24import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
36import * as Option from "effect/Option";
47import type * as Electron from "electron";
58import { beforeEach, vi } from "vite-plus/test";
......@@ -24,6 +27,10 @@ vi.mock("electron", () => ({
2427
2528import * as ElectronMenu from "./ElectronMenu.ts";
2629
30const TestLayer = ElectronMenu.layer.pipe(
31 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
32);
33
2734describe("ElectronMenu", () => {
2835 beforeEach(() => {
2936 buildFromTemplateMock.mockReset();
......@@ -42,7 +49,7 @@ describe("ElectronMenu", () => {
4249
4350 assert.isTrue(Option.isNone(selectedItemId));
4451 assert.equal(buildFromTemplateMock.mock.calls.length, 0);
45 }).pipe(Effect.provide(ElectronMenu.layer)),
52 }).pipe(Effect.provide(TestLayer)),
4653 );
4754
4855 it.effect("resolves with the clicked leaf item id", () =>
......@@ -69,7 +76,7 @@ describe("ElectronMenu", () => {
6976 });
7077
7178 assert.equal(Option.getOrNull(selectedItemId), "copy");
72 }).pipe(Effect.provide(ElectronMenu.layer)),
79 }).pipe(Effect.provide(TestLayer)),
7380 );
7481
7582 it.effect("resolves with none when the menu closes without a click", () =>
......@@ -93,7 +100,7 @@ describe("ElectronMenu", () => {
93100 enabled: true,
94101 click: buildFromTemplateMock.mock.calls[0]?.[0][0].click,
95102 });
96 }).pipe(Effect.provide(ElectronMenu.layer)),
103 }).pipe(Effect.provide(TestLayer)),
97104 );
98105
99106 it.effect("defers popupTemplate side effects until the returned Effect runs", () =>
......@@ -114,6 +121,89 @@ describe("ElectronMenu", () => {
114121
115122 assert.equal(buildFromTemplateMock.mock.calls.length, 1);
116123 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)),
118208 );
119209});
apps/desktop/src/electron/ElectronMenu.ts+85-24
......@@ -4,6 +4,7 @@ import * as Context from "effect/Context";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
78
89import * as Electron from "electron";
910
......@@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput {
2324 readonly template: readonly Electron.MenuItemConstructorOptions[];
2425}
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
2649export class ElectronMenu extends Context.Service<
2750 ElectronMenu,
2851 {
......@@ -142,16 +165,36 @@ export const make = Effect.gen(function* () {
142165
143166 return ElectronMenu.of({
144167 setApplicationMenu: (template) =>
145 Effect.sync(() => {
146 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
147 }),
168 Effect.try({
169 try: () => {
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),
148181 popupTemplate: (input) =>
149 Effect.sync(() => {
150 if (input.template.length === 0) {
151 return;
152 }
153 Electron.Menu.buildFromTemplate([...input.template]).popup({ window: input.window });
154 }),
182 input.template.length === 0
183 ? Effect.void
184 : Effect.try({
185 try: () =>
186 Electron.Menu.buildFromTemplate([...input.template]).popup({
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),
155198 showContextMenu: (input) =>
156199 Effect.callback<Option.Option<string>>((resume) => {
157200 const normalizedItems = normalizeContextMenuItems(input.items);
......@@ -169,21 +212,39 @@ export const make = Effect.gen(function* () {
169212 resume(Effect.succeed(selectedItemId));
170213 };
171214
172 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
173 const popupPosition = normalizePosition(input.position);
174 const popupOptions = Option.match(popupPosition, {
175 onNone: (): Electron.PopupOptions => ({
176 window: input.window,
177 callback: () => complete(Option.none()),
178 }),
179 onSome: (position): Electron.PopupOptions => ({
180 window: input.window,
181 x: position.x,
182 y: position.y,
183 callback: () => complete(Option.none()),
184 }),
185 });
186 menu.popup(popupOptions);
215 try {
216 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
217 const popupPosition = normalizePosition(input.position);
218 const popupOptions = Option.match(popupPosition, {
219 onNone: (): Electron.PopupOptions => ({
220 window: input.window,
221 callback: () => complete(Option.none()),
222 }),
223 onSome: (position): Electron.PopupOptions => ({
224 window: input.window,
225 x: position.x,
226 y: position.y,
227 callback: () => complete(Option.none()),
228 }),
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 }
187248 }),
188249 });
189250});