1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import type * as Electron from "electron";
8
9import { makeComponentLogger } from "../app/DesktopObservability.ts";
10import * as ElectronDialog from "../electron/ElectronDialog.ts";
11import * as ElectronMenu from "../electron/ElectronMenu.ts";
12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
14import * as DesktopLocalUi from "../updates/DesktopLocalUi.ts";
15import * as DesktopWindow from "./DesktopWindow.ts";
16
17export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()(
18 "DesktopApplicationMenuActionError",
19 {
20 action: Schema.String,
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return `Desktop menu action "${this.action}" failed.`;
26 }
27}
28
29export class DesktopApplicationMenu extends Context.Service<
30 DesktopApplicationMenu,
31 {
32 readonly configure: Effect.Effect<void>;
33 }
34>()("@t3tools/desktop/window/DesktopApplicationMenu") {}
35
36type DesktopApplicationMenuRuntimeServices =
37 | DesktopUpdates.DesktopUpdates
38 | DesktopLocalUi.DesktopLocalUi
39 | DesktopWindow.DesktopWindow
40 | ElectronDialog.ElectronDialog;
41
42const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater");
43
44const { logError: logMenuError } = makeComponentLogger("desktop-menu");
45
46const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* (
47 action: string,
48): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
49 const desktopWindow = yield* DesktopWindow.DesktopWindow;
50 yield* desktopWindow.dispatchMenuAction(action, {
51 reveal: action !== "paste-as-text",
52 });
53});
54
55const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
56 direction: DesktopWindow.MainWindowZoomDirection,
57): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
58 const desktopWindow = yield* DesktopWindow.DesktopWindow;
59 yield* desktopWindow.zoomMain(direction);
60});
61
62const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")(function* (
63 command: DesktopWindow.MainWindowContentsCommand,
64): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
65 const desktopWindow = yield* DesktopWindow.DesktopWindow;
66 yield* desktopWindow.runMainContentsCommand(command);
67});
68
69const checkForUpdatesFromMenu = Effect.gen(function* () {
70 const updates = yield* DesktopUpdates.DesktopUpdates;
71 const electronDialog = yield* ElectronDialog.ElectronDialog;
72 const result = yield* updates.check("menu");
73 const updateState = result.state;
74
75 if (updateState.status === "up-to-date") {
76 yield* electronDialog.showMessageBox({
77 type: "info",
78 title: "You're up to date!",
79 message: `The Knife ${updateState.uiVersion ?? updateState.currentVersion} is currently the newest version available.`,
80 buttons: ["OK"],
81 });
82 } else if (updateState.status === "error") {
83 yield* electronDialog.showMessageBox({
84 type: "warning",
85 title: "Update check failed",
86 message: "Could not check for updates.",
87 detail: updateState.message ?? "An unknown error occurred. Please try again later.",
88 buttons: ["OK"],
89 });
90 }
91}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
92
93const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
94 const updates = yield* DesktopUpdates.DesktopUpdates;
95 const electronDialog = yield* ElectronDialog.ElectronDialog;
96 const disabledReason = yield* updates.disabledReason;
97 if (Option.isSome(disabledReason)) {
98 yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
99 disabledReason: disabledReason.value,
100 });
101 yield* electronDialog.showMessageBox({
102 type: "info",
103 title: "Updates unavailable",
104 message: "Automatic updates are not available right now.",
105 detail: disabledReason.value,
106 buttons: ["OK"],
107 });
108 return;
109 }
110
111 const desktopWindow = yield* DesktopWindow.DesktopWindow;
112 yield* desktopWindow.ensureMain;
113 yield* checkForUpdatesFromMenu;
114}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick"));
115
116/** @public Service construction is part of the canonical Effect module API. */
117export const make = Effect.gen(function* () {
118 const electronMenu = yield* ElectronMenu.ElectronMenu;
119 const environment = yield* DesktopEnvironment.DesktopEnvironment;
120 const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
121 const runPromise = Effect.runPromiseWith(context);
122
123 const runMenuEffect = <E>(
124 action: string,
125 effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>,
126 ) => {
127 void runPromise(
128 effect.pipe(
129 Effect.annotateLogs({ action }),
130 Effect.withSpan("desktop.menu.action"),
131 Effect.catchCause((cause) => {
132 const error = new DesktopApplicationMenuActionError({ action, cause });
133 return logMenuError(error.message, { error });
134 }),
135 ),
136 );
137 };
138
139 const configure = Effect.gen(function* () {
140 const checkForUpdatesClick = () => {
141 runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
142 };
143 const settingsClick = () => {
144 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
145 };
146 // Chromium already pastes as plain text for this chord, so the accelerator
147 // needs nothing from the menu: the composer and the terminal each arm
148 // themselves from the same keydown. Routing it through the renderer anyway
149 // lands a second, injected paste and doubles the text. Only a menu click,
150 // which produces no keystroke for them to see, needs that round trip.
151 const pasteAsTextClick = (
152 _item: Electron.MenuItem,
153 _window: Electron.BaseWindow | undefined,
154 event: Electron.KeyboardEvent,
155 ) => {
156 if (event.triggeredByAccelerator === true) return;
157 runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
158 };
159 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
160 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
161 };
162 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {
163 runMenuEffect(command, runMainContentsCommand(command));
164 };
165 const localUiClick = () => () => {
166 runMenuEffect(
167 "reset-ui",
168 Effect.gen(function* () {
169 const localUi = yield* DesktopLocalUi.DesktopLocalUi;
170 yield* localUi.reset;
171 yield* runMainContentsCommand("forceReload");
172 }).pipe(
173 Effect.catchTags({
174 DesktopLocalUiError: (error) =>
175 Effect.gen(function* () {
176 const dialog = yield* ElectronDialog.ElectronDialog;
177 yield* dialog.showMessageBox({
178 type: "error",
179 title: "Could not update UI",
180 message: error.message,
181 buttons: ["OK"],
182 });
183 }),
184 }),
185 ),
186 );
187 };
188 const template: Electron.MenuItemConstructorOptions[] = [];
189
190 if (environment.platform === "darwin") {
191 template.push({
192 label: environment.displayName,
193 submenu: [
194 { role: "about", label: `About ${environment.displayName}` },
195 {
196 label: "Check for Updates...",
197 click: checkForUpdatesClick,
198 },
199 { type: "separator" },
200 {
201 label: "Settings...",
202 accelerator: "CmdOrCtrl+,",
203 click: settingsClick,
204 },
205 { type: "separator" },
206 { role: "services" },
207 { type: "separator" },
208 { role: "hide", label: `Hide ${environment.displayName}` },
209 { role: "hideOthers" },
210 { role: "unhide" },
211 { type: "separator" },
212 { role: "quit", label: `Quit ${environment.displayName}` },
213 ],
214 });
215 }
216
217 template.push(
218 {
219 label: "File",
220 submenu: [
221 ...(environment.platform === "darwin"
222 ? []
223 : [
224 {
225 label: "Settings...",
226 accelerator: "CmdOrCtrl+,",
227 click: settingsClick,
228 },
229 { type: "separator" as const },
230 ]),
231 { role: environment.platform === "darwin" ? "close" : "quit" },
232 ],
233 },
234 {
235 label: "Edit",
236 submenu: [
237 { role: "undo" },
238 { role: "redo" },
239 { type: "separator" },
240 { role: "cut" },
241 { role: "copy" },
242 { role: "paste" },
243 {
244 label: "Paste as Text",
245 accelerator: "CmdOrCtrl+Shift+V",
246 click: pasteAsTextClick,
247 },
248 { role: "delete" },
249 { type: "separator" },
250 { role: "selectAll" },
251 ...(environment.platform === "darwin"
252 ? [
253 { type: "separator" as const },
254 {
255 label: "Speech",
256 submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }],
257 },
258 ]
259 : []),
260 ],
261 },
262 {
263 label: "View",
264 submenu: [
265 /*
266 Not the reload, DevTools or zoom roles: those act on the focused
267 webContents, so with a browser page focused they reload or zoom
268 the guest page and the app UI appears stuck. These always target
269 the main window (see DesktopWindow.zoomMain).
270 */
271 { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") },
272 {
273 label: "Use Bundled UI",
274 enabled: !environment.isDevelopment,
275 click: localUiClick(),
276 },
277 {
278 label: "Force Reload",
279 accelerator: "Shift+CmdOrCtrl+R",
280 click: mainContentsClick("forceReload"),
281 },
282 {
283 label: "Toggle Developer Tools",
284 accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I",
285 click: mainContentsClick("toggleDevTools"),
286 },
287 { type: "separator" },
288 { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
289 { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
290 {
291 label: "Zoom In",
292 accelerator: "CmdOrCtrl+Plus",
293 visible: false,
294 click: zoomClick("in"),
295 },
296 { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") },
297 { type: "separator" },
298 { role: "togglefullscreen" },
299 ],
300 },
301 { role: "windowMenu" },
302 {
303 role: "help",
304 submenu: [
305 {
306 label: "Check for Updates...",
307 click: checkForUpdatesClick,
308 },
309 ],
310 },
311 );
312
313 yield* electronMenu.setApplicationMenu(template);
314 }).pipe(Effect.withSpan("desktop.menu.configure"));
315
316 return DesktopApplicationMenu.of({
317 configure,
318 });
319});
320
321export const layer = Layer.effect(DesktopApplicationMenu, make);