1import type { ContextMenuItem } from "@t3tools/contracts";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8
9import * as Electron from "electron";
10
11export interface ElectronMenuPosition {
12 readonly x: number;
13 readonly y: number;
14}
15
16export interface ElectronMenuContextInput {
17 readonly window: Electron.BrowserWindow;
18 readonly items: readonly ContextMenuItem[];
19 readonly position: Option.Option<ElectronMenuPosition>;
20}
21
22export interface ElectronMenuTemplateInput {
23 readonly window: Electron.BrowserWindow;
24 readonly template: readonly Electron.MenuItemConstructorOptions[];
25 readonly frame?: Electron.WebFrameMain;
26}
27
28const ElectronMenuOperation = Schema.Literals([
29 "set-application-menu",
30 "popup-template",
31 "show-context-menu",
32]);
33
34export class ElectronMenuOperationError extends Schema.TaggedError<ElectronMenuOperationError>()(
35 "ElectronMenuOperationError",
36 {
37 operation: ElectronMenuOperation,
38 platform: Schema.String,
39 windowId: Schema.NullOr(Schema.Number),
40 itemCount: Schema.Number,
41 cause: Schema.Defect(),
42 },
43) {
44 override get message(): string {
45 const window = this.windowId === null ? "" : ` for window ${this.windowId}`;
46 return `Electron menu operation ${JSON.stringify(this.operation)} failed${window} with ${this.itemCount} items on ${this.platform}.`;
47 }
48}
49
50export class ElectronMenu extends Context.Service<
51 ElectronMenu,
52 {
53 readonly setApplicationMenu: (
54 template: readonly Electron.MenuItemConstructorOptions[],
55 ) => Effect.Effect<void>;
56 readonly showContextMenu: (
57 input: ElectronMenuContextInput,
58 ) => Effect.Effect<Option.Option<string>>;
59 readonly popupTemplate: (input: ElectronMenuTemplateInput) => Effect.Effect<void>;
60 }
61>()("@t3tools/desktop/electron/ElectronMenu") {}
62
63function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
64 const normalizedItems: ContextMenuItem[] = [];
65
66 for (const sourceItem of source) {
67 if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
68 continue;
69 }
70
71 // Header items are decorative section labels for the web fallback only —
72 // Electron's native menu has no equivalent affordance, so we skip them.
73 if (sourceItem.header === true) {
74 continue;
75 }
76
77 const normalizedItem: ContextMenuItem = {
78 id: sourceItem.id,
79 label: sourceItem.label,
80 destructive: sourceItem.destructive === true,
81 disabled: sourceItem.disabled === true,
82 ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}),
83 ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}),
84 };
85
86 if (sourceItem.children) {
87 const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
88 if (normalizedChildren.length === 0) {
89 continue;
90 }
91 normalizedItem.children = normalizedChildren;
92 }
93
94 normalizedItems.push(normalizedItem);
95 }
96
97 return normalizedItems;
98}
99
100// Renderer positions arrive in CSS pixels; popup() expects window points, so
101// page zoom must be factored in or menus drift proportionally to their
102// distance from the window origin.
103const normalizePosition = (
104 position: Option.Option<ElectronMenuPosition>,
105 zoomFactor: number,
106): Option.Option<ElectronMenuPosition> =>
107 Option.filter(
108 position,
109 ({ x, y }) =>
110 Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
111 ).pipe(
112 Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
113 );
114
115/** @public Service construction is part of the canonical Effect module API. */
116export const make = Effect.gen(function* () {
117 const platform = yield* HostProcess.Platform;
118 let destructiveMenuIconCache: Option.Option<Electron.NativeImage> | undefined;
119
120 const getDestructiveMenuIcon = (): Option.Option<Electron.NativeImage> => {
121 if (platform !== "darwin") {
122 return Option.none();
123 }
124 if (destructiveMenuIconCache !== undefined) {
125 return destructiveMenuIconCache;
126 }
127
128 try {
129 const icon = Electron.nativeImage.createFromNamedImage("trash").resize({
130 width: 12,
131 height: 12,
132 });
133 icon.setTemplateImage(true);
134 destructiveMenuIconCache = icon.isEmpty() ? Option.none() : Option.some(icon);
135 } catch {
136 destructiveMenuIconCache = Option.none();
137 }
138
139 return destructiveMenuIconCache;
140 };
141
142 const buildTemplate = (
143 entries: readonly ContextMenuItem[],
144 complete: (selectedItemId: Option.Option<string>) => void,
145 ): Electron.MenuItemConstructorOptions[] => {
146 const template: Electron.MenuItemConstructorOptions[] = [];
147 let hasInsertedDestructiveSeparator = false;
148 let sectionStartedByExplicitSeparator = false;
149 const appendSeparator = () => {
150 if (template.length === 0 || template.at(-1)?.type === "separator") return;
151 template.push({ type: "separator" });
152 };
153
154 for (const item of entries) {
155 if (item.separatorBefore) {
156 appendSeparator();
157 sectionStartedByExplicitSeparator = true;
158 }
159 if (
160 item.destructive &&
161 !hasInsertedDestructiveSeparator &&
162 !sectionStartedByExplicitSeparator &&
163 template.length > 0
164 ) {
165 appendSeparator();
166 hasInsertedDestructiveSeparator = true;
167 }
168
169 const itemOption: Electron.MenuItemConstructorOptions = {
170 label: item.label,
171 enabled: !item.disabled,
172 ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}),
173 };
174 if (item.children && item.children.length > 0) {
175 itemOption.submenu = buildTemplate(item.children, complete);
176 } else {
177 itemOption.click = () => complete(Option.some(item.id));
178 }
179 if (item.destructive && (!item.children || item.children.length === 0)) {
180 const destructiveIcon = getDestructiveMenuIcon();
181 if (Option.isSome(destructiveIcon)) {
182 itemOption.icon = destructiveIcon.value;
183 }
184 }
185
186 template.push(itemOption);
187 }
188
189 return template;
190 };
191
192 return ElectronMenu.of({
193 setApplicationMenu: (template) =>
194 Effect.try({
195 try: () => {
196 Electron.Menu.setApplicationMenu(Electron.Menu.buildFromTemplate([...template]));
197 },
198 catch: (cause) =>
199 new ElectronMenuOperationError({
200 operation: "set-application-menu",
201 platform,
202 windowId: null,
203 itemCount: template.length,
204 cause,
205 }),
206 }).pipe(Effect.orDie),
207 popupTemplate: (input) =>
208 input.template.length === 0
209 ? Effect.void
210 : Effect.try({
211 try: () =>
212 Electron.Menu.buildFromTemplate([...input.template]).popup({
213 window: input.window,
214 ...(input.frame ? { frame: input.frame } : {}),
215 }),
216 catch: (cause) =>
217 new ElectronMenuOperationError({
218 operation: "popup-template",
219 platform,
220 windowId: input.window.id,
221 itemCount: input.template.length,
222 cause,
223 }),
224 }).pipe(Effect.orDie),
225 showContextMenu: (input) =>
226 Effect.callback<Option.Option<string>>((resume) => {
227 const normalizedItems = normalizeContextMenuItems(input.items);
228 if (normalizedItems.length === 0) {
229 resume(Effect.succeedNone);
230 return;
231 }
232
233 let completed = false;
234 const complete = (selectedItemId: Option.Option<string>) => {
235 if (completed) {
236 return;
237 }
238 completed = true;
239 resume(Effect.succeed(selectedItemId));
240 };
241
242 try {
243 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
244 const popupPosition = normalizePosition(
245 input.position,
246 input.window.webContents.getZoomFactor(),
247 );
248 const popupOptions = Option.match(popupPosition, {
249 onNone: (): Electron.PopupOptions => ({
250 window: input.window,
251 callback: () => complete(Option.none()),
252 }),
253 onSome: (position): Electron.PopupOptions => ({
254 window: input.window,
255 x: position.x,
256 y: position.y,
257 callback: () => complete(Option.none()),
258 }),
259 });
260 menu.popup(popupOptions);
261 } catch (cause) {
262 if (completed) {
263 return;
264 }
265 completed = true;
266 resume(
267 Effect.die(
268 new ElectronMenuOperationError({
269 operation: "show-context-menu",
270 platform,
271 windowId: input.window.id,
272 itemCount: normalizedItems.length,
273 cause,
274 }),
275 ),
276 );
277 }
278 }),
279 });
280});
281
282export const layer = Layer.effect(ElectronMenu, make);