diff --git a/apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts b/apps/desktop/src/main/__tests__/native-file-dialog-copy.test.ts similarity index 59% rename from apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts rename to apps/desktop/src/main/__tests__/native-file-dialog-copy.test.ts index 22aa914054..026a14970c 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-boot-copy.test.ts +++ b/apps/desktop/src/main/__tests__/native-file-dialog-copy.test.ts @@ -18,13 +18,16 @@ */ import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { getStartupRecoveryCopy } from '../runtime-host-boot-copy.js'; +import test from 'node:test'; +import { nativeFileDialogCopy } from '../native-file-dialog-copy.js'; -test('startup recovery copy preserves Traditional Chinese', () => { - const copy = getStartupRecoveryCopy('zh-TW'); - assert.equal(copy.storageRoot.title, 'Maka 工作區需要修復'); - assert.match(copy.storageRoot.detail('C:\\Maka'), /磁碟識別碼/); - assert.equal(copy.runtimeHost.title, '預設 Runtime Host 無法連線'); - assert.match(copy.runtimeHost.detail('失敗'), /設定中處理/); +test('every native file dialog string is translated in each locale', () => { + const en = nativeFileDialogCopy('en'); + for (const locale of ['zh-CN', 'zh-TW'] as const) { + const copy = nativeFileDialogCopy(locale); + for (const key of Object.keys(en) as Array) { + assert.notEqual(copy[key], en[key], `${locale}: ${key} is still the English string`); + assert.match(copy[key], /[一-鿿]/u, `${locale}: ${key} carries no Han text`); + } + } }); diff --git a/apps/desktop/src/main/__tests__/pet-pack-import.test.ts b/apps/desktop/src/main/__tests__/pet-pack-import.test.ts index 0306cf8f5b..643f81f3e4 100644 --- a/apps/desktop/src/main/__tests__/pet-pack-import.test.ts +++ b/apps/desktop/src/main/__tests__/pet-pack-import.test.ts @@ -175,6 +175,7 @@ test('IPC imports only the native picker result and returns stable envelopes', a }, settingsStore: createSettingsStore(stateRoot), now: () => 42, + resolveLocale: async () => 'en' as const, }); const result = await handlers.get('pets:importLocalDirectory')?.({}, '/untrusted/path'); @@ -211,6 +212,7 @@ test('IPC returns sprite bytes, removes the pack, and emits only real mutations' }, settingsStore, now: () => 84, + resolveLocale: async () => 'en' as const, }); await settingsStore.update({ personalization: { selectedPetId: 'likun.missing' } }); assert.equal(await handlers.get('pets:getSelection')?.({}), null); diff --git a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts index b85e169067..772556b560 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts @@ -49,6 +49,7 @@ test("projects an empty Skill surface until a remote Project is selected", async getDefaultPermissionMode: async () => "ask", openPath: async () => "", allowLocalPaths: false, + resolveLocale: async () => 'en' as const, }); for (const channel of [ @@ -68,6 +69,7 @@ test("binds new-session Skill discovery to its explicit Project", async () => { const resolvedProjectIds: Array = []; let target: unknown; registerRuntimeHostSkillsIpc({ + resolveLocale: async () => 'en' as const, ipcMain: { handle: (channel, listener) => handlers.set(channel, listener), handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), diff --git a/apps/desktop/src/main/markdown-save-ipc-main.ts b/apps/desktop/src/main/markdown-save-ipc-main.ts index cdee09b8ad..cd5ce7cca2 100644 --- a/apps/desktop/src/main/markdown-save-ipc-main.ts +++ b/apps/desktop/src/main/markdown-save-ipc-main.ts @@ -19,16 +19,27 @@ import type { IpcMain } from 'electron'; import { saveMarkdownViaDialog } from './markdown-save-main.js'; +import type { UiLocale } from '@maka/core/ui-locale'; import type { createMainWindowController } from './main-window.js'; +import { nativeFileDialogCopy } from './native-file-dialog-copy.js'; export function registerMarkdownSaveIpc(input: { readonly ipcMain: Pick; readonly mainWindowController: ReturnType; + readonly resolveLocale: () => Promise; }): void { - input.ipcMain.handle('daily-review:saveMarkdownToFile', (_event, value) => - saveMarkdownViaDialog(input.mainWindowController, value, 'Save daily review'), + input.ipcMain.handle('daily-review:saveMarkdownToFile', async (_event, value) => + saveMarkdownViaDialog( + input.mainWindowController, + value, + nativeFileDialogCopy(await input.resolveLocale()).saveDailyReview, + ), ); - input.ipcMain.handle('chat:saveConversationToFile', (_event, value) => - saveMarkdownViaDialog(input.mainWindowController, value, 'Save conversation'), + input.ipcMain.handle('chat:saveConversationToFile', async (_event, value) => + saveMarkdownViaDialog( + input.mainWindowController, + value, + nativeFileDialogCopy(await input.resolveLocale()).saveConversation, + ), ); } diff --git a/apps/desktop/src/main/native-file-dialog-copy.ts b/apps/desktop/src/main/native-file-dialog-copy.ts new file mode 100644 index 0000000000..3ab00ff9b3 --- /dev/null +++ b/apps/desktop/src/main/native-file-dialog-copy.ts @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +/** + * Copy for the native open/save panels. Format names (`Markdown`, + * `PNG and JPEG`) stay untranslated: they identify a file format, not a + * product concept. `allFiles` is translated because the panel lists it beside + * the OS's own localized entries. + */ +interface NativeFileDialogCopy { + readonly referenceFolder: string; + readonly addAttachments: string; + readonly importSkillSource: string; + readonly importCustomPet: string; + readonly saveDailyReview: string; + readonly saveConversation: string; + readonly allFiles: string; +} + +const COPY = { + 'zh-CN': { + referenceFolder: '引用文件夹', + addAttachments: '添加附件', + importSkillSource: '导入 Skill 源文件', + importCustomPet: '导入自定义宠物', + saveDailyReview: '保存每日回顾', + saveConversation: '保存对话', + allFiles: '所有文件', + }, + 'zh-TW': { + referenceFolder: '引用資料夾', + addAttachments: '新增附件', + importSkillSource: '匯入 Skill 原始檔', + importCustomPet: '匯入自訂寵物', + saveDailyReview: '儲存每日回顧', + saveConversation: '儲存對話', + allFiles: '所有檔案', + }, + en: { + referenceFolder: 'Reference folder', + addAttachments: 'Add attachments', + importSkillSource: 'Import Skill source', + importCustomPet: 'Import custom pet', + saveDailyReview: 'Save daily review', + saveConversation: 'Save conversation', + allFiles: 'All Files', + }, +} satisfies UiCatalog; + +export function nativeFileDialogCopy(locale: UiLocale): NativeFileDialogCopy { + return COPY[locale]; +} diff --git a/apps/desktop/src/main/pet-pack-import.ts b/apps/desktop/src/main/pet-pack-import.ts index 73de5c4ee8..4657e400a0 100644 --- a/apps/desktop/src/main/pet-pack-import.ts +++ b/apps/desktop/src/main/pet-pack-import.ts @@ -32,6 +32,8 @@ import { } from '@maka/storage/pet-pack-store'; import type { SettingsStore } from '@maka/storage/settings-store'; import type { createMainWindowController } from './main-window.js'; +import type { UiLocale } from '@maka/core/ui-locale'; +import { nativeFileDialogCopy } from './native-file-dialog-copy.js'; type MainWindowController = Pick< ReturnType, @@ -123,6 +125,7 @@ export function registerPetPackIpc(input: { readonly settingsStore: Pick; readonly store?: PetPackStore; readonly now?: () => number; + readonly resolveLocale: () => Promise; }): void { const store = input.store ?? createPetPackStore(input.workspaceRoot); const now = input.now ?? Date.now; @@ -207,7 +210,7 @@ export function registerPetPackIpc(input: { ); input.ipcMain.handle('pets:importLocalDirectory', async (): Promise => { const selection = await input.mainWindowController.showOpenDialog({ - title: 'Import custom pet', + title: nativeFileDialogCopy(await input.resolveLocale()).importCustomPet, properties: ['openDirectory'], }); if (selection.canceled || selection.filePaths.length === 0) { diff --git a/apps/desktop/src/main/runtime-host-boot-copy.ts b/apps/desktop/src/main/runtime-host-boot-copy.ts deleted file mode 100644 index fb1489d8c7..0000000000 --- a/apps/desktop/src/main/runtime-host-boot-copy.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; - -interface StartupRecoveryCopy { - storageRoot: { - title: string; - message: string; - detail(workspaceRoot: string): string; - buttons: [string, string]; - }; - runtimeHost: { - title: string; - message(profileName: string): string; - detail(message: string): string; - buttons: [string, string, string]; - }; -} - -const STARTUP_RECOVERY_COPY = { - 'zh-CN': { - storageRoot: { - title: 'Maka 工作区需要修复', - message: 'Maka 无法验证这个工作区。', - detail: (workspaceRoot) => - `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}`, - buttons: ['修复工作区', '退出'], - }, - runtimeHost: { - title: '默认 Runtime Host 无法连接', - message: (profileName) => `无法连接 ${profileName}`, - detail: (message) => - `${message}\n\n你可以重试、改用 Local 作为默认 Host,或保持当前选择并稍后在设置中处理。`, - buttons: ['重试', '改用 Local', '保持离线'], - }, - }, - 'zh-TW': { - storageRoot: { - title: 'Maka 工作區需要修復', - message: 'Maka 無法驗證這個工作區。', - detail: (workspaceRoot) => - `系統中的磁碟識別碼可能已變更。只有當這是本機原本的 Maka 工作區,而不是複製的工作區時,才選擇修復。\n\n${workspaceRoot}`, - buttons: ['修復工作區', '離開'], - }, - runtimeHost: { - title: '預設 Runtime Host 無法連線', - message: (profileName) => `無法連線 ${profileName}`, - detail: (message) => - `${message}\n\n你可以重試、改用 Local 作為預設 Host,或保留目前選擇並稍後在設定中處理。`, - buttons: ['重試', '改用 Local', '保持離線'], - }, - }, - en: { - storageRoot: { - title: 'Maka workspace needs repair', - message: 'Maka cannot verify this workspace.', - detail: (workspaceRoot) => - `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`, - buttons: ['Repair Workspace', 'Exit'], - }, - runtimeHost: { - title: 'Default Runtime Host is unavailable', - message: (profileName) => `Could not connect to ${profileName}`, - detail: (message) => - `${message}\n\nRetry, use Local as the default Host, or keep the current selection and resolve it later in Settings.`, - buttons: ['Retry', 'Use Local', 'Keep Offline'], - }, - }, -} satisfies UiCatalog; - -export function getStartupRecoveryCopy(locale: UiLocale): StartupRecoveryCopy { - return STARTUP_RECOVERY_COPY[locale]; -} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 2242f354c4..1b8647ff40 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -113,6 +113,7 @@ import { computerUseServiceHealth } from "./computer-use-host.js"; import { registerDesktopDiagnosticsIpc } from "./desktop-diagnostics-ipc-main.js"; import { assembleDesktopNativeCapabilities } from "./desktop-native-capability-assembly.js"; import { clientSettingsConfirmation } from "./client-settings-confirmation-copy.js"; +import { nativeFileDialogCopy } from "./native-file-dialog-copy.js"; import { createDesktopLocaleAuthority } from "./desktop-locale-authority.js"; import { buildRiveWorkflowTool } from "./rive-workflow-tool.js"; import { applyAppIcon } from "./app-icon-surface.js"; @@ -1092,7 +1093,13 @@ mcpManager.onChange(() => { }); registerPersistentClientIpc(); -registerPetPackIpc({ ipcMain, workspaceRoot, mainWindowController, settingsStore }); +registerPetPackIpc({ + ipcMain, + workspaceRoot, + mainWindowController, + settingsStore, + resolveLocale: () => desktopLocale.resolve(), +}); const browserIpc = registerBrowserIpc({ mainWindowController, isHostActive: (scope) => runtimeHostManager?.ownsScope(scope) === true, @@ -1701,6 +1708,7 @@ function registerHostClientIpc( ipcMain: scopedIpc, }); registerRuntimeHostSkillsIpc({ + resolveLocale: () => desktopLocale.resolve(), ipcMain: scopedIpc, client, workspaceRoot, @@ -1879,7 +1887,11 @@ function registerPersistentClientIpc(): void { await clientSettingsEffects.apply(settings, true); }, }); - registerMarkdownSaveIpc({ ipcMain, mainWindowController }); + registerMarkdownSaveIpc({ + ipcMain, + mainWindowController, + resolveLocale: () => desktopLocale.resolve(), + }); registerDesktopRuntimeHostProfileIpc(ipcMain, runtimeHostProfileService); registerDesktopGuestSessionMountIpc( ipcMain, @@ -1987,7 +1999,7 @@ function registerPersistentClientIpc(): void { if (!local || local.readiness !== 'ready') throw new Error('Local Runtime Host is unavailable'); const hostId = local.candidate.client.hostId; const result = await mainWindowController.showOpenDialog({ - title: 'Reference folder', + title: nativeFileDialogCopy(await desktopLocale.resolve()).referenceFolder, properties: ['openDirectory'], }); if (result.canceled || !result.filePaths[0]) return { ok: false, reason: 'cancelled' }; @@ -1995,7 +2007,7 @@ function registerPersistentClientIpc(): void { }); ipcMain.handle("attachments:pickFiles", async (event) => { const result = await mainWindowController.showOpenDialog({ - title: "Add attachments", + title: nativeFileDialogCopy(await desktopLocale.resolve()).addAttachments, properties: ["openFile", "multiSelections"], }); if (result.canceled || !result.filePaths[0]) diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 76a9d37e7a..d019762473 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -46,6 +46,8 @@ import type { DesktopRuntimeHostClient, DesktopSkillCatalogSnapshot, } from "./runtime-host-client.js"; +import type { UiLocale } from "@maka/core/ui-locale"; +import { nativeFileDialogCopy } from "./native-file-dialog-copy.js"; import { resolveSkillOpenPath } from "./skill-open-path.js"; import { handleReconnectableRead, @@ -68,6 +70,7 @@ interface RuntimeHostSkillsIpcDeps { readonly getDefaultPermissionMode: () => Promise; readonly openPath: (path: string) => Promise; readonly allowLocalPaths?: boolean; + readonly resolveLocale: () => Promise; } interface GovernanceProjection { @@ -182,12 +185,14 @@ export function registerRuntimeHostSkillsIpc( if (deps.allowLocalPaths === false) { throw new Error("Local Skill import is unavailable for a remote Runtime Host"); } + const copy = nativeFileDialogCopy(await deps.resolveLocale()); const result = await deps.mainWindowController.showOpenDialog({ - title: "Import Skill source", + title: copy.importSkillSource, properties: ["openFile"], + // Format names identify a file format, not a product concept. filters: [ { name: "Skill Markdown", extensions: ["md"] }, - { name: "All Files", extensions: ["*"] }, + { name: copy.allFiles, extensions: ["*"] }, ], }); if (result.canceled || result.filePaths.length === 0) {