Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion frontend/forge.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
import type { ForgeConfig, IForgePlugin } from "@electron-forge/shared-types";
import { VitePlugin } from "@electron-forge/plugin-vite";
import MakerNSIS from "./makers/maker-nsis";
import MakerAppImage from "./makers/maker-appimage";
import { writeFileSync } from "node:fs";
import { prepareBrandedDevElectronExecutable } from "./src/main/dev-electron-branding";

// Default GitHub release target (production). aoagents was the temporary rewrite
// home; releases land on AgentWrapper (spec §1.1).
Expand Down Expand Up @@ -144,6 +145,21 @@ const config: ForgeConfig = {
},
],
plugins: [
// Forge dev mode launches Electron's stock macOS binary directly. Returning
// a branded alias keeps the native app menu from showing "Electron".
{
__isElectronForgePlugin: true,
name: "branded-dev-electron",
init: () => {},
startLogic: async () => {
try {
return prepareBrandedDevElectronExecutable({ platform: process.platform }) ?? false;
} catch (err) {
console.warn("failed to brand Electron dev executable:", err);
return false;
}
},
} satisfies IForgePlugin,
new VitePlugin({
build: [
{ entry: "src/main.ts", config: "vite.main.config.ts", target: "main" },
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
clipboard,
dialog,
ipcMain,
Menu,
net,
nativeImage,
Notification as ElectronNotification,
Expand Down Expand Up @@ -51,6 +52,7 @@ import { createBrowserViewHost, type BrowserViewHost } from "./main/browser-view
import { connectSupervisor, type SupervisorLinkHandle } from "./main/supervisor-link";
import { shouldLinkOnAttach } from "./main/daemon-owner";
import { readMigrationState, updateMigration, writeAppStateMarker, type MigrationState } from "./main/app-state";
import { installApplicationMenu } from "./main/app-menu";

// Globals injected at compile time by @electron-forge/plugin-vite.
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string | undefined;
Expand Down Expand Up @@ -1175,6 +1177,12 @@ app.whenReady().then(async () => {

registerRendererProtocol();
applyRuntimeAppIcon();
installApplicationMenu({
Menu,
platform: process.platform,
isDev,
openExternal: (url) => shell.openExternal(url),
});
createWindow();
void startDaemon();
initAutoUpdates();
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/main/app-menu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { APP_DISPLAY_NAME, buildApplicationMenuTemplate, installApplicationMenu } from "./app-menu";

type MenuItem = {
label?: string;
role?: string;
submenu?: MenuItem[];
type?: string;
};

function collectLabels(items: MenuItem[]): string[] {
return items.flatMap((item) => [
item.label ?? "",
...(Array.isArray(item.submenu) ? collectLabels(item.submenu) : []),
]);
}

function collectRoles(items: MenuItem[]): string[] {
return items.flatMap((item) => [item.role ?? "", ...(Array.isArray(item.submenu) ? collectRoles(item.submenu) : [])]);
}

describe("buildApplicationMenuTemplate", () => {
it("brands the macOS app menu as Agent Orchestrator", () => {
const template = buildApplicationMenuTemplate({ platform: "darwin", isDev: true });
const appMenu = template[0] as MenuItem;

expect(appMenu.label).toBe(APP_DISPLAY_NAME);
expect(appMenu.submenu?.map((item) => item.role)).toEqual([
"about",
undefined,
"services",
undefined,
"hide",
"hideOthers",
"unhide",
undefined,
"quit",
]);
expect(appMenu.submenu?.find((item) => item.role === "about")?.label).toBe(`About ${APP_DISPLAY_NAME}`);
expect(appMenu.submenu?.find((item) => item.role === "hide")?.label).toBe(`Hide ${APP_DISPLAY_NAME}`);
expect(appMenu.submenu?.find((item) => item.role === "quit")?.label).toBe(`Quit ${APP_DISPLAY_NAME}`);
expect(collectLabels(template as MenuItem[]).some((label) => /electron/i.test(label))).toBe(false);
});

it("keeps standard top-level menus without a macOS app menu on Linux and Windows", () => {
const linuxTemplate = buildApplicationMenuTemplate({ platform: "linux", isDev: true }) as MenuItem[];
const windowsTemplate = buildApplicationMenuTemplate({ platform: "win32", isDev: true }) as MenuItem[];

expect(linuxTemplate.map((item) => item.label)).toEqual(["File", "Edit", "View", "Window", "Help"]);
expect(windowsTemplate.map((item) => item.label)).toEqual(["File", "Edit", "View", "Window", "Help"]);
expect(linuxTemplate[0]?.label).not.toBe(APP_DISPLAY_NAME);
expect(windowsTemplate[0]?.label).not.toBe(APP_DISPLAY_NAME);
});

it("keeps reload and devtools roles in development only", () => {
const devRoles = collectRoles(buildApplicationMenuTemplate({ platform: "darwin", isDev: true }) as MenuItem[]);
const prodRoles = collectRoles(buildApplicationMenuTemplate({ platform: "darwin", isDev: false }) as MenuItem[]);

expect(devRoles).toEqual(expect.arrayContaining(["reload", "forceReload", "toggleDevTools"]));
expect(prodRoles).not.toContain("reload");
expect(prodRoles).not.toContain("forceReload");
expect(prodRoles).not.toContain("toggleDevTools");
expect(prodRoles).toEqual(expect.arrayContaining(["resetZoom", "zoomIn", "zoomOut", "togglefullscreen"]));
});
});

describe("installApplicationMenu", () => {
it("builds and installs the native menu from the branded template", () => {
const builtMenu = { id: "native-menu" };
const Menu = {
buildFromTemplate: vi.fn(() => builtMenu),
setApplicationMenu: vi.fn(),
};

installApplicationMenu({ Menu, platform: "darwin", isDev: true });

expect(Menu.buildFromTemplate).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ label: APP_DISPLAY_NAME })]),
);
expect(Menu.setApplicationMenu).toHaveBeenCalledWith(builtMenu);
});
});
133 changes: 133 additions & 0 deletions frontend/src/main/app-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { MenuItemConstructorOptions } from "electron";

export const APP_DISPLAY_NAME = "Agent Orchestrator";
export const APP_REPOSITORY_URL = "https://github.com/AgentWrapper/agent-orchestrator";

type OpenExternal = (url: string) => Promise<void> | void;

export type ApplicationMenuOptions = {
platform: NodeJS.Platform;
isDev: boolean;
openExternal?: OpenExternal;
};

export type ApplicationMenuApi<TMenu> = {
buildFromTemplate: (template: MenuItemConstructorOptions[]) => TMenu;
setApplicationMenu: (menu: TMenu | null) => void;
};

function separator(): MenuItemConstructorOptions {
return { type: "separator" };
}

function buildMacAppMenu(): MenuItemConstructorOptions {
return {
label: APP_DISPLAY_NAME,
submenu: [
{ role: "about", label: `About ${APP_DISPLAY_NAME}` },
separator(),
{ role: "services" },
separator(),
{ role: "hide", label: `Hide ${APP_DISPLAY_NAME}` },
{ role: "hideOthers" },
{ role: "unhide" },
separator(),
{ role: "quit", label: `Quit ${APP_DISPLAY_NAME}` },
],
};
}

function buildFileMenu(platform: NodeJS.Platform): MenuItemConstructorOptions {
return {
label: "File",
submenu: platform === "darwin" ? [{ role: "close" }] : [{ role: "close" }, separator(), { role: "quit" }],
};
}

function buildEditMenu(): MenuItemConstructorOptions {
return {
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
separator(),
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "pasteAndMatchStyle" },
{ role: "delete" },
{ role: "selectAll" },
],
};
}

function buildViewMenu(isDev: boolean): MenuItemConstructorOptions {
const browserControls: MenuItemConstructorOptions[] = [
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
separator(),
{ role: "togglefullscreen" },
];

return {
label: "View",
submenu: isDev
? [{ role: "reload" }, { role: "forceReload" }, { role: "toggleDevTools" }, separator(), ...browserControls]
: browserControls,
};
}

function buildWindowMenu(platform: NodeJS.Platform): MenuItemConstructorOptions {
return {
label: "Window",
submenu:
platform === "darwin"
? [{ role: "minimize" }, { role: "zoom" }, separator(), { role: "front" }]
: [{ role: "minimize" }, { role: "close" }],
};
}

function buildHelpMenu(openExternal?: OpenExternal): MenuItemConstructorOptions {
return {
label: "Help",
submenu: [
{
label: `${APP_DISPLAY_NAME} on GitHub`,
enabled: Boolean(openExternal),
click: () => {
void openExternal?.(APP_REPOSITORY_URL);
},
},
],
};
}

export function buildApplicationMenuTemplate({
platform,
isDev,
openExternal,
}: ApplicationMenuOptions): MenuItemConstructorOptions[] {
const template: MenuItemConstructorOptions[] = [
buildFileMenu(platform),
buildEditMenu(),
buildViewMenu(isDev),
buildWindowMenu(platform),
buildHelpMenu(openExternal),
];

if (platform === "darwin") {
return [buildMacAppMenu(), ...template];
}

return template;
}

export function installApplicationMenu<TMenu>({
Menu,
platform,
isDev,
openExternal,
}: ApplicationMenuOptions & { Menu: ApplicationMenuApi<TMenu> }): void {
Menu.setApplicationMenu(Menu.buildFromTemplate(buildApplicationMenuTemplate({ platform, isDev, openExternal })));
}
Loading
Loading