import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Schema from "effect/Schema"; import type * as Electron from "electron"; import { makeComponentLogger } from "../app/DesktopObservability.ts";import * as ElectronApp from "../electron/ElectronApp.ts";import * as ElectronDialog from "../electron/ElectronDialog.ts";import * as ElectronMenu from "../electron/ElectronMenu.ts";import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";import * as DesktopUpdates from "../updates/DesktopUpdates.ts";import * as DesktopWindow from "./DesktopWindow.ts"; export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()( "DesktopApplicationMenuActionError", { action: Schema.String, cause: Schema.Defect(), },) { override get message(): string { return `Desktop menu action "${this.action}" failed.`; }} export class DesktopApplicationMenu extends Context.Service< DesktopApplicationMenu, { readonly configure: Effect.Effect<void>; }>()("@t3tools/desktop/window/DesktopApplicationMenu") {} type DesktopApplicationMenuRuntimeServices = | DesktopUpdates.DesktopUpdates | DesktopWindow.DesktopWindow | ElectronDialog.ElectronDialog; const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater"); const { logError: logMenuError } = makeComponentLogger("desktop-menu"); const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* ( action: string,): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.dispatchMenuAction(action);}); const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( direction: DesktopWindow.MainWindowZoomDirection,): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> { const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.zoomMain(direction);}); const checkForUpdatesFromMenu = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; const result = yield* updates.check("menu"); const updateState = result.state; if (updateState.status === "up-to-date") { yield* electronDialog.showMessageBox({ type: "info", title: "You're up to date!", message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`, buttons: ["OK"], }); } else if (updateState.status === "error") { yield* electronDialog.showMessageBox({ type: "warning", title: "Update check failed", message: "Could not check for updates.", detail: updateState.message ?? "An unknown error occurred. Please try again later.", buttons: ["OK"], }); }}).pipe(Effect.withSpan("desktop.menu.checkForUpdates")); const handleCheckForUpdatesMenuClick = Effect.gen(function* () { const updates = yield* DesktopUpdates.DesktopUpdates; const electronDialog = yield* ElectronDialog.ElectronDialog; const disabledReason = yield* updates.disabledReason; if (Option.isSome(disabledReason)) { yield* logUpdaterInfo("manual update check requested, but updates are disabled", { disabledReason: disabledReason.value, }); yield* electronDialog.showMessageBox({ type: "info", title: "Updates unavailable", message: "Automatic updates are not available right now.", detail: disabledReason.value, buttons: ["OK"], }); return; } const desktopWindow = yield* DesktopWindow.DesktopWindow; yield* desktopWindow.ensureMain; yield* checkForUpdatesFromMenu;}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick")); /** @public Service construction is part of the canonical Effect module API. */export const make = Effect.gen(function* () { const electronApp = yield* ElectronApp.ElectronApp; const electronMenu = yield* ElectronMenu.ElectronMenu; const environment = yield* DesktopEnvironment.DesktopEnvironment; const appName = yield* electronApp.name; const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>(); const runPromise = Effect.runPromiseWith(context); const runMenuEffect = <E>( action: string, effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>, ) => { void runPromise( effect.pipe( Effect.annotateLogs({ action }), Effect.withSpan("desktop.menu.action"), Effect.catchCause((cause) => { const error = new DesktopApplicationMenuActionError({ action, cause }); return logMenuError(error.message, { error }); }), ), ); }; const configure = Effect.gen(function* () { const checkForUpdatesClick = () => { runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick); }; const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; const template: Electron.MenuItemConstructorOptions[] = []; if (environment.platform === "darwin") { template.push({ label: appName, submenu: [ { role: "about" }, { label: "Check for Updates...", click: checkForUpdatesClick, }, { type: "separator" }, { label: "Settings...", accelerator: "CmdOrCtrl+,", click: settingsClick, }, { type: "separator" }, { role: "services" }, { type: "separator" }, { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" }, ], }); } template.push( { label: "File", submenu: [ ...(environment.platform === "darwin" ? [] : [ { label: "Settings...", accelerator: "CmdOrCtrl+,", click: settingsClick, }, { type: "separator" as const }, ]), { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, { role: "editMenu" }, { label: "View", submenu: [ { role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, { type: "separator" }, /* Not the zoom roles: those act on the focused webContents, so with an embedded preview WebContentsView focused they zoom the guest page and the app UI appears stuck. These always zoom the main window (see DesktopWindow.zoomMain). */ { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") }, { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") }, { label: "Zoom In", accelerator: "CmdOrCtrl+Plus", visible: false, click: zoomClick("in"), }, { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") }, { type: "separator" }, { role: "togglefullscreen" }, ], }, { role: "windowMenu" }, { role: "help", submenu: [ { label: "Check for Updates...", click: checkForUpdatesClick, }, ], }, ); yield* electronMenu.setApplicationMenu(template); }).pipe(Effect.withSpan("desktop.menu.configure")); return DesktopApplicationMenu.of({ configure, });}); export const layer = Layer.effect(DesktopApplicationMenu, make);
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import type * as Electron from "electron";
import { makeComponentLogger } from "../app/DesktopObservability.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopUpdates from "../updates/DesktopUpdates.ts";
import * as DesktopWindow from "./DesktopWindow.ts";
export class DesktopApplicationMenuActionError extends Schema.TaggedError<DesktopApplicationMenuActionError>()(
"DesktopApplicationMenuActionError",
{
action: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Desktop menu action "${this.action}" failed.`;
}
}
export class DesktopApplicationMenu extends Context.Service<
DesktopApplicationMenu,
{
readonly configure: Effect.Effect<void>;
}
>()("@t3tools/desktop/window/DesktopApplicationMenu") {}
type DesktopApplicationMenuRuntimeServices =
| DesktopUpdates.DesktopUpdates
| DesktopWindow.DesktopWindow
| ElectronDialog.ElectronDialog;
const { logInfo: logUpdaterInfo } = makeComponentLogger("desktop-updater");
const { logError: logMenuError } = makeComponentLogger("desktop-menu");
const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function* (
action: string,
): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.dispatchMenuAction(action);
});
const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
direction: DesktopWindow.MainWindowZoomDirection,
): Effect.fn.Return<void, never, DesktopWindow.DesktopWindow> {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.zoomMain(direction);
});
const checkForUpdatesFromMenu = Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const result = yield* updates.check("menu");
const updateState = result.state;
if (updateState.status === "up-to-date") {
yield* electronDialog.showMessageBox({
type: "info",
title: "You're up to date!",
message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
buttons: ["OK"],
});
} else if (updateState.status === "error") {
yield* electronDialog.showMessageBox({
type: "warning",
title: "Update check failed",
message: "Could not check for updates.",
detail: updateState.message ?? "An unknown error occurred. Please try again later.",
buttons: ["OK"],
});
}
}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
const updates = yield* DesktopUpdates.DesktopUpdates;
const electronDialog = yield* ElectronDialog.ElectronDialog;
const disabledReason = yield* updates.disabledReason;
if (Option.isSome(disabledReason)) {
yield* logUpdaterInfo("manual update check requested, but updates are disabled", {
disabledReason: disabledReason.value,
});
yield* electronDialog.showMessageBox({
type: "info",
title: "Updates unavailable",
message: "Automatic updates are not available right now.",
detail: disabledReason.value,
buttons: ["OK"],
});
return;
}
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.ensureMain;
yield* checkForUpdatesFromMenu;
}).pipe(Effect.withSpan("desktop.menu.handleCheckForUpdatesClick"));
/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const electronMenu = yield* ElectronMenu.ElectronMenu;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const appName = yield* electronApp.name;
const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
const runPromise = Effect.runPromiseWith(context);
const runMenuEffect = <E>(
action: string,
effect: Effect.Effect<void, E, DesktopApplicationMenuRuntimeServices>,
) => {
void runPromise(
effect.pipe(
Effect.annotateLogs({ action }),
Effect.withSpan("desktop.menu.action"),
Effect.catchCause((cause) => {
const error = new DesktopApplicationMenuActionError({ action, cause });
return logMenuError(error.message, { error });
}),
),
);
};
const configure = Effect.gen(function* () {
const checkForUpdatesClick = () => {
runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
};
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
};
const template: Electron.MenuItemConstructorOptions[] = [];
if (environment.platform === "darwin") {
template.push({
label: appName,
submenu: [
{ role: "about" },
{
label: "Check for Updates...",
click: checkForUpdatesClick,
},
{ type: "separator" },
{
label: "Settings...",
accelerator: "CmdOrCtrl+,",
click: settingsClick,
},
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
});
}
template.push(
{
label: "File",
submenu: [
...(environment.platform === "darwin"
? []
: [
{
label: "Settings...",
accelerator: "CmdOrCtrl+,",
click: settingsClick,
},
{ type: "separator" as const },
]),
{ role: environment.platform === "darwin" ? "close" : "quit" },
],
},
{ role: "editMenu" },
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
/*
Not the zoom roles: those act on the focused webContents, so with
an embedded preview WebContentsView focused they zoom the guest
page and the app UI appears stuck. These always zoom the main
window (see DesktopWindow.zoomMain).
*/
{ label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
{ label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
{
label: "Zoom In",
accelerator: "CmdOrCtrl+Plus",
visible: false,
click: zoomClick("in"),
},
{ label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{ role: "windowMenu" },
{
role: "help",
submenu: [
{
label: "Check for Updates...",
click: checkForUpdatesClick,
},
],
},
);
yield* electronMenu.setApplicationMenu(template);
}).pipe(Effect.withSpan("desktop.menu.configure"));
return DesktopApplicationMenu.of({
configure,
});
});
export const layer = Layer.effect(DesktopApplicationMenu, make);