From 91ee1fb2a25a6a11bc692ef4587c2edddcb26533 Mon Sep 17 00:00:00 2001 From: Nicolas Pirello Date: Sat, 17 Oct 2026 10:54:12 -0300 Subject: [PATCH] Add Spanish localization and persistent GUI language preferences to MemHub Co-authored-by: Cursor --- .../apps/desktop/src/application-menu.test.ts | 39 ++ memhub/apps/desktop/src/application-menu.ts | 55 ++- .../apps/desktop/src/background-tray.test.ts | 40 ++ memhub/apps/desktop/src/background-tray.ts | 29 +- .../desktop/src/desktop-lifecycle.test.ts | 31 ++ memhub/apps/desktop/src/desktop-lifecycle.ts | 3 + .../src/display-preferences-ipc.test.ts | 162 ++++++- .../desktop/src/early-startup-errors.test.ts | 66 +++ .../apps/desktop/src/early-startup-errors.ts | 14 +- .../src/language-preferences-ipc.test.ts | 55 +++ .../desktop/src/language-preferences-ipc.ts | 50 +++ .../desktop/src/language-preferences.test.ts | 141 ++++++ .../apps/desktop/src/language-preferences.ts | 135 ++++++ memhub/apps/desktop/src/main.ts | 70 ++- memhub/apps/desktop/src/native-language.ts | 7 + .../apps/desktop/src/preload-sandbox.test.ts | 3 + memhub/apps/desktop/src/preload.ts | 54 +++ memhub/apps/web/src/App.r6.test.tsx | 25 ++ memhub/apps/web/src/App.tsx | 28 +- .../apps/web/src/ApplicationDetail.test.tsx | 26 ++ memhub/apps/web/src/ApplicationDetail.tsx | 157 ++++--- memhub/apps/web/src/ApplicationsHome.test.tsx | 93 ++++ memhub/apps/web/src/ApplicationsHome.tsx | 285 +++++++----- .../apps/web/src/ConnectionFallback.test.tsx | 47 ++ memhub/apps/web/src/ConnectionFallback.tsx | 53 ++- memhub/apps/web/src/DebugLogPanel.test.tsx | 51 +++ memhub/apps/web/src/DebugLogPanel.tsx | 64 +-- memhub/apps/web/src/DesktopFrame.test.tsx | 11 +- memhub/apps/web/src/FirstImportReview.tsx | 133 +++--- memhub/apps/web/src/FirstUseGuide.test.tsx | 47 +- memhub/apps/web/src/FirstUseGuide.tsx | 212 +++++---- memhub/apps/web/src/GraphWidthPrompt.test.tsx | 48 ++ memhub/apps/web/src/GraphWidthPrompt.tsx | 33 +- memhub/apps/web/src/HealthPanel.test.tsx | 36 +- memhub/apps/web/src/HealthPanel.tsx | 261 +++++++---- memhub/apps/web/src/HomeCardBoundary.tsx | 11 +- .../apps/web/src/ImportProgressStrip.test.tsx | 52 +++ memhub/apps/web/src/ImportProgressStrip.tsx | 10 +- memhub/apps/web/src/LanguageSelector.test.tsx | 95 ++++ memhub/apps/web/src/LanguageSelector.tsx | 55 +++ memhub/apps/web/src/MemoryEvidencePanel.tsx | 4 +- memhub/apps/web/src/MemoryGraph3D.tsx | 18 +- memhub/apps/web/src/MemoryMap.tsx | 241 ++++++---- .../apps/web/src/MemoryNoteComposer.test.tsx | 32 ++ memhub/apps/web/src/MemoryNoteComposer.tsx | 47 +- memhub/apps/web/src/MemoryRecallScope.tsx | 29 +- memhub/apps/web/src/MemorySpace.r6.test.tsx | 60 +++ memhub/apps/web/src/MemorySpace.tsx | 77 ++-- memhub/apps/web/src/ModelSettings.test.tsx | 41 +- memhub/apps/web/src/ModelSettings.tsx | 301 +++++++------ .../web/src/OnboardingFlow.language.test.tsx | 425 ++++++++++++++++++ memhub/apps/web/src/OnboardingFlow.tsx | 153 ++++--- .../web/src/OnboardingModelController.tsx | 22 +- .../apps/web/src/OnboardingModelStep.test.tsx | 83 +++- memhub/apps/web/src/OnboardingModelStep.tsx | 209 +++++---- memhub/apps/web/src/R6ColdStartFrame.test.tsx | 153 +++++++ memhub/apps/web/src/R6ColdStartFrame.tsx | 10 +- memhub/apps/web/src/R6Controls.tsx | 13 +- memhub/apps/web/src/R6ImportProgress.tsx | 42 +- memhub/apps/web/src/R6Settings.test.tsx | 26 ++ memhub/apps/web/src/R6Settings.tsx | 88 ++-- .../web/src/SelectedMemoryDetail.test.tsx | 24 + memhub/apps/web/src/SelectedMemoryDetail.tsx | 126 ++++-- memhub/apps/web/src/SourceImportReceipt.tsx | 106 +++-- memhub/apps/web/src/application-detail.css | 19 +- memhub/apps/web/src/applications-home.css | 33 +- memhub/apps/web/src/applications-language.ts | 199 ++++++++ memhub/apps/web/src/call-history-client.ts | 46 +- memhub/apps/web/src/clock-stamp.ts | 14 +- memhub/apps/web/src/connection-prompt.ts | 34 ++ memhub/apps/web/src/diagnostics-export.ts | 14 +- memhub/apps/web/src/first-import-r6.css | 28 +- memhub/apps/web/src/global.d.ts | 11 + .../web/src/gui-language-lifecycle.test.tsx | 140 ++++++ memhub/apps/web/src/gui-language.test.tsx | 224 +++++++++ memhub/apps/web/src/gui-language.tsx | 206 +++++++++ memhub/apps/web/src/main.tsx | 5 +- memhub/apps/web/src/memory-language.ts | 316 +++++++++++++ memhub/apps/web/src/onboarding-language.ts | 20 + memhub/apps/web/src/onboarding-model-r6.css | 67 ++- memhub/apps/web/src/onboarding-r6.css | 108 ++++- .../apps/web/src/query-client.test-setup.ts | 8 +- memhub/apps/web/src/r6-controls.css | 15 +- memhub/apps/web/src/r6-import-progress.css | 4 +- memhub/apps/web/src/r6-memory.css | 13 + memhub/apps/web/src/r6-settings.css | 31 +- memhub/apps/web/src/r6-shell.css | 12 +- memhub/packages/contracts/package.json | 4 + .../packages/contracts/src/gui-i18n-guide.ts | 203 +++++++++ .../src/gui-i18n-health-guide.test.ts | 30 ++ .../packages/contracts/src/gui-i18n-health.ts | 141 ++++++ .../contracts/src/gui-i18n-messages.ts | 61 +++ .../contracts/src/gui-i18n-onboarding.test.ts | 48 ++ .../contracts/src/gui-i18n-onboarding.ts | 392 ++++++++++++++++ .../contracts/src/gui-i18n-recovery.test.ts | 23 + .../contracts/src/gui-i18n-recovery.ts | 139 ++++++ .../contracts/src/gui-i18n-settings.test.ts | 22 + .../contracts/src/gui-i18n-settings.ts | 383 ++++++++++++++++ .../packages/contracts/src/gui-i18n.test.ts | 161 +++++++ memhub/packages/contracts/src/gui-i18n.ts | 70 +++ 100 files changed, 7157 insertions(+), 1204 deletions(-) create mode 100644 memhub/apps/desktop/src/language-preferences-ipc.test.ts create mode 100644 memhub/apps/desktop/src/language-preferences-ipc.ts create mode 100644 memhub/apps/desktop/src/language-preferences.test.ts create mode 100644 memhub/apps/desktop/src/language-preferences.ts create mode 100644 memhub/apps/desktop/src/native-language.ts create mode 100644 memhub/apps/web/src/LanguageSelector.test.tsx create mode 100644 memhub/apps/web/src/LanguageSelector.tsx create mode 100644 memhub/apps/web/src/OnboardingFlow.language.test.tsx create mode 100644 memhub/apps/web/src/R6ColdStartFrame.test.tsx create mode 100644 memhub/apps/web/src/applications-language.ts create mode 100644 memhub/apps/web/src/connection-prompt.ts create mode 100644 memhub/apps/web/src/gui-language-lifecycle.test.tsx create mode 100644 memhub/apps/web/src/gui-language.test.tsx create mode 100644 memhub/apps/web/src/gui-language.tsx create mode 100644 memhub/apps/web/src/memory-language.ts create mode 100644 memhub/apps/web/src/onboarding-language.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-guide.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-health-guide.test.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-health.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-messages.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-onboarding.test.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-onboarding.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-recovery.test.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-recovery.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-settings.test.ts create mode 100644 memhub/packages/contracts/src/gui-i18n-settings.ts create mode 100644 memhub/packages/contracts/src/gui-i18n.test.ts create mode 100644 memhub/packages/contracts/src/gui-i18n.ts diff --git a/memhub/apps/desktop/src/application-menu.test.ts b/memhub/apps/desktop/src/application-menu.test.ts index 9ebd62b..01a4beb 100644 --- a/memhub/apps/desktop/src/application-menu.test.ts +++ b/memhub/apps/desktop/src/application-menu.test.ts @@ -1,7 +1,46 @@ +import type { LanguageSnapshot } from "@one-memory/contracts/gui-i18n"; import { expect, it, vi } from "vitest"; import { installApplicationMenu } from "./application-menu"; +it("rebuilds translated labels from committed language updates without exporting", () => { + let change: (() => void) | undefined; + const snapshot: LanguageSnapshot = { + preference: "es", + locale: "es", + revision: 1, + }; + const unsubscribe = vi.fn(); + const read = vi.fn(() => snapshot); + const buildMenu = vi.fn(() => ({}) as never); + const dispose = installApplicationMenu({ + productName: "MemHub", + buildMenu, + setMenu: vi.fn(), + exportLogs: async () => {}, + reportError: vi.fn(), + language: { + read, + subscribe: (listener) => { + change = () => listener(snapshot); + return unsubscribe; + }, + }, + }); + expect(buildMenu).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ label: "Ayuda" })]), + ); + snapshot.locale = "zh-CN"; + change?.(); + expect(read).toHaveBeenCalledTimes(2); + expect(buildMenu).toHaveBeenCalledTimes(2); + expect(buildMenu).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ label: "帮助" })]), + ); + dispose(); + expect(unsubscribe).toHaveBeenCalledTimes(1); +}); + it("exports logs from Help without requiring a renderer window", async () => { const exportLogs = vi.fn(async () => undefined); let template: Array> = []; diff --git a/memhub/apps/desktop/src/application-menu.ts b/memhub/apps/desktop/src/application-menu.ts index ad1413c..8fa99a0 100644 --- a/memhub/apps/desktop/src/application-menu.ts +++ b/memhub/apps/desktop/src/application-menu.ts @@ -1,36 +1,49 @@ +import { translate } from "@one-memory/contracts/gui-i18n"; import type { Menu, MenuItemConstructorOptions } from "electron"; +import type { NativeLanguageSource } from "./native-language"; export function installApplicationMenu(options: { productName: string; + language?: NativeLanguageSource; buildMenu(template: MenuItemConstructorOptions[]): Menu; setMenu(menu: Menu): void; exportLogs(): Promise; reportError(message: string): void; -}): void { +}): () => void { const runExport = async () => { try { await options.exportLogs(); } catch { - options.reportError("日志未能导出,请稍后重试。"); + options.reportError( + translate( + options.language?.read().locale ?? "zh-CN", + "nativeExportError", + ), + ); } }; - options.setMenu( - options.buildMenu([ - { - label: options.productName, - submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }], - }, - { role: "editMenu" }, - { role: "windowMenu" }, - { - label: "帮助", - submenu: [ - { - label: "导出日志(发给开发排查)", - click: runExport, - }, - ], - }, - ]), - ); + const refresh = () => { + const locale = options.language?.read().locale ?? "zh-CN"; + options.setMenu( + options.buildMenu([ + { + label: options.productName, + submenu: [{ role: "about" }, { type: "separator" }, { role: "quit" }], + }, + { role: "editMenu" }, + { role: "windowMenu" }, + { + label: translate(locale, "nativeHelp"), + submenu: [ + { + label: translate(locale, "nativeExportLogs"), + click: runExport, + }, + ], + }, + ]), + ); + }; + refresh(); + return options.language?.subscribe(refresh) ?? (() => {}); } diff --git a/memhub/apps/desktop/src/background-tray.test.ts b/memhub/apps/desktop/src/background-tray.test.ts index de9c348..c77d391 100644 --- a/memhub/apps/desktop/src/background-tray.test.ts +++ b/memhub/apps/desktop/src/background-tray.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import type { LanguageSnapshot } from "@one-memory/contracts/gui-i18n"; import type { App, Menu, @@ -25,6 +26,12 @@ function fixture( enabled: false, status: "disabled", }; + let changed: (() => void) | undefined; + const languageSnapshot: LanguageSnapshot = { + preference: "zh-CN", + locale: "zh-CN", + revision: 1, + }; const app = { isPackaged: packaged, quit: vi.fn() }; const tray = Object.assign(new EventEmitter(), { setToolTip: vi.fn(), @@ -49,6 +56,15 @@ function fixture( installBackgroundTray({ app: app as unknown as App, platform, + language: { + read: () => languageSnapshot, + subscribe: (listener) => { + changed = () => listener(languageSnapshot); + return () => { + changed = undefined; + }; + }, + }, resourcesPath: "/bundle/Contents/Resources", appPath: "/repo/apps/desktop", createImage: vi.fn(() => image as unknown as NativeImage), @@ -80,6 +96,10 @@ function fixture( install, menu, click, + changeLanguage(locale: LanguageSnapshot["locale"]) { + languageSnapshot.locale = locale; + changed?.(); + }, setState(value: LoginPreference) { state = value; }, @@ -87,6 +107,26 @@ function fixture( } describe("native macOS background tray", () => { + it("rebuilds Spanish and Chinese labels without changing login or stopping services", async () => { + const f = fixture(); + f.install(); + await flush(); + f.changeLanguage("es"); + await flush(); + expect(f.menu()[0]?.label).toBe("Abrir MemHub"); + expect(f.menu()[1]?.label).toBe("Iniciar MemHub al iniciar sesión"); + expect(f.menu()[3]?.label).toBe( + "Salir de MemHub (el servicio sigue en segundo plano)", + ); + expect(f.setLoginPreference).not.toHaveBeenCalled(); + f.click(0); + expect(f.open).toHaveBeenCalledOnce(); + f.changeLanguage("zh-CN"); + await flush(); + expect(f.menu()[0]?.label).toBe("打开 MemHub"); + f.click(3); + expect(f.app.quit).toHaveBeenCalledOnce(); + }); it("reads and changes the unified Runtime login preference", async () => { const f = fixture(); const dispose = f.install(); diff --git a/memhub/apps/desktop/src/background-tray.ts b/memhub/apps/desktop/src/background-tray.ts index 9b015ec..ce95c7c 100644 --- a/memhub/apps/desktop/src/background-tray.ts +++ b/memhub/apps/desktop/src/background-tray.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { type MessageKey, translate } from "@one-memory/contracts/gui-i18n"; import type { App, Menu, @@ -7,6 +8,7 @@ import type { Tray, } from "electron"; import type { LoginPreference } from "./login-preference"; +import type { NativeLanguageSource } from "./native-language"; export const LOGIN_SERVICE_NAME = "com.tencent.onememory.runtime.plist"; @@ -24,6 +26,7 @@ interface TrayOptions { setLoginPreference(enabled: boolean): Promise; allowLoginStartup?: boolean; productName?: string; + language?: NativeLanguageSource; } export function installBackgroundTray(options: TrayOptions): () => void { @@ -41,29 +44,32 @@ export function installBackgroundTray(options: TrayOptions): () => void { let generation = 0; const refresh = async () => { if (disposed) return; + const locale = options.language?.read().locale ?? "zh-CN"; + const t = (key: MessageKey) => + translate(locale, key, { product: productName }); const current = ++generation; let checked = false; let enabled = app.isPackaged && options.allowLoginStartup !== false; - let label = `登录时启动 ${productName}`; + let label = t("nativeLogin"); if (options.allowLoginStartup === false) { - label += "(阶段包不可用)"; + label += t("nativeTrialUnavailable"); } else if (app.isPackaged) { try { const state = await options.readLoginPreference(); if (disposed || current !== generation) return; checked = state.enabled; enabled = state.supported; - if (state.status === "managed-by-cli") label += "(由 CLI 管理)"; + if (state.status === "managed-by-cli") label += t("nativeCliManaged"); } catch { - label += "(状态不可用)"; + label += t("nativeStatusUnavailable"); enabled = false; } } else { - label += "(开发模式不可用)"; + label += t("nativeDevelopmentUnavailable"); } tray.setContextMenu( options.buildMenu([ - { label: `打开 ${productName}`, click: options.open }, + { label: t("nativeOpen"), click: options.open }, { label, type: "checkbox", @@ -74,14 +80,19 @@ export function installBackgroundTray(options: TrayOptions): () => void { void options .setLoginPreference(item.checked) .catch(() => - options.reportError("无法更改登录启动设置,请重试。"), + options.reportError( + translate( + options.language?.read().locale ?? "zh-CN", + "nativeLoginError", + ), + ), ) .finally(() => void refresh()); }, }, { type: "separator" }, { - label: `退出 ${productName}(后台服务继续运行)`, + label: t("nativeQuit"), click: () => { if (!disposed) app.quit(); }, @@ -89,6 +100,7 @@ export function installBackgroundTray(options: TrayOptions): () => void { ]), ); }; + const unsubscribeLanguage = options.language?.subscribe(() => void refresh()); try { void refresh(); // Refresh before the native menu opens, including changes made in Settings. @@ -101,6 +113,7 @@ export function installBackgroundTray(options: TrayOptions): () => void { return () => { if (disposed) return; disposed = true; + unsubscribeLanguage?.(); tray.removeAllListeners("mouse-down"); tray.removeAllListeners("right-click"); tray.destroy(); diff --git a/memhub/apps/desktop/src/desktop-lifecycle.test.ts b/memhub/apps/desktop/src/desktop-lifecycle.test.ts index 7b21e0d..431d45c 100644 --- a/memhub/apps/desktop/src/desktop-lifecycle.test.ts +++ b/memhub/apps/desktop/src/desktop-lifecycle.test.ts @@ -50,6 +50,7 @@ interface Overrides { whenReady?: Promise; windowGate?: Promise; windowError?: Error; + beforeNativeUi?: () => Promise; } function fixture(background = false, lock = true, overrides: Overrides = {}) { @@ -81,6 +82,7 @@ function fixture(background = false, lock = true, overrides: Overrides = {}) { platform: overrides.platform ?? "darwin", background, prepare, + beforeNativeUi: overrides.beforeNativeUi, initialize, stop, dispose, @@ -129,6 +131,35 @@ function fixture(background = false, lock = true, overrides: Overrides = {}) { } describe("desktop background lifecycle with injected Electron", () => { + it("awaits the post-readiness language hook before menus, tray and runtime", async () => { + const gate = deferred(); + const beforeNativeUi = vi.fn(() => gate.promise); + const f = fixture(false, true, { beforeNativeUi }); + expect(beforeNativeUi).not.toHaveBeenCalled(); + f.ready.resolve(); + await f.flush(); + expect(beforeNativeUi).toHaveBeenCalledTimes(1); + expect(f.installTray).not.toHaveBeenCalled(); + expect(f.initialize).not.toHaveBeenCalled(); + gate.resolve(); + f.starting.resolve(); + await f.flush(); + expect(f.installTray).toHaveBeenCalledTimes(1); + expect(f.windows).toHaveLength(1); + }); + it("does not install native UI after quitting during the language hook", async () => { + const gate = deferred(); + const f = fixture(false, true, { beforeNativeUi: () => gate.promise }); + f.ready.resolve(); + await f.flush(); + f.quit(); + gate.resolve(); + await f.flush(); + expect(f.installTray).not.toHaveBeenCalled(); + expect(f.initialize).not.toHaveBeenCalled(); + f.stopping.resolve(); + await f.flush(); + }); it("starts services and tray without a window in background mode", async () => { const f = fixture(true); await f.boot(); diff --git a/memhub/apps/desktop/src/desktop-lifecycle.ts b/memhub/apps/desktop/src/desktop-lifecycle.ts index b83f04b..b2488c1 100644 --- a/memhub/apps/desktop/src/desktop-lifecycle.ts +++ b/memhub/apps/desktop/src/desktop-lifecycle.ts @@ -18,6 +18,7 @@ interface LifecycleOptions { platform: NodeJS.Platform; background: boolean; prepare(): void; + beforeNativeUi?(): Promise; initialize(isQuitting: () => boolean): Promise; stop(): Promise; dispose(): void; @@ -145,6 +146,8 @@ export function startDesktopLifecycle(options: LifecycleOptions): void { .then(async () => { if (quitting) return; startup = Promise.resolve().then(async () => { + if (quitting) return; + await options.beforeNativeUi?.(); if (quitting) return; options.installTray(open); await options.initialize(() => quitting); diff --git a/memhub/apps/desktop/src/display-preferences-ipc.test.ts b/memhub/apps/desktop/src/display-preferences-ipc.test.ts index b534ec3..6923c18 100644 --- a/memhub/apps/desktop/src/display-preferences-ipc.test.ts +++ b/memhub/apps/desktop/src/display-preferences-ipc.test.ts @@ -1,4 +1,8 @@ import { EventEmitter } from "node:events"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { translate } from "@one-memory/contracts/gui-i18n"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; interface FakeFrame { @@ -11,13 +15,17 @@ interface FakeFrame { const fake = vi.hoisted(() => ({ ready: undefined as (() => Promise) | undefined, - handlers: new Map unknown>(), + handlers: new Map unknown>(), windows: [] as FakeWindow[], app: undefined as unknown as EventEmitter, theme: undefined as unknown as EventEmitter, renderer: undefined as unknown as EventEmitter, unload: undefined as unknown as EventTarget, packaged: true, + userData: "", + preferred: vi.fn(() => ["es-MX", "zh-CN"]), + locale: vi.fn(() => "zh-CN"), + menu: vi.fn(), loadFailure: false, reduceMotion: false as unknown, reduceTransparency: false as unknown, @@ -93,7 +101,10 @@ vi.mock("electron", () => ({ }), isPackaged: fake.packaged, getName: () => "One Memory Preview", - getPath: () => "/unused/display-test", + getPath: (name: string) => + name === "userData" ? fake.userData : "/unused/display-test", + getPreferredSystemLanguages: fake.preferred, + getLocale: fake.locale, getAppPath: () => "/unused/display-test", quit: vi.fn(), exit: vi.fn(), @@ -119,8 +130,10 @@ vi.mock("electron", () => ({ }, BrowserWindow: FakeWindow, ipcMain: { - handle: (channel: string, handler: (event: unknown) => unknown) => - fake.handlers.set(channel, handler), + handle: ( + channel: string, + handler: (event: unknown, ...args: unknown[]) => unknown, + ) => fake.handlers.set(channel, handler), removeHandler: (channel: string) => { fake.removeHandler(channel); fake.handlers.delete(channel); @@ -138,7 +151,7 @@ vi.mock("electron", () => ({ session: { defaultSession: {} }, clipboard: { writeText: vi.fn() }, dialog: { showErrorBox: fake.error }, - Menu: { buildFromTemplate: vi.fn(() => ({})), setApplicationMenu: vi.fn() }, + Menu: { buildFromTemplate: vi.fn(() => ({})), setApplicationMenu: fake.menu }, shell: { openExternal: vi.fn() }, })); vi.mock("@one-memory/host-bridge", () => ({ @@ -190,7 +203,8 @@ vi.mock("./core-supervisor", () => ({ findAvailablePort: async () => 9999, })); -beforeEach(() => { +beforeEach(async () => { + fake.userData = await mkdtemp(path.join(os.tmpdir(), "memhub-language-ipc-")); vi.resetModules(); vi.resetAllMocks(); fake.app = new EventEmitter(); @@ -212,8 +226,9 @@ beforeEach(() => { vi.stubEnv("ONE_MEMORY_WEB_URL", "http://127.0.0.1:5173"); vi.stubEnv("ONE_MEMORY_SMOKE_QUIT_MS", "0"); }); -afterEach(() => { +afterEach(async () => { fake.app.emit("will-quit"); + await rm(fake.userData, { recursive: true, force: true }); fake.unload.dispatchEvent(new Event("unload")); vi.unstubAllGlobals(); vi.unstubAllEnvs(); @@ -242,6 +257,91 @@ const eventFor = (window: FakeWindow) => ({ senderFrame: window.frame, }); +describe("GUI language main IPC", () => { + it("loads the active profile after ready and before native menus", async () => { + await writeFile( + path.join(fake.userData, "gui-language.json"), + JSON.stringify({ preference: "es" }), + ); + await import("./main.js"); + expect(fake.preferred).not.toHaveBeenCalled(); + expect(fake.menu).not.toHaveBeenCalled(); + await fake.ready?.(); + expect(fake.preferred).toHaveBeenCalledTimes(1); + expect(fake.preferred.mock.invocationCallOrder[0]).toBeLessThan( + fake.menu.mock.invocationCallOrder[0], + ); + const read = fake.handlers.get("one-memory:get-language-preference"); + if (!read) throw Error("Missing language read handler"); + expect(await read(eventFor(fake.windows[0]))).toEqual({ + preference: "es", + locale: "es", + revision: 0, + }); + }); + it("guards both operations and broadcasts only to live owned main frames", async () => { + const window = await startup(); + const read = fake.handlers.get("one-memory:get-language-preference"); + if (!read) throw Error("Missing language read handler"); + const write = fake.handlers.get("one-memory:set-language-preference"); + if (!write) throw Error("Missing language write handler"); + const foreign = new FakeWindow(); + for (const event of [ + eventFor(foreign), + { + sender: window.webContents, + senderFrame: { ...window.frame, parent: window.frame }, + }, + { sender: window.webContents, senderFrame: null }, + ]) { + await expect(read(event)).rejects.toThrow(); + await expect(write(event, "es")).rejects.toThrow(); + } + window.frame.url = "https://example.invalid"; + await expect(read(eventFor(window))).rejects.toThrow(); + await expect(write(eventFor(window), "es")).rejects.toThrow(); + window.frame.url = "one-memory://app/index.html"; + await write(eventFor(window), "zh-CN"); + expect(window.frame.send).toHaveBeenCalledWith( + "one-memory:language-preference-changed", + { preference: "zh-CN", locale: "zh-CN", revision: 1 }, + ); + expect(foreign.frame.send).not.toHaveBeenCalled(); + window.frame.send.mockImplementation(() => { + throw Error("destroyed during send"); + }); + await expect(write(eventFor(window), "es")).resolves.toMatchObject({ + revision: 2, + }); + fake.app.emit("will-quit"); + expect(fake.handlers.has("one-memory:get-language-preference")).toBe(false); + expect(fake.handlers.has("one-memory:set-language-preference")).toBe(false); + }); + it("does not deliver an admitted write to a document that navigated during persistence", async () => { + const window = await startup(); + const write = fake.handlers.get("one-memory:set-language-preference"); + const read = fake.handlers.get("one-memory:get-language-preference"); + if (!write || !read) throw Error("Missing language handlers"); + const result = write(eventFor(window), "es"); + window.frame.url = "https://example.invalid"; + await expect(result).rejects.toThrow(); + expect(window.frame.send).not.toHaveBeenCalled(); + window.frame.url = "one-memory://app/index.html"; + expect(await read(eventFor(window))).toMatchObject({ + preference: "es", + revision: 1, + }); + }); + it("uses the shared locale for the native error title without translating error codes", async () => { + fake.loadFailure = true; + await startup(); + expect(fake.error).toHaveBeenCalledWith( + translate("es", "nativeErrorTitle"), + "page_load_failed", + ); + }); +}); + describe("display preference main IPC", () => { it("registers background inspection against the fixed data directory and guards navigation", async () => { const window = await startup(); @@ -406,6 +506,9 @@ describe("display preference main IPC", () => { interface PreferenceAPI { getBackgroundHealth(): Promise; restartBackground(): Promise<{ state: "restarted" }>; + getLanguagePreference(): Promise; + setLanguagePreference(preference: unknown): Promise; + onLanguagePreferenceChanged(listener: (value: unknown) => void): () => void; getDisplayPreferences(): Promise<{ reduceMotion: boolean; reduceTransparency: boolean; @@ -418,6 +521,51 @@ async function preload() { return fake.expose.mock.calls[0][1] as PreferenceAPI; } +describe("language preference preload", () => { + it("validates writes and snapshots, strips event metadata and cleans subscriptions", async () => { + const api = await preload(); + const snapshot = { preference: "es", locale: "es", revision: 2 }; + fake.invoke.mockResolvedValue({ ...snapshot, secret: "not exposed" }); + expect(await api.getLanguagePreference()).toEqual(snapshot); + expect(await api.setLanguagePreference("es")).toEqual(snapshot); + expect(fake.invoke).toHaveBeenLastCalledWith( + "one-memory:set-language-preference", + "es", + ); + fake.invoke.mockClear(); + await expect(api.setLanguagePreference("en")).rejects.toThrow(); + expect(fake.invoke).not.toHaveBeenCalled(); + const listener = vi.fn(); + const unsubscribe = api.onLanguagePreferenceChanged(listener); + for (const invalid of [ + null, + {}, + { ...snapshot, revision: -1 }, + { ...snapshot, preference: "en" }, + { ...snapshot, locale: "en" }, + ]) { + fake.invoke.mockResolvedValue(invalid); + await expect(api.getLanguagePreference()).rejects.toThrow(); + await expect(api.setLanguagePreference("es")).rejects.toThrow(); + fake.renderer.emit("one-memory:language-preference-changed", {}, invalid); + } + expect(listener).not.toHaveBeenCalled(); + fake.renderer.emit( + "one-memory:language-preference-changed", + { secret: true }, + { ...snapshot, secret: true }, + ); + expect(listener).toHaveBeenCalledExactlyOnceWith(snapshot); + expect(listener.mock.calls[0][0]).not.toBe(snapshot); + fake.unload.dispatchEvent(new Event("unload")); + unsubscribe(); + unsubscribe(); + expect( + fake.renderer.listenerCount("one-memory:language-preference-changed"), + ).toBe(0); + }); +}); + describe("display preference preload", () => { it("preload exposes a no-argument background health read", async () => { const api = await preload(); diff --git a/memhub/apps/desktop/src/early-startup-errors.test.ts b/memhub/apps/desktop/src/early-startup-errors.test.ts index 39a56b9..002886a 100644 --- a/memhub/apps/desktop/src/early-startup-errors.test.ts +++ b/memhub/apps/desktop/src/early-startup-errors.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { installEarlyStartupErrorHandler } from "./early-startup-errors"; +import { stableRuntimeUserMessage } from "./stable-runtime"; const roots: string[] = []; @@ -14,6 +15,40 @@ afterEach(async () => { }); describe("early desktop startup errors", () => { + it.each(["es", "throws"] as const)( + "uses best-effort language (%s) without changing safe error codes", + async (language) => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "desktop-early-language-"), + ); + roots.push(dataDir); + const processEvents = new EventEmitter(); + const showError = vi.fn(); + const exit = vi.fn(); + const dispose = installEarlyStartupErrorHandler({ + processEvents, + dataDir, + stderr: { write: vi.fn(() => true) }, + showError, + exit, + readLocale: () => { + if (language === "throws") throw new Error("unavailable"); + return "es"; + }, + }); + processEvents.emit( + "uncaughtException", + new Error("synthetic_safe_failure"), + ); + expect(showError).toHaveBeenCalledExactlyOnceWith( + language === "es" ? "MemHub no pudo iniciarse" : "MemHub 无法启动", + stableRuntimeUserMessage(new Error("synthetic_safe_failure")), + ); + expect(exit).toHaveBeenCalledExactlyOnceWith(1); + dispose(); + }, + ); + it("replaces an earlier handler on the same process emitter", () => { const processEvents = new EventEmitter(); const options = { @@ -32,6 +67,37 @@ describe("early desktop startup errors", () => { dispose(); }); + it.each(["es", undefined] as const)( + "uses a safe title for initialized locale %s and retains the technical code", + async (locale) => { + const dataDir = await mkdtemp( + path.join(os.tmpdir(), "desktop-early-locale-"), + ); + roots.push(dataDir); + const processEvents = new EventEmitter(); + const showError = vi.fn(); + const exit = vi.fn(); + const dispose = installEarlyStartupErrorHandler({ + processEvents, + dataDir, + stderr: { write: vi.fn(() => true) }, + showError, + exit, + readLocale: () => locale, + }); + processEvents.emit( + "uncaughtException", + new Error("desktop_startup_failed"), + ); + expect(showError).toHaveBeenCalledExactlyOnceWith( + locale === "es" ? "MemHub no pudo iniciarse" : "MemHub 无法启动", + stableRuntimeUserMessage(new Error("desktop_startup_failed")), + ); + expect(exit).toHaveBeenCalledExactlyOnceWith(1); + dispose(); + }, + ); + it("writes stderr and diagnostics before showing the fatal dialog", async () => { const dataDir = await mkdtemp( path.join(os.tmpdir(), "desktop-early-error-"), diff --git a/memhub/apps/desktop/src/early-startup-errors.ts b/memhub/apps/desktop/src/early-startup-errors.ts index 390654c..3d0b156 100644 --- a/memhub/apps/desktop/src/early-startup-errors.ts +++ b/memhub/apps/desktop/src/early-startup-errors.ts @@ -1,4 +1,5 @@ import type { EventEmitter } from "node:events"; +import { type GuiLocale, translate } from "@one-memory/contracts/gui-i18n"; import { diagnosticError, writeDiagnosticEventSync, @@ -12,6 +13,8 @@ interface EarlyStartupErrorHandlerOptions { showError(title: string, message: string): void; exit(code: number): void; afterDiagnosticWrite?: () => void; + /** Only already initialized preferences; never read configuration during a fatal error. */ + readLocale?: () => GuiLocale | undefined; } const SAFE_ERROR_CODE = /^[a-z][a-z0-9_]{0,127}$/; @@ -56,7 +59,16 @@ export function installEarlyStartupErrorHandler( // The dialog is the only part of this a person ever reads, so it gets the // explanation, not the code. The code stays in the diagnostic event, // where support can still match it against a known failure. - options.showError("MemHub 无法启动", stableRuntimeUserMessage(error)); + let locale: GuiLocale = "zh-CN"; + try { + locale = options.readLocale?.() ?? "zh-CN"; + } catch { + // Preferences may not exist yet; do not read files or guess the locale. + } + options.showError( + translate(locale, "nativeErrorTitle"), + stableRuntimeUserMessage(error), + ); options.exit(1); } }; diff --git a/memhub/apps/desktop/src/language-preferences-ipc.test.ts b/memhub/apps/desktop/src/language-preferences-ipc.test.ts new file mode 100644 index 0000000..bfc3412 --- /dev/null +++ b/memhub/apps/desktop/src/language-preferences-ipc.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { LanguagePreferences } from "./language-preferences"; +import { installLanguagePreferencesIpc } from "./language-preferences-ipc"; + +describe("language preference IPC", () => { + it("guards reads/writes, validates payloads, broadcasts commits and disposes", async () => { + const handlers = new Map< + string, + (event: string, ...args: unknown[]) => Promise + >(); + const save = vi.fn(async () => {}); + const preferences = await LanguagePreferences.open( + "/synthetic/profile", + "es", + { read: async () => "{}", save }, + ); + const broadcast = vi.fn(); + const dispose = installLanguagePreferencesIpc({ + ipc: { + handle: (name, handler) => handlers.set(name, handler), + removeHandler: (name) => { + handlers.delete(name); + }, + }, + preferences, + requireTrustedWindow: (event: string) => { + if (event !== "trusted") throw Error("untrusted"); + }, + broadcast, + }); + const read = handlers.get("one-memory:get-language-preference"); + const write = handlers.get("one-memory:set-language-preference"); + if (!read || !write) throw Error("Missing language handlers"); + await expect(read("foreign")).rejects.toThrow("untrusted"); + await expect(write("iframe", "es")).rejects.toThrow("untrusted"); + await expect(write("trusted", "en")).rejects.toThrow(); + await expect(write("trusted", "es", "extra")).rejects.toThrow(); + expect(save).not.toHaveBeenCalled(); + expect(await read("trusted")).toEqual({ + preference: "system", + locale: "es", + revision: 0, + }); + await write("trusted", "zh-CN"); + expect(broadcast).toHaveBeenCalledExactlyOnceWith({ + preference: "zh-CN", + locale: "zh-CN", + revision: 1, + }); + dispose(); + dispose(); + expect(handlers.size).toBe(0); + await expect(write("trusted", "es")).rejects.toThrow(); + }); +}); diff --git a/memhub/apps/desktop/src/language-preferences-ipc.ts b/memhub/apps/desktop/src/language-preferences-ipc.ts new file mode 100644 index 0000000..5aea931 --- /dev/null +++ b/memhub/apps/desktop/src/language-preferences-ipc.ts @@ -0,0 +1,50 @@ +import { + isLanguagePreference, + type LanguageSnapshot, +} from "@one-memory/contracts/gui-i18n"; +import type { LanguagePreferences } from "./language-preferences"; + +const GET = "one-memory:get-language-preference"; +const SET = "one-memory:set-language-preference"; + +export function installLanguagePreferencesIpc(options: { + ipc: { + handle( + name: string, + handler: (event: Event, ...args: unknown[]) => Promise, + ): unknown; + removeHandler(name: string): void; + }; + preferences: LanguagePreferences; + requireTrustedWindow(event: Event): unknown; + broadcast(snapshot: LanguageSnapshot): void; +}): () => void { + let closed = false; + const requireTrusted = (event: Event) => { + if (closed) throw Error("language_preferences_closed"); + options.requireTrustedWindow(event); + }; + options.ipc.handle(GET, async (event, ...args) => { + requireTrusted(event); + if (args.length) throw Error("invalid_language_preference_arguments"); + return options.preferences.read(); + }); + options.ipc.handle(SET, async (event, ...args) => { + requireTrusted(event); + if (args.length !== 1 || !isLanguagePreference(args[0])) + throw Error("invalid_language_preference"); + const snapshot = await options.preferences.set(args[0]); + requireTrusted(event); + return snapshot; + }); + const unsubscribe = options.preferences.subscribe((snapshot) => { + if (!closed) options.broadcast(snapshot); + }); + return () => { + if (closed) return; + closed = true; + unsubscribe(); + options.ipc.removeHandler(GET); + options.ipc.removeHandler(SET); + }; +} diff --git a/memhub/apps/desktop/src/language-preferences.test.ts b/memhub/apps/desktop/src/language-preferences.test.ts new file mode 100644 index 0000000..8f0c3b5 --- /dev/null +++ b/memhub/apps/desktop/src/language-preferences.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + LanguagePreferences, + preferredSystemLanguage, +} from "./language-preferences"; + +const roots: string[] = []; +async function profile() { + const root = await mkdtemp(path.join(os.tmpdir(), "memhub-language-")); + roots.push(root); + return root; +} +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("GUI language preferences", () => { + it("defaults to system, persists only the preference in the active profile and reloads", async () => { + const root = await profile(); + const store = await LanguagePreferences.open(root, "es-MX"); + expect(store.read()).toEqual({ + preference: "system", + locale: "es", + revision: 0, + }); + expect(await readdir(root)).toEqual([]); + expect(await store.set("zh-CN")).toEqual({ + preference: "zh-CN", + locale: "zh-CN", + revision: 1, + }); + expect( + JSON.parse(await readFile(path.join(root, "gui-language.json"), "utf8")), + ).toEqual({ preference: "zh-CN" }); + expect((await LanguagePreferences.open(root, "es")).read().preference).toBe( + "zh-CN", + ); + expect( + (await LanguagePreferences.open(await profile(), "es")).read().preference, + ).toBe("system"); + expect(await readdir(root)).toEqual(["gui-language.json"]); + }); + it.each(["{broken", '{"preference":"en"}', "null", "[]"])( + "does not overwrite invalid input %s while loading", + async (content) => { + const root = await profile(); + const file = path.join(root, "gui-language.json"); + await writeFile(file, content); + const store = await LanguagePreferences.open(root, "es"); + expect(store.read().preference).toBe("system"); + expect(await readFile(file, "utf8")).toBe(content); + }, + ); + it("survives failed reads and preserves the snapshot on failed writes, then recovers", async () => { + const save = vi + .fn() + .mockRejectedValueOnce(new Error("disk full")) + .mockResolvedValue(undefined); + const store = await LanguagePreferences.open("/synthetic/profile", "es", { + read: async () => { + throw new Error("unreadable"); + }, + save, + }); + const listener = vi.fn(); + store.subscribe(listener); + await expect(store.set("zh-CN")).rejects.toThrow( + "language_preference_save_failed", + ); + expect(store.read()).toEqual({ + preference: "system", + locale: "es", + revision: 0, + }); + expect(listener).not.toHaveBeenCalled(); + await store.set("zh-CN"); + expect(store.read().revision).toBe(1); + expect(listener).toHaveBeenCalledTimes(1); + }); + it("serializes racing writes and emits only committed snapshots", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const save = vi + .fn() + .mockImplementationOnce(() => gate) + .mockResolvedValue(undefined); + const store = await LanguagePreferences.open("/synthetic/profile", "es", { + read: async () => "{}", + save, + }); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + const first = store.set("zh-CN"); + const second = store.set("es"); + await vi.waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + expect(store.read().revision).toBe(0); + release(); + expect((await first).revision).toBe(1); + expect(await second).toEqual({ + preference: "es", + locale: "es", + revision: 2, + }); + expect(listener.mock.calls.map(([value]) => value.revision)).toEqual([ + 1, 2, + ]); + unsubscribe(); + await store.set("system"); + expect(listener).toHaveBeenCalledTimes(2); + await expect(store.set("invalid" as "es")).rejects.toThrow(); + }); + it("uses only the primary preferred language, falling back to locale if unavailable", () => { + expect( + preferredSystemLanguage({ + getPreferredSystemLanguages: () => ["en", "es"], + getLocale: () => "es", + }), + ).toBe("en"); + expect( + preferredSystemLanguage({ + getPreferredSystemLanguages: () => [], + getLocale: () => "es", + }), + ).toBe("es"); + expect( + preferredSystemLanguage({ + getPreferredSystemLanguages: () => { + throw Error(); + }, + getLocale: () => "es", + }), + ).toBe("es"); + }); +}); diff --git a/memhub/apps/desktop/src/language-preferences.ts b/memhub/apps/desktop/src/language-preferences.ts new file mode 100644 index 0000000..b7ea6b4 --- /dev/null +++ b/memhub/apps/desktop/src/language-preferences.ts @@ -0,0 +1,135 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + isLanguagePreference, + type LanguagePreference, + type LanguageSnapshot, + resolveGuiLocale, +} from "@one-memory/contracts/gui-i18n"; + +interface Storage { + read(file: string): Promise; + save(file: string, content: string): Promise; +} +const storage: Storage = { + read: (file) => readFile(file, "utf8"), + async save(file, content) { + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, content, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); + await rename(temporary, file); + } finally { + await rm(temporary, { force: true }).catch(() => {}); + } + }, +}; + +/** GUI-only preferences, scoped to the active Electron userData profile. */ +export class LanguagePreferences { + private queue: Promise = Promise.resolve(); + private readonly listeners = new Set<(snapshot: LanguageSnapshot) => void>(); + private snapshot: LanguageSnapshot; + + private constructor( + private readonly file: string, + private readonly systemLanguage: string | undefined, + private readonly io: Storage, + preference: LanguagePreference, + ) { + this.snapshot = { + preference, + locale: resolveGuiLocale(preference, systemLanguage), + revision: 0, + }; + } + + static async open( + userData: string, + systemLanguage?: string, + io: Storage = storage, + ): Promise { + const file = path.join(userData, "gui-language.json"); + let preference: LanguagePreference = "system"; + try { + const data: unknown = JSON.parse(await io.read(file)); + if ( + data && + typeof data === "object" && + "preference" in data && + isLanguagePreference(data.preference) + ) { + preference = data.preference; + } + } catch { + // Missing, unreadable or corrupt preferences never block startup or get rewritten. + } + return new LanguagePreferences(file, systemLanguage, io, preference); + } + + read(): LanguageSnapshot { + return { ...this.snapshot }; + } + + set(preference: LanguagePreference): Promise { + if (!isLanguagePreference(preference)) + return Promise.reject(Error("invalid_language_preference")); + const operation = this.queue.then(async () => { + if (preference === this.snapshot.preference) return this.read(); + try { + await this.io.save(this.file, `${JSON.stringify({ preference })}\n`); + } catch { + throw Error("language_preference_save_failed"); + } + this.snapshot = { + preference, + locale: resolveGuiLocale(preference, this.systemLanguage), + revision: this.snapshot.revision + 1, + }; + for (const listener of this.listeners) { + try { + listener(this.read()); + } catch { + /* A closed consumer cannot undo a committed save. */ + } + } + return this.read(); + }); + this.queue = operation.catch(() => {}); + return operation; + } + + subscribe(listener: (snapshot: LanguageSnapshot) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async drain(): Promise { + await this.queue; + } +} + +/** Call only after Electron readiness: later preferred languages are not fallbacks. */ +export function preferredSystemLanguage(app: { + getPreferredSystemLanguages(): string[]; + getLocale(): string; +}): string | undefined { + try { + const primary = app.getPreferredSystemLanguages()[0]; + if (primary) return primary; + } catch { + /* Older/unavailable platform API: use Electron's locale. */ + } + try { + return app.getLocale() || undefined; + } catch { + return undefined; + } +} diff --git a/memhub/apps/desktop/src/main.ts b/memhub/apps/desktop/src/main.ts index 8db9290..c514725 100644 --- a/memhub/apps/desktop/src/main.ts +++ b/memhub/apps/desktop/src/main.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream, existsSync, mkdirSync } from "node:fs"; import path from "node:path"; +import { translate } from "@one-memory/contracts/gui-i18n"; import { RecallBroker } from "@one-memory/host-bridge"; import { checkSkillDrift, @@ -90,6 +91,11 @@ import { resolvePackagedBuildNumber, resolvePackagedCommit, } from "./isolated-runtime"; +import { + LanguagePreferences, + preferredSystemLanguage, +} from "./language-preferences"; +import { installLanguagePreferencesIpc } from "./language-preferences-ipc"; import { disableLegacyLoginItem, wasOpenedByLegacyLoginItem, @@ -122,6 +128,9 @@ let runtimeSupervisor: RuntimeSupervisor | null = null; let runtimeDataDir: string | null = null; let preserveManagedRuntimeOnQuit = false; let disposeTray: (() => void) | null = null; +let disposeApplicationMenu: (() => void) | null = null; +let languagePreferences: LanguagePreferences | null = null; +let disposeLanguagePreferences: (() => void) | null = null; const displayWindows = new Set(); let disposeDisplayPreferences: (() => void) | null = null; let disposeSourceDirectoryPicker: (() => void) | null = null; @@ -143,6 +152,7 @@ installEarlyStartupErrorHandler({ process.env.ONE_MEMORY_DATA_DIR?.trim() || path.join(app.getPath("home"), ".one-memory"), stderr: process.stderr, + readLocale: () => languagePreferences?.read().locale ?? "zh-CN", showError: (title, message) => dialog.showErrorBox(title, message), exit: (code) => app.exit(code), }); @@ -958,6 +968,26 @@ async function initialize(isQuitting: () => boolean): Promise { } try { + if (languagePreferences) { + disposeLanguagePreferences = installLanguagePreferencesIpc({ + ipc: ipcMain, + preferences: languagePreferences, + requireTrustedWindow, + broadcast: (snapshot) => { + for (const window of displayWindows) { + try { + assertTrustedWindow(window); + window.webContents.mainFrame.send( + "one-memory:language-preference-changed", + snapshot, + ); + } catch { + // Navigation/destruction races must never leak to another document. + } + } + }, + }); + } disposeDisplayPreferences = installDisplayPreferences(); disposeSourceDirectoryPicker = installSourceDirectoryPicker(); if (appManagedRuntime) { @@ -1150,10 +1180,20 @@ async function initialize(isQuitting: () => boolean): Promise { }); void dialog.showMessageBox({ type: "warning", - message: "请先将 MemHub 拖到“应用程序”文件夹", - detail: - "从“应用程序”重新打开后,MemHub 才会接入其他应用并安装命令行工具。", - buttons: ["知道了"], + message: translate( + languagePreferences?.read().locale ?? "zh-CN", + "nativeMoveTitle", + ), + detail: translate( + languagePreferences?.read().locale ?? "zh-CN", + "nativeMoveDetail", + ), + buttons: [ + translate( + languagePreferences?.read().locale ?? "zh-CN", + "nativeAcknowledge", + ), + ], }); } else if ( trialProfile === null && @@ -1318,12 +1358,19 @@ if (desktopVersion !== null) { platform: process.platform, background: legacyLoginLaunch || process.argv.includes("--background"), prepare: prepareProtocol, + beforeNativeUi: async () => { + languagePreferences = await LanguagePreferences.open( + app.getPath("userData"), + preferredSystemLanguage(app), + ); + }, initialize, createWindow, getWindows: () => [...displayWindows], installTray: (open) => { - installApplicationMenu({ + disposeApplicationMenu = installApplicationMenu({ productName: app.getName(), + language: languagePreferences ?? undefined, buildMenu: (template) => Menu.buildFromTemplate(template), setMenu: (menu) => Menu.setApplicationMenu(menu), exportLogs: async () => { @@ -1335,6 +1382,7 @@ if (desktopVersion !== null) { }); disposeTray = installBackgroundTray({ app, + language: languagePreferences ?? undefined, platform: process.platform, resourcesPath: process.resourcesPath, appPath: app.getAppPath(), @@ -1379,6 +1427,9 @@ if (desktopVersion !== null) { ...(coreSupervisor ? { instance_id: coreSupervisor.instanceId } : {}), }); // An admitted native effect must settle and journal before Runtime detach. + disposeLanguagePreferences?.(); + disposeLanguagePreferences = null; + await languagePreferences?.drain(); await connectionSetup?.drain(); // BUG-12: stopping the supervisor only releases this process's handles. In // a packaged build the Runtime is its own process and was left running @@ -1427,6 +1478,10 @@ if (desktopVersion !== null) { }); }, dispose: () => { + disposeLanguagePreferences?.(); + disposeLanguagePreferences = null; + disposeApplicationMenu?.(); + disposeApplicationMenu = null; connectionSetup?.dispose(); disposeDiagnostics?.(); disposeDiagnostics = null; @@ -1448,7 +1503,10 @@ if (desktopVersion !== null) { error instanceof Error ? error.name : "UnknownError", ); dialog.showErrorBox( - "MemHub 无法启动", + translate( + languagePreferences?.read().locale ?? "zh-CN", + "nativeErrorTitle", + ), error instanceof Error ? error.message : "desktop_unavailable", ); }, diff --git a/memhub/apps/desktop/src/native-language.ts b/memhub/apps/desktop/src/native-language.ts new file mode 100644 index 0000000..4d59b45 --- /dev/null +++ b/memhub/apps/desktop/src/native-language.ts @@ -0,0 +1,7 @@ +import type { LanguageSnapshot } from "@one-memory/contracts/gui-i18n"; + +/** Shared committed GUI language for native menus, tray, and dialogs. */ +export interface NativeLanguageSource { + read(): LanguageSnapshot; + subscribe(listener: (snapshot: LanguageSnapshot) => void): () => void; +} diff --git a/memhub/apps/desktop/src/preload-sandbox.test.ts b/memhub/apps/desktop/src/preload-sandbox.test.ts index c7daa6f..13c41fe 100644 --- a/memhub/apps/desktop/src/preload-sandbox.test.ts +++ b/memhub/apps/desktop/src/preload-sandbox.test.ts @@ -59,6 +59,9 @@ it("loads the production preload in a sandbox with Electron as its only module", const bridge = exposed as unknown as Record; expect(bridge.getApiBaseUrl).toBeTypeOf("function"); expect(bridge.getApiToken).toBeTypeOf("function"); + expect(bridge.getLanguagePreference).toBeTypeOf("function"); + expect(bridge.setLanguagePreference).toBeTypeOf("function"); + expect(bridge.onLanguagePreferenceChanged).toBeTypeOf("function"); } finally { rmSync(output, { recursive: true, force: true }); } diff --git a/memhub/apps/desktop/src/preload.ts b/memhub/apps/desktop/src/preload.ts index 622d4ec..9abe1c1 100644 --- a/memhub/apps/desktop/src/preload.ts +++ b/memhub/apps/desktop/src/preload.ts @@ -6,6 +6,12 @@ import type { ConnectionSetupResult, ConnectionSetupSelection, } from "@one-memory/contracts"; +import { + isLanguagePreference, + isLanguageSnapshot, + type LanguagePreference, + type LanguageSnapshot, +} from "@one-memory/contracts/gui-i18n"; import type { RecallInstallPreview, RecallUninstallPreview, @@ -30,7 +36,55 @@ import type { SyncHookStatus, } from "./sync-hook"; +function languageSnapshot(payload: unknown): LanguageSnapshot { + if (!isLanguageSnapshot(payload)) + throw Error("language_preferences_unavailable"); + return { + preference: payload.preference, + locale: payload.locale, + revision: payload.revision, + }; +} + contextBridge.exposeInMainWorld("oneMemory", { + getLanguagePreference: async (): Promise => + languageSnapshot( + await ipcRenderer.invoke("one-memory:get-language-preference"), + ), + setLanguagePreference: async ( + preference: LanguagePreference, + ): Promise => { + if (!isLanguagePreference(preference)) + throw Error("invalid_language_preference"); + return languageSnapshot( + await ipcRenderer.invoke( + "one-memory:set-language-preference", + preference, + ), + ); + }, + onLanguagePreferenceChanged: ( + listener: (value: LanguageSnapshot) => void, + ): (() => void) => { + if (typeof listener !== "function") + throw Error("invalid_language_preference_listener"); + const handler = (_event: unknown, payload: unknown) => { + if (isLanguageSnapshot(payload)) listener(languageSnapshot(payload)); + }; + let subscribed = true; + const unsubscribe = () => { + if (!subscribed) return; + subscribed = false; + ipcRenderer.removeListener( + "one-memory:language-preference-changed", + handler, + ); + window.removeEventListener("unload", unsubscribe); + }; + ipcRenderer.on("one-memory:language-preference-changed", handler); + window.addEventListener("unload", unsubscribe, { once: true }); + return unsubscribe; + }, getDebugLogs: (): Promise => ipcRenderer.invoke("one-memory:get-debug-logs"), logUiTrace: (input: { diff --git a/memhub/apps/web/src/App.r6.test.tsx b/memhub/apps/web/src/App.r6.test.tsx index cc33510..a188e02 100644 --- a/memhub/apps/web/src/App.r6.test.tsx +++ b/memhub/apps/web/src/App.r6.test.tsx @@ -9,6 +9,31 @@ import { import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { App } from "./App"; +import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language"; + +it("localizes shared navigation and footer in Spanish", async () => { + localStorage.setItem(LANGUAGE_STORAGE_KEY, "es"); + render( + + + , + ); + const nav = await screen.findByRole("navigation", { + name: "Navegación principal", + }); + expect( + within(nav) + .getAllByRole("button") + .map((button) => button.textContent), + ).toEqual(["Aplicaciones", "Memorias", "Configuración"]); + expect(within(nav).getByRole("button", { name: "Configuración" }).title).toBe( + "Configuración", + ); + expect(screen.getAllByText("Almacenamiento local").length).toBeGreaterThan(0); + expect( + screen.getByRole("link", { name: "Saltar al contenido principal" }), + ).toBeTruthy(); +}); const memoryProps = vi.hoisted(() => vi.fn()); diff --git a/memhub/apps/web/src/App.tsx b/memhub/apps/web/src/App.tsx index fd53128..0faf529 100644 --- a/memhub/apps/web/src/App.tsx +++ b/memhub/apps/web/src/App.tsx @@ -22,10 +22,12 @@ import { CALL_HISTORY_ENTRY_VISIBLE, } from "./ApplicationsHome"; import { getOnboarding } from "./api"; +import { useApplicationsLanguage } from "./applications-language"; import { CallHistory } from "./CallHistory"; import { DebugLogPanel } from "./DebugLogPanel"; import { DesktopFrame } from "./DesktopFrame"; import { FirstUseGuide } from "./FirstUseGuide"; +import { useGuiLanguage } from "./gui-language"; import { HealthPanel } from "./HealthPanel"; import { ImportProgressStrip } from "./ImportProgressStrip"; import { MatterWorkspace } from "./MatterWorkspace"; @@ -64,6 +66,8 @@ export function App() { } function AppContent() { + const { t } = useGuiLanguage(); + const { a } = useApplicationsLanguage(); const [onboarding, setOnboarding] = useState(null); const [startupRead, setStartupRead] = useState<"loading" | "ready" | "error">( "loading", @@ -216,19 +220,19 @@ function AppContent() { {startupRead === "error" ? (

- 暂时无法读取启动状态,请重试。 + {t("startupReadError")}

{ if (!startupFlight.current) readStartup(); }} > - 重新读取 + {t("startupRetry")}
) : (

- 正在打开 MemHub… + {t("startupOpening")}

)} @@ -264,9 +268,9 @@ function AppContent() { the text it used to contain. */}
- 跳到主要内容 + {a("skip")} -