feat(FN-1074): add desktop tray and application menu
- Add dedicated desktop tray and menu modules, including platform-aware menu templates and tray status/visibility controls - Integrate tray setup, app menu initialization, and tray status exports into the Electron main process bootstrap - Add tray icon assets plus an icon generation script and update desktop package dependencies/lockfile - Expand desktop test coverage with new menu/tray test suites and main process integration assertions - Update desktop README with tray and application menu usage/documentation
This commit is contained in:
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => {
|
||||
const browserWindowInstance = {
|
||||
loadURL: vi.fn(),
|
||||
on: vi.fn(),
|
||||
isVisible: vi.fn(() => true),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
@@ -28,13 +29,27 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
const trayInstance = {
|
||||
setImage: vi.fn(),
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
const Tray = vi.fn(() => trayInstance);
|
||||
const Menu = { buildFromTemplate: vi.fn(() => ({ id: "mock-menu" })) };
|
||||
const nativeImage = { createEmpty: vi.fn(() => ({ id: "mock-image" })) };
|
||||
const Menu = {
|
||||
buildFromTemplate: vi.fn(() => ({ id: "mock-menu" })),
|
||||
setApplicationMenu: vi.fn(),
|
||||
};
|
||||
const nativeImage = {
|
||||
createEmpty: vi.fn(() => ({ id: "mock-image" })),
|
||||
createFromPath: vi.fn(() => ({
|
||||
resize: vi.fn(() => ({ id: "resized-image" })),
|
||||
})),
|
||||
};
|
||||
|
||||
const shell = {
|
||||
openExternal: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
@@ -44,6 +59,7 @@ const mocks = vi.hoisted(() => {
|
||||
Tray,
|
||||
Menu,
|
||||
nativeImage,
|
||||
shell,
|
||||
browserWindowInstance,
|
||||
};
|
||||
});
|
||||
@@ -55,6 +71,7 @@ vi.mock("electron", () => ({
|
||||
Tray: mocks.Tray,
|
||||
Menu: mocks.Menu,
|
||||
nativeImage: mocks.nativeImage,
|
||||
shell: mocks.shell,
|
||||
}));
|
||||
|
||||
async function importMainModule() {
|
||||
@@ -144,14 +161,14 @@ describe("main process", () => {
|
||||
expect(mocks.app.whenReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setupTray creates tray menu and hides window on close", async () => {
|
||||
it("setupTray configures tray interactions with provided tray instance", async () => {
|
||||
const { setupTray } = await importMainModule();
|
||||
|
||||
setupTray(mocks.browserWindowInstance as never);
|
||||
setupTray(mocks.browserWindowInstance as never, mocks.trayInstance as never);
|
||||
|
||||
expect(mocks.Tray).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.nativeImage.createEmpty).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.trayInstance.setToolTip).toHaveBeenCalledWith("Fusion");
|
||||
expect(mocks.nativeImage.createFromPath).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.trayInstance.setImage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.trayInstance.setToolTip).toHaveBeenCalledWith("Fusion — Running");
|
||||
expect(mocks.Menu.buildFromTemplate).toHaveBeenCalledTimes(1);
|
||||
|
||||
const closeCall = mocks.browserWindowInstance.on.mock.calls.find(
|
||||
|
||||
237
packages/desktop/src/__tests__/menu.test.ts
Normal file
237
packages/desktop/src/__tests__/menu.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MenuItemConstructorOptions } from "electron";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const menuInstance = { id: "app-menu" };
|
||||
|
||||
return {
|
||||
Menu: {
|
||||
buildFromTemplate: vi.fn(() => menuInstance),
|
||||
setApplicationMenu: vi.fn(),
|
||||
},
|
||||
shell: {
|
||||
openExternal: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
menuInstance,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
Menu: mocks.Menu,
|
||||
shell: mocks.shell,
|
||||
}));
|
||||
|
||||
function createMainWindowMock() {
|
||||
return {
|
||||
webContents: {
|
||||
reload: vi.fn(),
|
||||
reloadIgnoringCache: vi.fn(),
|
||||
toggleDevTools: vi.fn(),
|
||||
getZoomLevel: vi.fn(() => 1),
|
||||
setZoomLevel: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findMenuItem(
|
||||
template: MenuItemConstructorOptions[],
|
||||
label: string,
|
||||
): MenuItemConstructorOptions | undefined {
|
||||
for (const item of template) {
|
||||
if (item.label === label) {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (Array.isArray(item.submenu)) {
|
||||
const nested = findMenuItem(item.submenu, label);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectAccelerators(template: MenuItemConstructorOptions[]): string[] {
|
||||
const accelerators: string[] = [];
|
||||
|
||||
for (const item of template) {
|
||||
if (typeof item.accelerator === "string") {
|
||||
accelerators.push(item.accelerator);
|
||||
}
|
||||
|
||||
if (Array.isArray(item.submenu)) {
|
||||
accelerators.push(...collectAccelerators(item.submenu));
|
||||
}
|
||||
}
|
||||
|
||||
return accelerators;
|
||||
}
|
||||
|
||||
describe("application menu", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "darwin",
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("macOS template includes App menu with About, Preferences and Quit", async () => {
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
expect(template[0]?.label).toBe("Fusion");
|
||||
|
||||
const appMenu = template[0]?.submenu as MenuItemConstructorOptions[];
|
||||
expect(appMenu.some((item) => item.label === "About Fusion")).toBe(true);
|
||||
expect(appMenu.some((item) => item.label === "Preferences")).toBe(true);
|
||||
|
||||
const preferences = appMenu.find((item) => item.label === "Preferences");
|
||||
const quit = appMenu.find((item) => item.label === "Quit Fusion");
|
||||
|
||||
expect(preferences?.accelerator).toBe("CmdOrCtrl+,");
|
||||
expect(quit?.accelerator).toBe("CmdOrCtrl+Q");
|
||||
});
|
||||
|
||||
it("Edit menu includes standard editing shortcuts", async () => {
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
const editMenu = findMenuItem(template, "Edit");
|
||||
const editItems = (editMenu?.submenu ?? []) as MenuItemConstructorOptions[];
|
||||
|
||||
expect(editItems.some((item) => item.label === "Undo" && item.accelerator === "CmdOrCtrl+Z")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(editItems.some((item) => item.label === "Cut" && item.accelerator === "CmdOrCtrl+X")).toBe(true);
|
||||
expect(editItems.some((item) => item.label === "Copy" && item.accelerator === "CmdOrCtrl+C")).toBe(true);
|
||||
expect(editItems.some((item) => item.label === "Paste" && item.accelerator === "CmdOrCtrl+V")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
editItems.some((item) => item.label === "Select All" && item.accelerator === "CmdOrCtrl+A"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("View menu includes zoom controls and dev tools shortcut", async () => {
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
const viewMenu = findMenuItem(template, "View");
|
||||
const viewItems = (viewMenu?.submenu ?? []) as MenuItemConstructorOptions[];
|
||||
|
||||
expect(
|
||||
viewItems.some((item) => item.label === "Zoom In" && item.accelerator === "CmdOrCtrl+Plus"),
|
||||
).toBe(true);
|
||||
expect(viewItems.some((item) => item.label === "Zoom Out" && item.accelerator === "CmdOrCtrl+-")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(viewItems.some((item) => item.label === "Reset Zoom" && item.accelerator === "CmdOrCtrl+0")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
viewItems.some(
|
||||
(item) => item.label === "Toggle Dev Tools" && item.accelerator === "Alt+CmdOrCtrl+I",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Window menu includes Minimize and Close shortcuts", async () => {
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
const windowMenu = findMenuItem(template, "Window");
|
||||
const windowItems = (windowMenu?.submenu ?? []) as MenuItemConstructorOptions[];
|
||||
|
||||
expect(
|
||||
windowItems.some((item) => item.label === "Minimize" && item.accelerator === "CmdOrCtrl+M"),
|
||||
).toBe(true);
|
||||
expect(windowItems.some((item) => item.label === "Close" && item.accelerator === "CmdOrCtrl+W")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("non-macOS template omits App menu and app-specific labels", async () => {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
expect(template[0]?.label).toBe("Edit");
|
||||
expect(findMenuItem(template, "About Fusion")).toBeUndefined();
|
||||
expect(findMenuItem(template, "Hide Fusion")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Help menu contains Fusion Documentation link", async () => {
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
const docsItem = findMenuItem(template, "Fusion Documentation");
|
||||
|
||||
expect(docsItem).toBeDefined();
|
||||
docsItem?.click?.({} as never, {} as never, {} as never);
|
||||
expect(mocks.shell.openExternal).toHaveBeenCalledWith(
|
||||
"https://github.com/eclipxe/fusion#readme",
|
||||
);
|
||||
});
|
||||
|
||||
it("all keyboard shortcuts use CmdOrCtrl prefix convention", async () => {
|
||||
const { buildMenuTemplate } = await import("../menu.ts");
|
||||
const template = buildMenuTemplate({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
const accelerators = collectAccelerators(template);
|
||||
|
||||
for (const accelerator of accelerators) {
|
||||
expect(accelerator).not.toMatch(/(^|[+])Cmd\+/);
|
||||
expect(accelerator).not.toMatch(/(^|[+])Ctrl\+/);
|
||||
}
|
||||
});
|
||||
|
||||
it("buildAppMenu builds and sets the application menu", async () => {
|
||||
const { buildAppMenu } = await import("../menu.ts");
|
||||
|
||||
const menu = buildAppMenu({
|
||||
mainWindow: createMainWindowMock() as never,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
expect(mocks.Menu.buildFromTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.Menu.setApplicationMenu).toHaveBeenCalledWith(mocks.menuInstance);
|
||||
expect(menu).toBe(mocks.menuInstance);
|
||||
});
|
||||
});
|
||||
170
packages/desktop/src/__tests__/tray.test.ts
Normal file
170
packages/desktop/src/__tests__/tray.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const app = {
|
||||
on: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
};
|
||||
|
||||
const menu = {
|
||||
buildFromTemplate: vi.fn((template) => ({ template })),
|
||||
};
|
||||
|
||||
const nativeImage = {
|
||||
createFromPath: vi.fn(() => ({
|
||||
resize: vi.fn(() => ({ id: "resized-image" })),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
app,
|
||||
menu,
|
||||
nativeImage,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
app: mocks.app,
|
||||
Menu: mocks.menu,
|
||||
nativeImage: mocks.nativeImage,
|
||||
BrowserWindow: vi.fn(),
|
||||
Tray: vi.fn(),
|
||||
}));
|
||||
|
||||
function createMainWindowMock(isVisible = true) {
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
return {
|
||||
isVisible: vi.fn(() => isVisible),
|
||||
show: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
listeners.set(event, handler);
|
||||
return undefined;
|
||||
}),
|
||||
getListener(event: string) {
|
||||
return listeners.get(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTrayMock() {
|
||||
const listeners = new Map<string, (...args: unknown[]) => void>();
|
||||
|
||||
return {
|
||||
setImage: vi.fn(),
|
||||
setToolTip: vi.fn(),
|
||||
setContextMenu: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
listeners.set(event, handler);
|
||||
return undefined;
|
||||
}),
|
||||
getListener(event: string) {
|
||||
return listeners.get(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("tray module", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("getTrayTooltip returns running label", async () => {
|
||||
const { getTrayTooltip } = await import("../tray.ts");
|
||||
expect(getTrayTooltip("running")).toBe("Fusion — Running");
|
||||
});
|
||||
|
||||
it("getTrayTooltip returns paused label", async () => {
|
||||
const { getTrayTooltip } = await import("../tray.ts");
|
||||
expect(getTrayTooltip("paused")).toBe("Fusion — Paused");
|
||||
});
|
||||
|
||||
it("getTrayTooltip returns stopped label", async () => {
|
||||
const { getTrayTooltip } = await import("../tray.ts");
|
||||
expect(getTrayTooltip("stopped")).toBe("Fusion — Stopped");
|
||||
});
|
||||
|
||||
it("buildTrayContextMenu toggles show/hide label based on visibility", async () => {
|
||||
const { buildTrayContextMenu } = await import("../tray.ts");
|
||||
|
||||
const hiddenMenu = buildTrayContextMenu({
|
||||
isWindowVisible: false,
|
||||
engineStatus: "running",
|
||||
});
|
||||
const visibleMenu = buildTrayContextMenu({
|
||||
isWindowVisible: true,
|
||||
engineStatus: "running",
|
||||
});
|
||||
|
||||
expect(hiddenMenu[0]).toMatchObject({ label: "Show Window" });
|
||||
expect(visibleMenu[0]).toMatchObject({ label: "Hide Window" });
|
||||
});
|
||||
|
||||
it("buildTrayContextMenu shows Pause/Resume labels and enables toggles for running/paused", async () => {
|
||||
const { buildTrayContextMenu } = await import("../tray.ts");
|
||||
|
||||
const runningMenu = buildTrayContextMenu({
|
||||
isWindowVisible: true,
|
||||
engineStatus: "running",
|
||||
});
|
||||
const pausedMenu = buildTrayContextMenu({
|
||||
isWindowVisible: true,
|
||||
engineStatus: "paused",
|
||||
});
|
||||
|
||||
expect(runningMenu[2]).toMatchObject({ label: "Pause Engine", enabled: true });
|
||||
expect(pausedMenu[2]).toMatchObject({ label: "Resume Engine", enabled: true });
|
||||
});
|
||||
|
||||
it("buildTrayContextMenu disables engine toggle when stopped and includes separators and quit", async () => {
|
||||
const { buildTrayContextMenu } = await import("../tray.ts");
|
||||
|
||||
const stoppedMenu = buildTrayContextMenu({
|
||||
isWindowVisible: true,
|
||||
engineStatus: "stopped",
|
||||
});
|
||||
|
||||
const separatorCount = stoppedMenu.filter((item) => item.type === "separator").length;
|
||||
|
||||
expect(stoppedMenu[2]).toMatchObject({ enabled: false });
|
||||
expect(separatorCount).toBe(2);
|
||||
expect(stoppedMenu[4]).toMatchObject({ label: "Quit Fusion" });
|
||||
});
|
||||
|
||||
it("setupTray sets tooltip and context menu", async () => {
|
||||
const { setupTray } = await import("../tray.ts");
|
||||
const mainWindow = createMainWindowMock(true);
|
||||
const tray = createTrayMock();
|
||||
|
||||
setupTray(mainWindow as never, tray as never);
|
||||
|
||||
expect(tray.setImage).toHaveBeenCalledTimes(1);
|
||||
expect(tray.setToolTip).toHaveBeenCalledWith("Fusion — Running");
|
||||
expect(mocks.menu.buildFromTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(tray.setContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updateTrayStatus updates tooltip and menu", async () => {
|
||||
const { setupTray, updateTrayStatus } = await import("../tray.ts");
|
||||
const mainWindow = createMainWindowMock(true);
|
||||
const tray = createTrayMock();
|
||||
|
||||
setupTray(mainWindow as never, tray as never);
|
||||
updateTrayStatus(tray as never, "paused");
|
||||
|
||||
expect(tray.setToolTip).toHaveBeenLastCalledWith("Fusion — Paused");
|
||||
expect(mocks.menu.buildFromTemplate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("updateTrayStatus still updates tooltip when tray was not initialized", async () => {
|
||||
const { updateTrayStatus } = await import("../tray.ts");
|
||||
const tray = createTrayMock();
|
||||
|
||||
updateTrayStatus(tray as never, "stopped");
|
||||
|
||||
expect(tray.setToolTip).toHaveBeenCalledWith("Fusion — Stopped");
|
||||
expect(tray.setContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
BIN
packages/desktop/src/icons/tray-16.png
Normal file
BIN
packages/desktop/src/icons/tray-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 B |
BIN
packages/desktop/src/icons/tray-32.png
Normal file
BIN
packages/desktop/src/icons/tray-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 B |
BIN
packages/desktop/src/icons/tray-48.png
Normal file
BIN
packages/desktop/src/icons/tray-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 344 B |
@@ -1,9 +1,15 @@
|
||||
export {
|
||||
createMainWindow,
|
||||
setupTray,
|
||||
updateTrayStatus,
|
||||
registerIpcHandlers,
|
||||
DASHBOARD_URL,
|
||||
run,
|
||||
} from "./main.js";
|
||||
|
||||
export { createTrayIcon, buildTrayContextMenu, getTrayTooltip } from "./tray.js";
|
||||
export { buildMenuTemplate, buildAppMenu } from "./menu.js";
|
||||
|
||||
export type { EngineStatus, TrayMenuOptions } from "./tray.js";
|
||||
export type { AppMenuOptions } from "./menu.js";
|
||||
export type { FusionDesktopAPI } from "./preload.js";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { app, BrowserWindow, Menu, Tray, ipcMain, nativeImage } from "electron";
|
||||
import { app, BrowserWindow, Tray, ipcMain, nativeImage } from "electron";
|
||||
import { buildAppMenu } from "./menu.js";
|
||||
import { setupTray, updateTrayStatus } from "./tray.js";
|
||||
|
||||
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
|
||||
|
||||
@@ -20,36 +22,6 @@ export function createMainWindow(): BrowserWindow {
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
export function setupTray(mainWindow: BrowserWindow): Tray {
|
||||
const tray = new Tray(nativeImage.createEmpty());
|
||||
tray.setToolTip("Fusion");
|
||||
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show Fusion",
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(menu);
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle("app:get-version", () => app.getVersion());
|
||||
ipcMain.on("app:quit", () => app.quit());
|
||||
@@ -60,7 +32,14 @@ export function run(): void {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
const mainWindow = createMainWindow();
|
||||
tray = setupTray(mainWindow);
|
||||
const trayInstance = tray ?? new Tray(nativeImage.createEmpty());
|
||||
tray = setupTray(mainWindow, trayInstance);
|
||||
|
||||
buildAppMenu({
|
||||
mainWindow,
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
registerIpcHandlers();
|
||||
});
|
||||
|
||||
@@ -74,11 +53,19 @@ export function run(): void {
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
const mainWindow = createMainWindow();
|
||||
tray = tray ?? setupTray(mainWindow);
|
||||
const trayInstance = tray ?? new Tray(nativeImage.createEmpty());
|
||||
tray = setupTray(mainWindow, trayInstance);
|
||||
|
||||
buildAppMenu({
|
||||
mainWindow,
|
||||
appName: "Fusion",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { setupTray, updateTrayStatus };
|
||||
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && resolve(process.argv[1]) === modulePath) {
|
||||
run();
|
||||
|
||||
237
packages/desktop/src/menu.ts
Normal file
237
packages/desktop/src/menu.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
Menu,
|
||||
shell,
|
||||
type BrowserWindow,
|
||||
type MenuItemConstructorOptions,
|
||||
} from "electron";
|
||||
|
||||
export interface AppMenuOptions {
|
||||
mainWindow: BrowserWindow;
|
||||
appName: string;
|
||||
}
|
||||
|
||||
function buildAppSubmenu(options: AppMenuOptions): MenuItemConstructorOptions {
|
||||
return {
|
||||
label: options.appName,
|
||||
submenu: [
|
||||
{
|
||||
label: `About ${options.appName}`,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Preferences",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: `Hide ${options.appName}`,
|
||||
role: "hide",
|
||||
},
|
||||
{
|
||||
label: "Hide Others",
|
||||
role: "hideOthers",
|
||||
},
|
||||
{
|
||||
label: "Show All",
|
||||
role: "unhide",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: `Quit ${options.appName}`,
|
||||
accelerator: "CmdOrCtrl+Q",
|
||||
role: "quit",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildEditSubmenu(): MenuItemConstructorOptions {
|
||||
return {
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{
|
||||
label: "Undo",
|
||||
accelerator: "CmdOrCtrl+Z",
|
||||
role: "undo",
|
||||
},
|
||||
{
|
||||
label: "Redo",
|
||||
accelerator: "Shift+CmdOrCtrl+Z",
|
||||
role: "redo",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Cut",
|
||||
accelerator: "CmdOrCtrl+X",
|
||||
role: "cut",
|
||||
},
|
||||
{
|
||||
label: "Copy",
|
||||
accelerator: "CmdOrCtrl+C",
|
||||
role: "copy",
|
||||
},
|
||||
{
|
||||
label: "Paste",
|
||||
accelerator: "CmdOrCtrl+V",
|
||||
role: "paste",
|
||||
},
|
||||
{
|
||||
label: "Select All",
|
||||
accelerator: "CmdOrCtrl+A",
|
||||
role: "selectAll",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildViewSubmenu(options: AppMenuOptions): MenuItemConstructorOptions {
|
||||
const { webContents } = options.mainWindow;
|
||||
|
||||
return {
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Reload",
|
||||
accelerator: "CmdOrCtrl+R",
|
||||
click: () => webContents.reload(),
|
||||
},
|
||||
{
|
||||
label: "Force Reload",
|
||||
accelerator: "CmdOrCtrl+Shift+R",
|
||||
click: () => webContents.reloadIgnoringCache(),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Toggle Dev Tools",
|
||||
accelerator: "Alt+CmdOrCtrl+I",
|
||||
click: () => webContents.toggleDevTools(),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Zoom In",
|
||||
accelerator: "CmdOrCtrl+Plus",
|
||||
click: () => {
|
||||
const level = webContents.getZoomLevel();
|
||||
webContents.setZoomLevel(level + 0.5);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Zoom Out",
|
||||
accelerator: "CmdOrCtrl+-",
|
||||
click: () => {
|
||||
const level = webContents.getZoomLevel();
|
||||
webContents.setZoomLevel(level - 0.5);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Reset Zoom",
|
||||
accelerator: "CmdOrCtrl+0",
|
||||
click: () => {
|
||||
webContents.setZoomLevel(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Toggle Full Screen",
|
||||
accelerator: "F11",
|
||||
role: "togglefullscreen",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildWindowSubmenu(isMac: boolean): MenuItemConstructorOptions {
|
||||
const windowItems: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "Minimize",
|
||||
accelerator: "CmdOrCtrl+M",
|
||||
role: "minimize",
|
||||
},
|
||||
];
|
||||
|
||||
if (isMac) {
|
||||
windowItems.push({
|
||||
label: "Zoom",
|
||||
role: "zoom",
|
||||
});
|
||||
}
|
||||
|
||||
windowItems.push(
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: "CmdOrCtrl+W",
|
||||
role: "close",
|
||||
},
|
||||
);
|
||||
|
||||
if (isMac) {
|
||||
windowItems.push(
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Bring All to Front",
|
||||
role: "front",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Window",
|
||||
submenu: windowItems,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHelpSubmenu(): MenuItemConstructorOptions {
|
||||
return {
|
||||
label: "Help",
|
||||
submenu: [
|
||||
{
|
||||
label: "Fusion Documentation",
|
||||
click: () => {
|
||||
void shell.openExternal("https://github.com/eclipxe/fusion#readme");
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMenuTemplate(options: AppMenuOptions): MenuItemConstructorOptions[] {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
buildEditSubmenu(),
|
||||
buildViewSubmenu(options),
|
||||
buildWindowSubmenu(isMac),
|
||||
buildHelpSubmenu(),
|
||||
];
|
||||
|
||||
if (isMac) {
|
||||
template.unshift(buildAppSubmenu(options));
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
export function buildAppMenu(options: AppMenuOptions): Menu {
|
||||
const menu = Menu.buildFromTemplate(buildMenuTemplate(options));
|
||||
Menu.setApplicationMenu(menu);
|
||||
return menu;
|
||||
}
|
||||
187
packages/desktop/src/tray.ts
Normal file
187
packages/desktop/src/tray.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
nativeImage,
|
||||
Tray,
|
||||
type MenuItemConstructorOptions,
|
||||
type NativeImage,
|
||||
} from "electron";
|
||||
|
||||
export type EngineStatus = "running" | "paused" | "stopped";
|
||||
|
||||
export interface TrayMenuOptions {
|
||||
isWindowVisible: boolean;
|
||||
engineStatus: EngineStatus;
|
||||
}
|
||||
|
||||
interface TrayState {
|
||||
mainWindow: BrowserWindow;
|
||||
engineStatus: EngineStatus;
|
||||
isQuitting: boolean;
|
||||
}
|
||||
|
||||
const trayState = new WeakMap<Tray, TrayState>();
|
||||
|
||||
function toggleMainWindow(mainWindow: BrowserWindow): void {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
function resolveEngineMenuLabel(engineStatus: EngineStatus): string {
|
||||
return engineStatus === "running" ? "Pause Engine" : "Resume Engine";
|
||||
}
|
||||
|
||||
function applyTrayMenu(tray: Tray, state: TrayState): void {
|
||||
const baseTemplate = buildTrayContextMenu({
|
||||
isWindowVisible: state.mainWindow.isVisible(),
|
||||
engineStatus: state.engineStatus,
|
||||
});
|
||||
|
||||
const contextTemplate = baseTemplate.map((item) => {
|
||||
if (item.type === "separator") {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item.label === "Show Window" || item.label === "Hide Window") {
|
||||
return {
|
||||
...item,
|
||||
click: () => toggleMainWindow(state.mainWindow),
|
||||
};
|
||||
}
|
||||
|
||||
if (item.label === "Pause Engine" || item.label === "Resume Engine") {
|
||||
return {
|
||||
...item,
|
||||
click: () => {
|
||||
if (state.engineStatus === "stopped") {
|
||||
return;
|
||||
}
|
||||
|
||||
state.engineStatus = state.engineStatus === "running" ? "paused" : "running";
|
||||
applyTrayMenu(tray, state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
click: () => {
|
||||
state.isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
tray.setToolTip(getTrayTooltip(state.engineStatus));
|
||||
tray.setContextMenu(Menu.buildFromTemplate(contextTemplate));
|
||||
}
|
||||
|
||||
export function createTrayIcon(): NativeImage {
|
||||
if (process.platform === "darwin") {
|
||||
const iconPath = path.join(import.meta.dirname, "icons", "tray-32.png");
|
||||
const retinaIcon = nativeImage.createFromPath(iconPath);
|
||||
return retinaIcon.resize({ width: 16, height: 16 });
|
||||
}
|
||||
|
||||
const iconPath = path.join(import.meta.dirname, "icons", "tray-48.png");
|
||||
return nativeImage.createFromPath(iconPath);
|
||||
}
|
||||
|
||||
export function buildTrayContextMenu(options: TrayMenuOptions): MenuItemConstructorOptions[] {
|
||||
return [
|
||||
{
|
||||
label: options.isWindowVisible ? "Hide Window" : "Show Window",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: resolveEngineMenuLabel(options.engineStatus),
|
||||
enabled: options.engineStatus !== "stopped",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Quit Fusion",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function getTrayTooltip(status: EngineStatus): string {
|
||||
switch (status) {
|
||||
case "paused":
|
||||
return "Fusion — Paused";
|
||||
case "stopped":
|
||||
return "Fusion — Stopped";
|
||||
case "running":
|
||||
default:
|
||||
return "Fusion — Running";
|
||||
}
|
||||
}
|
||||
|
||||
export function setupTray(mainWindow: BrowserWindow, tray: Tray): Tray {
|
||||
const state: TrayState = {
|
||||
mainWindow,
|
||||
engineStatus: "running",
|
||||
isQuitting: false,
|
||||
};
|
||||
|
||||
trayState.set(tray, state);
|
||||
|
||||
tray.setImage(createTrayIcon());
|
||||
applyTrayMenu(tray, state);
|
||||
|
||||
tray.on("click", () => {
|
||||
toggleMainWindow(mainWindow);
|
||||
});
|
||||
|
||||
mainWindow.on("show", () => {
|
||||
applyTrayMenu(tray, state);
|
||||
});
|
||||
|
||||
mainWindow.on("hide", () => {
|
||||
applyTrayMenu(tray, state);
|
||||
});
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (state.isQuitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
state.isQuitting = true;
|
||||
});
|
||||
|
||||
return tray;
|
||||
}
|
||||
|
||||
export function updateTrayStatus(tray: Tray, status: EngineStatus): void {
|
||||
const state = trayState.get(tray);
|
||||
|
||||
if (!state) {
|
||||
tray.setToolTip(getTrayTooltip(status));
|
||||
const menu = Menu.buildFromTemplate(
|
||||
buildTrayContextMenu({
|
||||
isWindowVisible: false,
|
||||
engineStatus: status,
|
||||
}),
|
||||
);
|
||||
tray.setContextMenu(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
state.engineStatus = status;
|
||||
applyTrayMenu(tray, state);
|
||||
}
|
||||
Reference in New Issue
Block a user