| 1 | import type { ContextMenuItem } from "@t3tools/contracts"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | |
| 9 | import * as Electron from "electron"; |
| 10 | |
| 11 | export interface ElectronMenuPosition { |
| 12 | readonly x: number; |
| 13 | readonly y: number; |
| 14 | } |
| 15 | |
| 16 | export interface ElectronMenuContextInput { |
| 17 | readonly window: Electron.BrowserWindow; |
| 18 | readonly items: readonly ContextMenuItem[]; |
| 19 | readonly position: Option.Option<ElectronMenuPosition>; |
| 20 | } |
| 21 | |
| 22 | export interface ElectronMenuTemplateInput { |
| 23 | readonly window: Electron.BrowserWindow; |
| 24 | readonly template: readonly Electron.MenuItemConstructorOptions[]; |
| 25 | readonly frame?: Electron.WebFrameMain; |
| 26 | } |
| 27 | |
| 28 | const ElectronMenuOperation = Schema.Literals([ |
| 29 | "set-application-menu", |
| 30 | "popup-template", |
| 31 | "show-context-menu", |
| 32 | ]); |
| 33 | |
| 34 | export 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 | |
| 50 | export 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 | |
| 63 | function 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. |
| 103 | const 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. */ |
| 116 | export 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 | |
| 282 | export const layer = Layer.effect(ElectronMenu, make); |