Files
AgentCompany/src/main/desktop-shell-menu.ts
T
2026-04-17 14:03:56 -04:00

278 lines
7.7 KiB
TypeScript

import { dirname, join } from "node:path";
import {
app,
BrowserWindow,
dialog,
Menu,
nativeImage,
shell,
Tray,
} from "electron";
import type { IpcMainInvokeEvent } from "electron";
import { IPC_CHANNELS } from "@shared/contracts";
import type { DesktopResult } from "@shared/contracts";
import { ok } from "./ipc-core-common";
export interface DesktopShellMenuDependencies {
iconPath: string;
isDev: boolean;
onCheckForUpdates: () => Promise<void>;
onBackupProfile: () => Promise<void>;
onShortcut: (action: "toggle-command-palette" | "toggle-console" | "refresh") => void;
getMainWindow: () => BrowserWindow | null;
ensureWindow: () => Promise<BrowserWindow | null>;
requestQuitWithConfirmation: () => Promise<void>;
registerHandle: (
channel: string,
handler: (event: IpcMainInvokeEvent, ...args: unknown[]) => Promise<DesktopResult<unknown>> | DesktopResult<unknown>,
) => void;
}
export function createDesktopShellMenu(deps: DesktopShellMenuDependencies) {
const {
iconPath,
isDev,
onCheckForUpdates,
onBackupProfile,
onShortcut,
getMainWindow,
ensureWindow,
requestQuitWithConfirmation,
registerHandle,
} = deps;
let appTray: Tray | null = null;
function buildMenu() {
const macAppMenu: Electron.MenuItemConstructorOptions = {
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "services" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{
label: `Quit ${app.name}`,
accelerator: "CmdOrCtrl+Q",
click: () => void requestQuitWithConfirmation(),
},
],
};
const template: Electron.MenuItemConstructorOptions[] = [
...(process.platform === "darwin" ? [macAppMenu] : []),
{
label: "File",
submenu: [
{
label: "Open Workspace Folder",
accelerator: "CmdOrCtrl+O",
click: async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
});
const targetPath = result.filePaths[0];
if (result.canceled || !targetPath) return;
shell.openPath(targetPath);
},
},
{ type: "separator" },
{
label: "Backup Profile",
accelerator: "CmdOrCtrl+Shift+B",
click: async () => {
await onBackupProfile();
},
},
...(process.platform === "darwin" ? [] : [
{ type: "separator" as const },
{
label: "Quit",
accelerator: "Alt+F4",
click: () => void requestQuitWithConfirmation(),
},
]),
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{
label: "Command Palette",
accelerator: "CmdOrCtrl+K",
click: () => onShortcut("toggle-command-palette"),
},
{
label: "Toggle Console",
accelerator: "CmdOrCtrl+`",
click: () => onShortcut("toggle-console"),
},
{
label: "Refresh Data",
accelerator: "CmdOrCtrl+R",
click: () => onShortcut("refresh"),
},
{ type: "separator" },
{
role: "toggleDevTools",
// Hidden in packaged builds unless opted in via env var so power
// users can still inspect rendered state without a dev build.
visible: isDev || process.env.AGENTCOMPANY_ENABLE_DEVTOOLS === "true",
},
],
},
{
label: "Run",
submenu: [
{
label: "Check For Updates",
click: async () => {
await onCheckForUpdates();
},
},
],
},
];
const menu = Menu.buildFromTemplate(template);
const mainWindow = getMainWindow();
if (process.platform === "darwin") {
Menu.setApplicationMenu(menu);
} else {
// Frameless windows: hide native menu bar but keep keyboard accelerators active
Menu.setApplicationMenu(menu);
mainWindow?.setMenuBarVisibility(false);
mainWindow?.setAutoHideMenuBar(true);
}
}
function createTray() {
if (appTray) {
return;
}
const assetsDir = dirname(iconPath);
let trayIcon: Electron.NativeImage;
if (process.platform === "darwin") {
// macOS menu bar: use Template images (monochrome black + alpha).
// Electron auto-detects the @2x variant when the base name includes "Template".
const templatePath = join(assetsDir, "tray-iconTemplate.png");
trayIcon = nativeImage.createFromPath(templatePath);
if (!trayIcon.isEmpty()) {
trayIcon.setTemplateImage(true);
}
} else {
// Windows / Linux: use the color tray icon
const trayPath = join(assetsDir, "tray-icon.png");
trayIcon = nativeImage.createFromPath(trayPath);
}
// Fallback: resize the full app icon
if (trayIcon.isEmpty()) {
const fallback = nativeImage.createFromPath(iconPath);
if (!fallback.isEmpty()) {
trayIcon = fallback.resize({ width: 32, height: 32 });
if (process.platform === "darwin") {
trayIcon.setTemplateImage(true);
}
}
}
appTray = new Tray(trayIcon.isEmpty() ? nativeImage.createEmpty() : trayIcon);
appTray.setToolTip("AgentCompany");
appTray.setContextMenu(
Menu.buildFromTemplate([
{
label: "Show AgentCompany",
click: () => {
void ensureWindow().then((win) => {
if (win) {
win.show();
win.focus();
}
});
},
},
{
label: "Hide AgentCompany",
click: () => {
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide();
}
},
},
{ type: "separator" },
{
label: "Quit",
click: () => void requestQuitWithConfirmation(),
},
]),
);
appTray.on("click", () => {
void ensureWindow().then((win) => {
if (!win) return;
if (win.isVisible()) {
win.hide();
return;
}
win.show();
win.focus();
});
});
}
function registerWindowControlIpc() {
// Window control IPC for custom title bar (Windows/Linux frameless windows).
// Routed through registerHandle so the trusted-sender check blocks
// embedded webContents from closing/minimizing the app.
registerHandle(IPC_CHANNELS.windowMinimize, () => {
getMainWindow()?.minimize();
return ok(null);
});
registerHandle(IPC_CHANNELS.windowMaximize, () => {
const mainWindow = getMainWindow();
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
return ok(null);
});
registerHandle(IPC_CHANNELS.windowClose, () => {
getMainWindow()?.close();
return ok(null);
});
registerHandle(IPC_CHANNELS.windowIsMaximized, () => {
return ok(getMainWindow()?.isMaximized() ?? false);
});
}
function destroy() {
appTray?.destroy();
appTray = null;
}
return {
buildMenu,
createTray,
registerWindowControlIpc,
destroy,
};
}