Open History to see the last commit
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 * 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 DesktopWindow from "./DesktopWindow.ts";
15
16export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()(
17 "DesktopApplicationMenuActionError",
18 {
19 action: Schema.String,
20 cause: Schema.Defect(),
21 },
22) {
23 override get message(): string {
24 return `Desktop menu action "${this.action}" failed.`;
25 }
26}
27
28export class DesktopApplicationMenu extends Context.Service<
29 DesktopApplicationMenu,
30 {
31 readonly configure: Effect.Effect<void>;
32 }
33>()("@t3tools/desktop/window/DesktopApplicationMenu") {}
34
35type DesktopApplicationMenuRuntimeServices =
36 | DesktopUpdates.DesktopUpdates
37 | DesktopWindow.DesktopWindow
38 | ElectronDialog.ElectronDialog;
39
40const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater");
41
42const { logError: logMenuError } = makeComponentLogger("desktop-menu");
43
44const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* (
45 action: string,
46): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
47 const desktopWindow = yield* DesktopWindow.DesktopWindow;
48 yield* desktopWindow.dispatchMenuAction(action, {
49 reveal: action !== "paste-as-text",
50 });
51});
52
53const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
54 direction: DesktopWindow.MainWindowZoomDirection,
55): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
56 const desktopWindow = yield* DesktopWindow.DesktopWindow;
57 yield* desktopWindow.zoomMain(direction);
58});
59
60const runMainContentsCommand = Effect.fn("desktop.menu.runMainContentsCommand")(function* (
61 command: DesktopWindow.MainWindowContentsCommand,
62): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
63 const desktopWindow = yield* DesktopWindow.DesktopWindow;
64 yield* desktopWindow.runMainContentsCommand(command);
65});
66
67const checkForUpdatesFromMenu = Effect.gen(function* () {
68 const updates = yield* DesktopUpdates.DesktopUpdates;
69 const electronDialog = yield* ElectronDialog.ElectronDialog;
70 const result = yield* updates.check("menu");
71 const updateState = result.state;
72
73 if (updateState.status === "up-to-date") {
74 yield* electronDialog.showMessageBox({
75 type: "info",
76 title: "You're up to date!",
77 message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
78 buttons: ["OK"],
79 });
80 } else if (updateState.status === "error") {
81 yield* electronDialog.showMessageBox({
82 type: "warning",
83 title: "Update check failed",
84 message: "Could not check for updates.",
85 detail: updateState.message ?? "An unknown error occurred. Please try again later.",
86 buttons: ["OK"],
87 });
88 }
89}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
90
91const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
92 const updates = yield* DesktopUpdates.DesktopUpdates;
93 const electronDialog = yield* ElectronDialog.ElectronDialog;
94 const disabledReason = yield* updates.disabledReason;
95 if (Option.isSome(disabledReason)) {
96 yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
97 disabledReason: disabledReason.value,
98 });
99 yield* electronDialog.showMessageBox({
100 type: "info",
101 title: "Updates unavailable",
102 message: "Automatic updates are not available right now.",
103 detail: disabledReason.value,
104 buttons: ["OK"],
105 });
106 return;
107 }
108
109 // Opening a window before backend confirmation can strand it on failed auth bootstrap.
110 const desktopWindow = yield* DesktopWindow.DesktopWindow;
111 yield* desktopWindow.activate;
112 yield* checkForUpdatesFromMenu;
113}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick"));
114
115/** @public */
116export const make = Effect.gen(function* () {
117 const electronMenu = yield* ElectronMenu.ElectronMenu;
118 const environment = yield* DesktopEnvironment.DesktopEnvironment;
119 const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
120 const runPromise = Effect.runPromiseWith(context);
121
122 const runMenuEffect = <E>(
123 action: string,
124 effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>,
125 ) => {
126 void runPromise(
127 effect.pipe(
128 Effect.annotateLogs({ action }),
129 Effect.withSpan("desktop.menu.action"),
130 Effect.catchCause((cause) => {
131 const error = new DesktopApplicationMenuActionError({ action, cause });
132 return logMenuError(error.message, { error });
133 }),
134 ),
135 );
136 };
137
138 const configure = Effect.gen(function* () {
139 const checkForUpdatesClick = () => {
140 runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
141 };
142 const settingsClick = () => {
143 runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
144 };
145 // Chromium and the renderer already handle this chord; injecting another paste duplicates text.
146 const pasteAsTextClick = (
147 _item: Electron.MenuItem,
148 _window: Electron.BaseWindow | undefined,
149 event: Electron.KeyboardEvent,
150 ) => {
151 if (event.triggeredByAccelerator === true) return;
152 runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
153 };
154 const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
155 runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
156 };
157 const mainContentsClick = (command: DesktopWindow.MainWindowContentsCommand) => () => {
158 runMenuEffect(command, runMainContentsCommand(command));
159 };
160 const template: Electron.MenuItemConstructorOptions[] = [];
161
162 if (environment.platform === "darwin") {
163 template.push({
164 label: environment.displayName,
165 submenu: [
166 { role: "about", label: `About ${environment.displayName}` },
167 {
168 label: "Check for Updates...",
169 click: checkForUpdatesClick,
170 },
171 { type: "separator" },
172 {
173 label: "Settings...",
174 accelerator: "CmdOrCtrl+,",
175 click: settingsClick,
176 },
177 { type: "separator" },
178 { role: "services" },
179 { type: "separator" },
180 { role: "hide", label: `Hide ${environment.displayName}` },
181 { role: "hideOthers" },
182 { role: "unhide" },
183 { type: "separator" },
184 { role: "quit", label: `Quit ${environment.displayName}` },
185 ],
186 });
187 }
188
189 template.push(
190 {
191 label: "File",
192 submenu: [
193 ...(environment.platform === "darwin"
194 ? []
195 : [
196 {
197 label: "Settings...",
198 accelerator: "CmdOrCtrl+,",
199 click: settingsClick,
200 },
201 { type: "separator" as const },
202 ]),
203 {
204 label: "Close Developer Tools",
205 accelerator: "CmdOrCtrl+W",
206 visible: false,
207 click: () => {
208 Electron.webContents
209 .getAllWebContents()
210 .find((contents) => !contents.isDestroyed() && contents.isDevToolsFocused())
211 ?.closeDevTools();
212 },
213 },
214 // Cmd/Ctrl+W belongs to the renderer's tab close; it must never close the window.
215 environment.platform === "darwin"
216 ? { label: "Close Window", click: (_item, window) => window?.close() }
217 : { role: "quit" },
218 ],
219 },
220 {
221 label: "Edit",
222 submenu: [
223 { role: "undo" },
224 { role: "redo" },
225 { type: "separator" },
226 { role: "cut" },
227 { role: "copy" },
228 { role: "paste" },
229 {
230 label: "Paste as Text",
231 accelerator: "CmdOrCtrl+Shift+V",
232 click: pasteAsTextClick,
233 },
234 { role: "delete" },
235 { type: "separator" },
236 { role: "selectAll" },
237 ...(environment.platform === "darwin"
238 ? [
239 { type: "separator" as const },
240 {
241 label: "Speech",
242 submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }],
243 },
244 ]
245 : []),
246 ],
247 },
248 {
249 label: "View",
250 submenu: [
251 /* Native reload/DevTools/zoom roles target focused guests; these commands must target the app. */
252 { label: "Reload", accelerator: "CmdOrCtrl+R", click: mainContentsClick("reload") },
253 {
254 label: "Force Reload",
255 accelerator: "Shift+CmdOrCtrl+R",
256 click: mainContentsClick("forceReload"),
257 },
258 {
259 label: "Toggle Developer Tools",
260 accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I",
261 click: mainContentsClick("toggleDevTools"),
262 },
263 { type: "separator" },
264 { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
265 { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
266 {
267 label: "Zoom In",
268 accelerator: "CmdOrCtrl+Plus",
269 visible: false,
270 click: zoomClick("in"),
271 },
272 ...(environment.platform === "darwin"
273 ? [
274 {
275 label: "Zoom In",
276 accelerator: "CmdOrCtrl+Shift+=",
277 visible: false,
278 click: zoomClick("in"),
279 },
280 ]
281 : []),
282 { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") },
283 // Keypad codes do not match number-row accelerators.
284 {
285 label: "Actual Size",
286 accelerator: "CmdOrCtrl+num0",
287 visible: false,
288 click: zoomClick("reset"),
289 },
290 {
291 label: "Zoom In",
292 accelerator: "CmdOrCtrl+numadd",
293 visible: false,
294 click: zoomClick("in"),
295 },
296 {
297 label: "Zoom Out",
298 accelerator: "CmdOrCtrl+numsub",
299 visible: false,
300 click: zoomClick("out"),
301 },
302 { type: "separator" },
303 { role: "togglefullscreen" },
304 ],
305 },
306 {
307 // Electron's default Window submenu adds a Ctrl+W close role off macOS.
308 role: "windowMenu",
309 submenu: [
310 { role: "minimize" },
311 { role: "zoom" },
312 ...(environment.platform === "darwin"
313 ? [{ type: "separator" as const }, { role: "front" as const }]
314 : []),
315 ],
316 },
317 {
318 role: "help",
319 submenu: [
320 {
321 label: "Check for Updates...",
322 click: checkForUpdatesClick,
323 },
324 ],
325 },
326 );
327
328 yield* electronMenu.setApplicationMenu(template);
329 }).pipe(Effect.withSpan("desktop.menu.configure"));
330
331 return DesktopApplicationMenu.of({
332 configure,
333 });
334});
335
336export const layer = Layer.effect(DesktopApplicationMenu, make);