From 53cbaca76e3808e59b919a64ffb2bc3eebf41b21 Mon Sep 17 00:00:00 2001 From: swyam sharma Date: Wed, 8 Jul 2026 13:03:48 +0530 Subject: [PATCH 1/2] fix(frontend): brand native Electron menu --- frontend/forge.config.ts | 18 ++- frontend/src/main.ts | 8 ++ frontend/src/main/app-menu.test.ts | 83 +++++++++++ frontend/src/main/app-menu.ts | 133 ++++++++++++++++++ .../src/main/dev-electron-branding.test.ts | 132 +++++++++++++++++ frontend/src/main/dev-electron-branding.ts | 110 +++++++++++++++ 6 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 frontend/src/main/app-menu.test.ts create mode 100644 frontend/src/main/app-menu.ts create mode 100644 frontend/src/main/dev-electron-branding.test.ts create mode 100644 frontend/src/main/dev-electron-branding.ts diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index fa7fd35aae..770473fe84 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -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). @@ -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" }, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index bec40303dc..df86016c7d 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -4,6 +4,7 @@ import { clipboard, dialog, ipcMain, + Menu, net, nativeImage, Notification as ElectronNotification, @@ -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; @@ -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(); diff --git a/frontend/src/main/app-menu.test.ts b/frontend/src/main/app-menu.test.ts new file mode 100644 index 0000000000..e29a950367 --- /dev/null +++ b/frontend/src/main/app-menu.test.ts @@ -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); + }); +}); diff --git a/frontend/src/main/app-menu.ts b/frontend/src/main/app-menu.ts new file mode 100644 index 0000000000..11fb876b22 --- /dev/null +++ b/frontend/src/main/app-menu.ts @@ -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; + +export type ApplicationMenuOptions = { + platform: NodeJS.Platform; + isDev: boolean; + openExternal?: OpenExternal; +}; + +export type ApplicationMenuApi = { + 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({ + Menu, + platform, + isDev, + openExternal, +}: ApplicationMenuOptions & { Menu: ApplicationMenuApi }): void { + Menu.setApplicationMenu(Menu.buildFromTemplate(buildApplicationMenuTemplate({ platform, isDev, openExternal }))); +} diff --git a/frontend/src/main/dev-electron-branding.test.ts b/frontend/src/main/dev-electron-branding.test.ts new file mode 100644 index 0000000000..8f756be4e1 --- /dev/null +++ b/frontend/src/main/dev-electron-branding.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { APP_DISPLAY_NAME } from "./app-menu"; +import { + brandDevElectronApp, + DEV_ELECTRON_BUNDLE_ID, + prepareBrandedDevElectronExecutable, + resolveBrandedDevElectronExecutable, + resolveDevElectronInfoPlist, +} from "./dev-electron-branding"; + +const electronInfoPlist = ` + + + CFBundleDisplayName + Electron + CFBundleExecutable + Electron + CFBundleIdentifier + com.github.Electron + CFBundleName + Electron + +`; + +describe("brandDevElectronApp", () => { + it("brands the macOS Electron dev bundle metadata", () => { + let written = ""; + const writeFile = vi.fn((_filePath: string, contents: string) => { + written = contents; + }); + const touch = vi.fn(); + + const changed = brandDevElectronApp({ + platform: "darwin", + infoPlistPath: "/tmp/Electron.app/Contents/Info.plist", + exists: () => true, + readFile: () => electronInfoPlist, + writeFile, + touch, + }); + + expect(changed).toBe(true); + expect(written).toContain(`CFBundleDisplayName\n\t${APP_DISPLAY_NAME}`); + expect(written).toContain(`CFBundleExecutable\n\t${APP_DISPLAY_NAME}`); + expect(written).toContain(`CFBundleName\n\t${APP_DISPLAY_NAME}`); + expect(written).toContain(`CFBundleIdentifier\n\t${DEV_ELECTRON_BUNDLE_ID}`); + expect(writeFile).toHaveBeenCalledWith("/tmp/Electron.app/Contents/Info.plist", written); + expect(touch).toHaveBeenCalledWith("/tmp/Electron.app"); + }); + + it("does nothing outside macOS", () => { + const writeFile = vi.fn(); + + const changed = brandDevElectronApp({ + platform: "linux", + infoPlistPath: "/tmp/Electron.app/Contents/Info.plist", + exists: () => true, + readFile: () => electronInfoPlist, + writeFile, + }); + + expect(changed).toBe(false); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it("does nothing when the Electron dev bundle plist cannot be found", () => { + const changed = brandDevElectronApp({ + platform: "darwin", + infoPlistPath: "/tmp/missing/Info.plist", + exists: () => false, + }); + + expect(changed).toBe(false); + }); +}); + +describe("prepareBrandedDevElectronExecutable", () => { + it("creates a branded executable alias for Forge dev start", () => { + const symlink = vi.fn(); + const writeFile = vi.fn(); + const executablePath = "/repo/frontend/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"; + + const brandedPath = prepareBrandedDevElectronExecutable({ + platform: "darwin", + electronExecutablePath: executablePath, + infoPlistPath: "/repo/frontend/node_modules/electron/dist/Electron.app/Contents/Info.plist", + exists: (filePath) => !filePath.endsWith(`MacOS/${APP_DISPLAY_NAME}`), + readFile: () => electronInfoPlist, + writeFile, + symlink, + touch: vi.fn(), + }); + + expect(brandedPath).toBe( + `/repo/frontend/node_modules/electron/dist/Electron.app/Contents/MacOS/${APP_DISPLAY_NAME}`, + ); + expect(symlink).toHaveBeenCalledWith("Electron", brandedPath); + expect(writeFile).toHaveBeenCalled(); + }); + + it("does not create a dev executable alias outside macOS", () => { + const symlink = vi.fn(); + + const brandedPath = prepareBrandedDevElectronExecutable({ + platform: "linux", + electronExecutablePath: "/repo/electron", + symlink, + }); + + expect(brandedPath).toBeNull(); + expect(symlink).not.toHaveBeenCalled(); + }); +}); + +describe("resolveDevElectronInfoPlist", () => { + it("resolves Info.plist from the Electron executable path", () => { + expect( + resolveDevElectronInfoPlist("/repo/frontend/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron"), + ).toBe("/repo/frontend/node_modules/electron/dist/Electron.app/Contents/Info.plist"); + }); +}); + +describe("resolveBrandedDevElectronExecutable", () => { + it("resolves a sibling executable alias with the app display name", () => { + expect( + resolveBrandedDevElectronExecutable( + "/repo/frontend/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron", + ), + ).toBe("/repo/frontend/node_modules/electron/dist/Electron.app/Contents/MacOS/Agent Orchestrator"); + }); +}); diff --git a/frontend/src/main/dev-electron-branding.ts b/frontend/src/main/dev-electron-branding.ts new file mode 100644 index 0000000000..a1ffc1bd7f --- /dev/null +++ b/frontend/src/main/dev-electron-branding.ts @@ -0,0 +1,110 @@ +import { existsSync, readFileSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { APP_DISPLAY_NAME } from "./app-menu"; + +export const DEV_ELECTRON_BUNDLE_ID = "dev.agent-orchestrator.desktop.dev"; + +const requireElectron = createRequire(import.meta.url); + +type DevElectronBrandingOptions = { + platform: NodeJS.Platform; + appName?: string; + bundleIdentifier?: string; + electronExecutablePath?: string; + brandedExecutablePath?: string; + infoPlistPath?: string; + exists?: (filePath: string) => boolean; + readFile?: (filePath: string) => string; + writeFile?: (filePath: string, contents: string) => void; + symlink?: (target: string, filePath: string) => void; + touch?: (filePath: string) => void; +}; + +function defaultElectronExecutablePath(): string { + return requireElectron("electron") as string; +} + +export function resolveDevElectronInfoPlist(electronExecutablePath: string): string { + return path.resolve(path.dirname(electronExecutablePath), "..", "Info.plist"); +} + +export function resolveBrandedDevElectronExecutable( + electronExecutablePath: string, + appName = APP_DISPLAY_NAME, +): string { + return path.join(path.dirname(electronExecutablePath), appName); +} + +function escapePlistValue(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function replacePlistStringValue(plist: string, key: string, value: string): string { + const pattern = new RegExp(`(${key}\\s*)([^<]*)()`); + return plist.replace(pattern, `$1${escapePlistValue(value)}$3`); +} + +export function brandDevElectronApp({ + platform, + appName = APP_DISPLAY_NAME, + bundleIdentifier = DEV_ELECTRON_BUNDLE_ID, + electronExecutablePath, + infoPlistPath, + exists = existsSync, + readFile = (filePath) => readFileSync(filePath, "utf8"), + writeFile = (filePath, contents) => writeFileSync(filePath, contents), + touch = (filePath) => { + const now = new Date(); + utimesSync(filePath, now, now); + }, +}: DevElectronBrandingOptions): boolean { + if (platform !== "darwin") return false; + + const plistPath = + infoPlistPath ?? resolveDevElectronInfoPlist(electronExecutablePath ?? defaultElectronExecutablePath()); + if (!exists(plistPath)) return false; + + const current = readFile(plistPath); + const next = [ + ["CFBundleDisplayName", appName], + ["CFBundleExecutable", appName], + ["CFBundleName", appName], + ["CFBundleIdentifier", bundleIdentifier], + ].reduce((plist, [key, value]) => replacePlistStringValue(plist, key, value), current); + + if (next === current) return false; + + writeFile(plistPath, next); + touch(path.resolve(plistPath, "..", "..")); + return true; +} + +export function prepareBrandedDevElectronExecutable({ + platform, + appName = APP_DISPLAY_NAME, + electronExecutablePath, + brandedExecutablePath, + exists = existsSync, + symlink = symlinkSync, + ...brandingOptions +}: DevElectronBrandingOptions): string | null { + if (platform !== "darwin") return null; + + const electronPath = electronExecutablePath ?? defaultElectronExecutablePath(); + const brandedPath = brandedExecutablePath ?? resolveBrandedDevElectronExecutable(electronPath, appName); + + brandDevElectronApp({ + platform, + appName, + electronExecutablePath: electronPath, + exists, + ...brandingOptions, + }); + + if (!exists(brandedPath)) { + symlink(path.basename(electronPath), brandedPath); + } + + return brandedPath; +} From b9b6ab8f0498b2f937b682f31fc8cb82fbadc747 Mon Sep 17 00:00:00 2001 From: swyam sharma <84120511+areycruzer@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:47:05 +0530 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- frontend/src/main/dev-electron-branding.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/main/dev-electron-branding.ts b/frontend/src/main/dev-electron-branding.ts index a1ffc1bd7f..ecea448a5b 100644 --- a/frontend/src/main/dev-electron-branding.ts +++ b/frontend/src/main/dev-electron-branding.ts @@ -94,6 +94,10 @@ export function prepareBrandedDevElectronExecutable({ const electronPath = electronExecutablePath ?? defaultElectronExecutablePath(); const brandedPath = brandedExecutablePath ?? resolveBrandedDevElectronExecutable(electronPath, appName); + if (!exists(brandedPath)) { + symlink(path.basename(electronPath), brandedPath); + } + brandDevElectronApp({ platform, appName, @@ -102,9 +106,5 @@ export function prepareBrandedDevElectronExecutable({ ...brandingOptions, }); - if (!exists(brandedPath)) { - symlink(path.basename(electronPath), brandedPath); - } - return brandedPath; }