1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6
7import type * as Electron from "electron";
8
9import * as ElectronApp from "../electron/ElectronApp.ts";
10import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronMenu from "../electron/ElectronMenu.ts";
12import * as DesktopApplicationMenu from "./DesktopApplicationMenu.ts";
13import * as DesktopConfig from "../app/DesktopConfig.ts";
14import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
15import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
16import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
17import * as DesktopWindow from "./DesktopWindow.ts";
18
19const environmentInput = {
20 dirname: "/repo/apps/desktop/dist-electron",
21 homeDirectory: "/Users/alice",
22 platform: "linux",
23 processArch: "arm64",
24 appVersion: "1.2.3",
25 appPath: "/repo",
26 isPackaged: false,
27 resourcesPath: "/repo/resources",
28 runningUnderArm64Translation: false,
29} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;
30
31const layerElectronApp = Layer.succeed(ElectronApp.ElectronApp, {
32 metadata: Effect.die("unexpected metadata read"),
33 name: Effect.succeed("The Knife"),
34 systemLocale: Effect.succeed("en-US"),
35 whenReady: Effect.void,
36 quit: Effect.void,
37 exit: () => Effect.void,
38 relaunch: () => Effect.void,
39 setPath: () => Effect.void,
40 setName: () => Effect.void,
41 setAboutPanelOptions: () => Effect.void,
42 setAppUserModelId: () => Effect.void,
43 getAppMetrics: Effect.succeed([]),
44 setAsDefaultProtocolClient: () => Effect.succeed(true),
45 setDesktopName: () => Effect.void,
46 setDockIcon: () => Effect.void,
47 appendCommandLineSwitch: () => Effect.void,
48 onBeforeQuitForUpdate: () => Effect.void,
49 removeCommandLineSwitch: () => Effect.void,
50 on: () => Effect.void,
51} satisfies ElectronApp.ElectronApp["Service"]);
52
53const layerElectronDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
54 pickFolder: () => Effect.succeedNone,
55 pickFiles: () => Effect.succeed([]),
56 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
57 showErrorBox: () => Effect.void,
58} satisfies ElectronDialog.ElectronDialog["Service"]);
59
60const layerDesktopUpdates = Layer.succeed(DesktopUpdates.DesktopUpdates, {
61 getState: Effect.die("unexpected getState"),
62 isActionActive: Effect.succeed(false),
63 isInstallActive: Effect.succeed(false),
64 subscribe: Effect.die("unexpected subscribe"),
65 emitState: Effect.void,
66 disabledReason: Effect.succeedNone,
67 configure: Effect.void,
68 setChannel: () => Effect.die("unexpected setChannel"),
69 check: () => Effect.die("unexpected check"),
70 download: Effect.die("unexpected download"),
71 install: Effect.die("unexpected install"),
72 installPrepared: () => Effect.die("unexpected installPrepared"),
73} satisfies DesktopUpdates.DesktopUpdates["Service"]);
74
75const layerDesktopWindow = (selectedAction: Deferred.Deferred<string>) =>
76 Layer.succeed(DesktopWindow.DesktopWindow, {
77 createMain: Effect.die("unexpected createMain"),
78 ensureMain: Effect.die("unexpected ensureMain"),
79 revealOrCreateMain: Effect.die("unexpected revealOrCreateMain"),
80 activate: Effect.void,
81 createMainIfBackendReady: Effect.void,
82 showConnectingSplash: Effect.void,
83 handleBackendReady: () => Effect.void,
84 handleBackendNotReady: Effect.void,
85 flushMainWindowBounds: Effect.void,
86 prepareCaptureReveal: Effect.void,
87 dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
88 dispatchSnapShotEvent: () => Effect.void,
89 zoomMain: (direction) =>
90 Deferred.succeed(selectedAction, `zoom-${direction}`).pipe(Effect.asVoid),
91 runMainContentsCommand: (command) =>
92 Deferred.succeed(selectedAction, `main-${command}`).pipe(Effect.asVoid),
93 syncAppearance: Effect.void,
94 } satisfies DesktopWindow.DesktopWindow["Service"]);
95
96const layerElectronMenu = (
97 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
98) =>
99 Layer.succeed(ElectronMenu.ElectronMenu, {
100 setApplicationMenu: (template) =>
101 Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid),
102 popupTemplate: () => Effect.void,
103 showContextMenu: () => Effect.succeedNone,
104 } satisfies ElectronMenu.ElectronMenu["Service"]);
105
106const configureMenu = (
107 selectedAction: Deferred.Deferred<string>,
108 applicationMenuTemplate: Deferred.Deferred<readonly Electron.MenuItemConstructorOptions[]>,
109 environment: Partial<DesktopEnvironment.MakeDesktopEnvironmentInput> = {},
110 localUi: DesktopLocalUi.DesktopLocalUi["Service"] = {
111 assetDirectories: Effect.die("unexpected asset read"),
112 currentVersion: Effect.succeed("1.0.0"),
113 apply: () => Effect.die("unexpected local UI update"),
114 reset: Effect.die("unexpected local UI reset"),
115 },
116) =>
117 Effect.gen(function* () {
118 const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
119 yield* menu.configure;
120 }).pipe(
121 Effect.provide(
122 DesktopApplicationMenu.layer.pipe(
123 Layer.provideMerge(layerElectronMenu(applicationMenuTemplate)),
124 Layer.provideMerge(layerDesktopWindow(selectedAction)),
125 Layer.provideMerge(layerDesktopUpdates),
126 Layer.provideMerge(Layer.succeed(DesktopLocalUi.DesktopLocalUi, localUi)),
127 Layer.provideMerge(layerElectronDialog),
128 Layer.provideMerge(layerElectronApp),
129 Layer.provideMerge(
130 DesktopEnvironment.layer({ ...environmentInput, ...environment }).pipe(
131 Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
132 ),
133 ),
134 ),
135 ),
136 );
137
138describe("DesktopApplicationMenu", () => {
139 it.effect.each([["Use Bundled UI", "reset"]] as const)(
140 "runs %s without needing a working renderer",
141 ([label, operation]) =>
142 Effect.gen(function* () {
143 const selectedAction = yield* Deferred.make<string>();
144 const templateReady =
145 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
146 const steps: string[] = [];
147 yield* configureMenu(
148 selectedAction,
149 templateReady,
150 {},
151 {
152 assetDirectories: Effect.die("unexpected asset read"),
153 currentVersion: Effect.succeed("1.0.0"),
154 apply: () => Effect.die("unexpected local UI update"),
155 reset: Effect.sync(() => {
156 steps.push("reset");
157 }),
158 },
159 );
160 const template = yield* Deferred.await(templateReady);
161 const view = template.find((item) => item.label === "View");
162 if (!Array.isArray(view?.submenu)) throw new Error("Missing View menu");
163 const item = view.submenu.find((entry) => entry.label === label);
164 if (!item?.click) throw new Error(`Missing ${label} menu action`);
165 item.click({} as Electron.MenuItem, undefined, {} as KeyboardEvent);
166 assert.equal(yield* Deferred.await(selectedAction), "main-forceReload");
167 assert.deepEqual(steps, [operation]);
168 }),
169 );
170 it.effect("keeps display branding in the macOS application menu", () =>
171 Effect.gen(function* () {
172 const selectedAction = yield* Deferred.make<string>();
173 const applicationMenuTemplate =
174 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
175
176 yield* configureMenu(selectedAction, applicationMenuTemplate, {
177 platform: "darwin",
178 appVersion: "0.0.43-nightly.20260929.2428",
179 });
180
181 const template = yield* Deferred.await(applicationMenuTemplate);
182 const applicationMenu = template[0];
183 assert.isDefined(applicationMenu);
184 assert.equal(applicationMenu.label, "The Knife (Nightly)");
185 if (!Array.isArray(applicationMenu.submenu)) {
186 throw new Error("Expected application menu submenu to be an array.");
187 }
188 assert.equal(
189 applicationMenu.submenu.find((item) => item.role === "about")?.label,
190 "About The Knife (Nightly)",
191 );
192 assert.equal(
193 applicationMenu.submenu.find((item) => item.role === "hide")?.label,
194 "Hide The Knife (Nightly)",
195 );
196 assert.equal(
197 applicationMenu.submenu.find((item) => item.role === "quit")?.label,
198 "Quit The Knife (Nightly)",
199 );
200 }),
201 );
202
203 it.effect("installs the native menu and routes Settings through DesktopWindow", () =>
204 Effect.gen(function* () {
205 const selectedAction = yield* Deferred.make<string>();
206 const applicationMenuTemplate =
207 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
208
209 yield* configureMenu(selectedAction, applicationMenuTemplate);
210
211 const template = yield* Deferred.await(applicationMenuTemplate);
212 const fileMenu = template.find((item) => item.label === "File");
213 assert.isDefined(fileMenu);
214 if (!Array.isArray(fileMenu.submenu)) {
215 throw new Error("Expected File menu submenu to be an array.");
216 }
217 const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings...");
218 assert.isDefined(settingsItem);
219 const settingsClick = settingsItem.click;
220 if (typeof settingsClick !== "function") {
221 throw new Error("Expected Settings menu item to have a click handler.");
222 }
223
224 settingsClick({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
225 assert.equal(yield* Deferred.await(selectedAction), "open-settings");
226 }),
227 );
228
229 it.effect("owns Paste as Text and routes it through the renderer", () =>
230 Effect.gen(function* () {
231 const selectedAction = yield* Deferred.make<string>();
232 const applicationMenuTemplate =
233 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
234
235 yield* configureMenu(selectedAction, applicationMenuTemplate);
236
237 const template = yield* Deferred.await(applicationMenuTemplate);
238 const editMenu = template.find((item) => item.label === "Edit");
239 assert.isDefined(editMenu);
240 if (!Array.isArray(editMenu.submenu)) {
241 throw new Error("Expected Edit menu submenu to be an array.");
242 }
243 const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
244 assert.isDefined(pasteAsTextItem);
245 assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V");
246 if (typeof pasteAsTextItem.click !== "function") {
247 throw new Error("Expected Paste as Text menu item to have a click handler.");
248 }
249
250 pasteAsTextItem.click(
251 {} as Electron.MenuItem,
252 {} as Electron.BrowserWindow,
253 {} as KeyboardEvent,
254 );
255 assert.equal(yield* Deferred.await(selectedAction), "paste-as-text");
256 }),
257 );
258
259 // Chromium pastes as plain text for the accelerator on its own. Dispatching
260 // the action as well injects a second paste, which doubles the pasted text.
261 it.effect("leaves the accelerator to Chromium instead of injecting a paste", () =>
262 Effect.gen(function* () {
263 const selectedAction = yield* Deferred.make<string>();
264 const applicationMenuTemplate =
265 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
266
267 yield* configureMenu(selectedAction, applicationMenuTemplate);
268
269 const template = yield* Deferred.await(applicationMenuTemplate);
270 const editMenu = template.find((item) => item.label === "Edit");
271 if (!Array.isArray(editMenu?.submenu)) {
272 throw new Error("Expected Edit menu submenu to be an array.");
273 }
274 const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
275 if (typeof pasteAsTextItem?.click !== "function") {
276 throw new Error("Expected Paste as Text menu item to have a click handler.");
277 }
278
279 pasteAsTextItem.click(
280 {} as Electron.MenuItem,
281 {} as Electron.BrowserWindow,
282 {
283 triggeredByAccelerator: true,
284 } as unknown as KeyboardEvent,
285 );
286 assert.isFalse(yield* Deferred.isDone(selectedAction));
287 }),
288 );
289
290 // Zoom must route through DesktopWindow.zoomMain instead of the Electron
291 // zoom roles: the roles zoom whichever webContents has focus, which breaks
292 // app zoom while an embedded preview WebContentsView holds focus.
293 it.effect("routes View menu zoom to the main window instead of zoom roles", () =>
294 Effect.gen(function* () {
295 const selectedAction = yield* Deferred.make<string>();
296 const applicationMenuTemplate =
297 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
298
299 yield* configureMenu(selectedAction, applicationMenuTemplate);
300
301 const template = yield* Deferred.await(applicationMenuTemplate);
302 const viewMenu = template.find((item) => item.label === "View");
303 assert.isDefined(viewMenu);
304 if (!Array.isArray(viewMenu.submenu)) {
305 throw new Error("Expected View menu submenu to be an array.");
306 }
307
308 assert.isUndefined(
309 viewMenu.submenu.find((item) =>
310 ["zoom", "reload", "devtools"].some((role) => item.role?.toLowerCase().includes(role)),
311 ),
312 );
313
314 const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");
315 assert.isDefined(zoomIn);
316 assert.equal(zoomIn.accelerator, "CmdOrCtrl+=");
317 if (typeof zoomIn.click !== "function") {
318 throw new Error("Expected Zoom In menu item to have a click handler.");
319 }
320
321 zoomIn.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
322 assert.equal(yield* Deferred.await(selectedAction), "zoom-in");
323 }),
324 );
325
326 it.effect("reloads the main window even while a browser page has focus", () =>
327 Effect.gen(function* () {
328 const selectedAction = yield* Deferred.make<string>();
329 const applicationMenuTemplate =
330 yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();
331
332 yield* configureMenu(selectedAction, applicationMenuTemplate);
333
334 const template = yield* Deferred.await(applicationMenuTemplate);
335 const viewMenu = template.find((item) => item.label === "View");
336 if (!Array.isArray(viewMenu?.submenu)) {
337 throw new Error("Expected View menu submenu to be an array.");
338 }
339 const reload = viewMenu.submenu.find((item) => item.label === "Reload");
340 assert.equal(reload?.accelerator, "CmdOrCtrl+R");
341 if (typeof reload?.click !== "function") {
342 throw new Error("Expected Reload menu item to have a click handler.");
343 }
344
345 reload.click({} as Electron.MenuItem, {} as Electron.BrowserWindow, {} as KeyboardEvent);
346 assert.equal(yield* Deferred.await(selectedAction), "main-reload");
347 }),
348 );
349});