diff --git a/packages/desktop-electron/src/main/menu-labels.ts b/packages/desktop-electron/src/main/menu-labels.ts index 17b307ea3..182cf8b16 100644 --- a/packages/desktop-electron/src/main/menu-labels.ts +++ b/packages/desktop-electron/src/main/menu-labels.ts @@ -4,6 +4,7 @@ export type MenuLabelKey = | "file" | "edit" | "view" + | "window" | "go" | "help" | "checkForUpdates" @@ -25,11 +26,35 @@ export type MenuLabelKey = | "reportProblem" | "openGithubIssue" +export type MenuRoleLabelKey = + | "about" + | "hide" + | "hideOthers" + | "unhide" + | "quit" + | "close" + | "undo" + | "redo" + | "cut" + | "copy" + | "paste" + | "selectAll" + | "reload" + | "toggleDevTools" + | "resetZoom" + | "zoomIn" + | "zoomOut" + | "togglefullscreen" + | "minimize" + | "zoom" + | "front" + const labels: Record> = { en: { file: "File", edit: "Edit", view: "View", + window: "Window", go: "Go", help: "Help", checkForUpdates: "Check for Updates...", @@ -55,6 +80,7 @@ const labels: Record> = { file: "文件", edit: "编辑", view: "视图", + window: "窗口", go: "前往", help: "帮助", checkForUpdates: "检查更新...", @@ -78,6 +104,57 @@ const labels: Record> = { }, } +// Keep explicit English role labels so role-backed menu templates stay deterministic +// in unit tests and non-macOS environments instead of depending on Electron runtime defaults. +const roleLabels: Record> = { + en: { + about: "About {appName}", + hide: "Hide {appName}", + hideOthers: "Hide Others", + unhide: "Show All", + quit: "Quit {appName}", + close: "Close Window", + undo: "Undo", + redo: "Redo", + cut: "Cut", + copy: "Copy", + paste: "Paste", + selectAll: "Select All", + reload: "Reload", + toggleDevTools: "Toggle Developer Tools", + resetZoom: "Actual Size", + zoomIn: "Zoom In", + zoomOut: "Zoom Out", + togglefullscreen: "Toggle Full Screen", + minimize: "Minimize", + zoom: "Zoom", + front: "Bring All to Front", + }, + zh: { + about: "关于 {appName}", + hide: "隐藏 {appName}", + hideOthers: "隐藏其他", + unhide: "显示全部", + quit: "退出 {appName}", + close: "关闭窗口", + undo: "撤销", + redo: "重做", + cut: "剪切", + copy: "复制", + paste: "粘贴", + selectAll: "全选", + reload: "重新加载", + toggleDevTools: "切换开发者工具", + resetZoom: "实际大小", + zoomIn: "放大", + zoomOut: "缩小", + togglefullscreen: "切换全屏", + minimize: "最小化", + zoom: "缩放", + front: "全部移到前面", + }, +} + function parseStoredOrRaw(value: unknown): unknown { if (typeof value !== "string") return value try { @@ -127,3 +204,12 @@ export function menuLabel(locale: MenuLocale, key: MenuLabelKey) { if (import.meta.env.DEV) console.warn("[menu] missing desktop label", { locale, key }) return key } + +export function menuRoleLabel(locale: MenuLocale, key: MenuRoleLabelKey, appName: string) { + if (import.meta.env.DEV && locale !== "en" && roleLabels[locale]?.[key] === undefined) { + console.warn("[menu] missing locale role label, falling back to en", { locale, key }) + } + const template = roleLabels[locale]?.[key] ?? roleLabels.en[key] ?? key + if (template === key && import.meta.env.DEV) console.warn("[menu] missing desktop role label", { locale, key }) + return template.replaceAll("{appName}", appName) +} diff --git a/packages/desktop-electron/src/main/menu-template.ts b/packages/desktop-electron/src/main/menu-template.ts index b6c6abe38..d27b1c742 100644 --- a/packages/desktop-electron/src/main/menu-template.ts +++ b/packages/desktop-electron/src/main/menu-template.ts @@ -1,4 +1,4 @@ -import { menuLabel, type MenuLocale } from "./menu-labels" +import { menuLabel, menuRoleLabel, type MenuLocale, type MenuRoleLabelKey } from "./menu-labels" import { PAWWORK_GITHUB_ISSUE_URL, PAWWORK_GITHUB_URL } from "./support-links" export type MenuItemTemplate = { @@ -31,6 +31,7 @@ type BuildMenuOptions = { export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { const { deps, appName, locale, feedbackEnabled } = options const t = (key: Parameters[1]) => menuLabel(locale, key) + const roleLabel = (key: MenuRoleLabelKey) => menuRoleLabel(locale, key, appName) const helpSubmenu: MenuItemTemplate[] = [ { label: t("pawworkOnGithub"), click: () => deps.openExternal(PAWWORK_GITHUB_URL) }, @@ -47,7 +48,7 @@ export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { label: appName, submenu: [ - { role: "about" }, + { label: roleLabel("about"), role: "about" }, { label: t("checkForUpdates"), click: () => deps.checkForUpdates(), @@ -61,11 +62,11 @@ export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] click: () => deps.relaunch(), }, { type: "separator" }, - { role: "hide" }, - { role: "hideOthers" }, - { role: "unhide" }, + { label: roleLabel("hide"), role: "hide" }, + { label: roleLabel("hideOthers"), role: "hideOthers" }, + { label: roleLabel("unhide"), role: "unhide" }, { type: "separator" }, - { role: "quit" }, + { label: roleLabel("quit"), role: "quit" }, ], }, { @@ -75,19 +76,19 @@ export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { label: t("openProject"), accelerator: "Cmd+O", click: () => deps.trigger("project.open") }, { label: t("newWindow"), accelerator: "Cmd+Shift+N", click: () => deps.newWindow() }, { type: "separator" }, - { role: "close" }, + { label: roleLabel("close"), role: "close" }, ], }, { label: t("edit"), submenu: [ - { role: "undo" }, - { role: "redo" }, + { label: roleLabel("undo"), role: "undo" }, + { label: roleLabel("redo"), role: "redo" }, { type: "separator" }, - { role: "cut" }, - { role: "copy" }, - { role: "paste" }, - { role: "selectAll" }, + { label: roleLabel("cut"), role: "cut" }, + { label: roleLabel("copy"), role: "copy" }, + { label: roleLabel("paste"), role: "paste" }, + { label: roleLabel("selectAll"), role: "selectAll" }, ], }, { @@ -97,14 +98,14 @@ export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { label: t("toggleTerminal"), accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") }, { label: t("toggleFileTree"), click: () => deps.trigger("fileTree.toggle") }, { type: "separator" }, - { role: "reload" }, - { role: "toggleDevTools" }, + { label: roleLabel("reload"), role: "reload" }, + { label: roleLabel("toggleDevTools"), role: "toggleDevTools" }, { type: "separator" }, - { role: "resetZoom" }, - { role: "zoomIn" }, - { role: "zoomOut" }, + { label: roleLabel("resetZoom"), role: "resetZoom" }, + { label: roleLabel("zoomIn"), role: "zoomIn" }, + { label: roleLabel("zoomOut"), role: "zoomOut" }, { type: "separator" }, - { role: "togglefullscreen" }, + { label: roleLabel("togglefullscreen"), role: "togglefullscreen" }, ], }, { @@ -120,7 +121,20 @@ export function buildMenuTemplate(options: BuildMenuOptions): MenuItemTemplate[] { label: t("nextProject"), accelerator: "Cmd+Option+Down", click: () => deps.trigger("project.next") }, ], }, - { role: "windowMenu" }, + { + label: t("window"), + // Electron 40.8.0 on macOS 15 still keeps our labeled submenu entries while + // preserving the native window list for the parent windowMenu role. + // If an Electron upgrade stops honoring this merge, localize the generated + // window submenu items after Menu.buildFromTemplate instead of dropping the role. + role: "windowMenu", + submenu: [ + { label: roleLabel("minimize"), role: "minimize" }, + { label: roleLabel("zoom"), role: "zoom" }, + { type: "separator" }, + { label: roleLabel("front"), role: "front" }, + ], + }, { label: t("help"), submenu: helpSubmenu, diff --git a/packages/desktop-electron/src/main/menu.test.ts b/packages/desktop-electron/src/main/menu.test.ts index dd89574c5..a0bf56b3b 100644 --- a/packages/desktop-electron/src/main/menu.test.ts +++ b/packages/desktop-electron/src/main/menu.test.ts @@ -18,7 +18,107 @@ function labels(template: MenuItemTemplate[]) { } function submenu(template: MenuItemTemplate[], label: string) { - return template.find((item) => item.label === label)?.submenu ?? [] + const item = menuItem(template, label) + expect(item, `menu '${label}' not found`).toBeDefined() + return item?.submenu ?? [] +} + +function menuItem(template: MenuItemTemplate[], label: string) { + return template.find((item) => item.label === label) +} + +function expectRoleLabels(items: MenuItemTemplate[], expected: Record) { + const roleItems = items.filter((item) => item.role !== undefined) + expect(roleItems).toHaveLength(Object.keys(expected).length) + for (const [role, label] of Object.entries(expected)) { + expect(items).toContainEqual(expect.objectContaining({ role, label })) + } +} + +function expectWindowMenuRoleLabels(template: MenuItemTemplate[], locale: "en" | "zh", appName: string) { + const labelsByLocale = { + en: { + appMenu: { + about: `About ${appName}`, + hide: `Hide ${appName}`, + hideOthers: "Hide Others", + unhide: "Show All", + quit: `Quit ${appName}`, + }, + file: { + close: "Close Window", + }, + edit: { + undo: "Undo", + redo: "Redo", + cut: "Cut", + copy: "Copy", + paste: "Paste", + selectAll: "Select All", + }, + view: { + reload: "Reload", + toggleDevTools: "Toggle Developer Tools", + resetZoom: "Actual Size", + zoomIn: "Zoom In", + zoomOut: "Zoom Out", + togglefullscreen: "Toggle Full Screen", + }, + windowMenu: { + label: "Window", + minimize: "Minimize", + zoom: "Zoom", + front: "Bring All to Front", + }, + }, + zh: { + appMenu: { + about: `关于 ${appName}`, + hide: `隐藏 ${appName}`, + hideOthers: "隐藏其他", + unhide: "显示全部", + quit: `退出 ${appName}`, + }, + file: { + close: "关闭窗口", + }, + edit: { + undo: "撤销", + redo: "重做", + cut: "剪切", + copy: "复制", + paste: "粘贴", + selectAll: "全选", + }, + view: { + reload: "重新加载", + toggleDevTools: "切换开发者工具", + resetZoom: "实际大小", + zoomIn: "放大", + zoomOut: "缩小", + togglefullscreen: "切换全屏", + }, + windowMenu: { + label: "窗口", + minimize: "最小化", + zoom: "缩放", + front: "全部移到前面", + }, + }, + } as const + + const expected = labelsByLocale[locale] + + expectRoleLabels(submenu(template, appName), expected.appMenu) + expectRoleLabels(submenu(template, locale === "zh" ? "文件" : "File"), expected.file) + expectRoleLabels(submenu(template, locale === "zh" ? "编辑" : "Edit"), expected.edit) + expectRoleLabels(submenu(template, locale === "zh" ? "视图" : "View"), expected.view) + expect(menuItem(template, expected.windowMenu.label)).toEqual(expect.objectContaining({ role: "windowMenu" })) + expectRoleLabels(submenu(template, expected.windowMenu.label), { + minimize: expected.windowMenu.minimize, + zoom: expected.windowMenu.zoom, + front: expected.windowMenu.front, + }) } describe("desktop menu template", () => { @@ -36,6 +136,30 @@ describe("desktop menu template", () => { expect(labels(template)).toContain("帮助") }) + test("localizes Chinese labels for role-backed menu items while preserving roles", () => { + const appName = "PawWork" + const template = buildMenuTemplate({ + deps: deps(), + appName, + locale: "zh", + feedbackEnabled: true, + }) + + expectWindowMenuRoleLabels(template, "zh", appName) + }) + + test("localizes English labels for role-backed menu items while preserving roles", () => { + const appName = "PawWork" + const template = buildMenuTemplate({ + deps: deps(), + appName, + locale: "en", + feedbackEnabled: true, + }) + + expectWindowMenuRoleLabels(template, "en", appName) + }) + test("renames stale webview label", () => { const template = buildMenuTemplate({ deps: deps(),