Skip to content

Commit defd9df

Browse files
[codex] Structure Electron menu failures (#3317)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 8c03188 commit defd9df

2 files changed

Lines changed: 179 additions & 28 deletions

File tree

apps/desktop/src/electron/ElectronMenu.test.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { assert, describe, it } from "@effect/vitest";
2+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3+
import * as Cause from "effect/Cause";
24
import * as Effect from "effect/Effect";
5+
import * as Layer from "effect/Layer";
36
import * as Option from "effect/Option";
47
import type * as Electron from "electron";
58
import { beforeEach, vi } from "vite-plus/test";
@@ -24,6 +27,10 @@ vi.mock("electron", () => ({
2427

2528
import * as ElectronMenu from "./ElectronMenu.ts";
2629

30+
const TestLayer = ElectronMenu.layer.pipe(
31+
Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
32+
);
33+
2734
describe("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

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as Context from "effect/Context";
44
import * as Effect from "effect/Effect";
55
import * as Layer from "effect/Layer";
66
import * as Option from "effect/Option";
7+
import * as Schema from "effect/Schema";
78

89
import * as Electron from "electron";
910

@@ -23,6 +24,28 @@ export interface ElectronMenuTemplateInput {
2324
readonly template: readonly Electron.MenuItemConstructorOptions[];
2425
}
2526

27+
const ElectronMenuOperation = Schema.Literals([
28+
"set-application-menu",
29+
"popup-template",
30+
"show-context-menu",
31+
]);
32+
33+
export 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+
2649
export 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
});

0 commit comments

Comments
 (0)