From ccae0107529142877d8b63588cf88a00c446e1d5 Mon Sep 17 00:00:00 2001 From: NekoPunch Date: Sat, 5 Sep 2026 01:05:32 -0700 Subject: [PATCH] fix(i18n): emit settings expected-result codes from producers Settings-area producers (memory, data, permission center, connection test, computer-use health, dev dialogs) emitted zh prose that reached en users verbatim, and presenters sniffed CJK to decide whether to show it. Producers now return stable codes and redact probe detail at the source; each settings catalog maps its codes per locale with complete message templates and an explicit unknown fallback. The remaining CJK passthrough is gated to zh. Generated-by: Claude Code Claude-Session: https://claude.ai/code/session_01AqdSkg56F2x55wEGRWvzcB --- .../main/__tests__/computer-use-host.test.ts | 8 +- .../github-copilot-local-credential.test.ts | 6 +- .../main/__tests__/oauth-result-copy.test.ts | 60 ++++++ .../__tests__/permission-center-copy.test.ts | 26 +++ .../runtime-host-artifacts-ipc-main.test.ts | 2 + .../runtime-host-memory-ipc-main.test.ts | 1 + .../__tests__/settings-memory-copy.test.ts | 194 ++++++++++++++++++ apps/desktop/src/main/browser-message-box.ts | 5 +- apps/desktop/src/main/capability-snapshot.ts | 92 +++------ apps/desktop/src/main/computer-use-host.ts | 18 +- apps/desktop/src/main/main.ts | 51 +++-- .../oauth/github-copilot-local-credential.ts | 10 +- .../main/runtime-host-artifacts-ipc-main.ts | 10 +- apps/desktop/src/main/runtime-host-boot.ts | 3 + .../src/main/runtime-host-config-ipc-main.ts | 12 +- .../runtime-host-github-copilot-ipc-main.ts | 22 +- .../src/main/runtime-host-memory-ipc-main.ts | 34 ++- apps/desktop/src/preload/bridge-contract.d.ts | 10 +- apps/desktop/src/preload/preload.ts | 10 +- .../features/connection-settings/index.ts | 3 +- .../settings-provider-copy.ts | 77 ++++++- .../subscription-result-message.ts | 53 ----- .../locales/permission-center-copy.ts | 109 ++++++++++ .../renderer/locales/settings-data-copy.ts | 12 +- .../renderer/locales/settings-memory-copy.ts | 178 +++++++++++++--- .../locales/settings-test-result-copy.ts | 4 +- .../renderer/settings/data-settings-page.tsx | 5 +- .../settings/permission-center-page.tsx | 54 ++++- .../settings/provider-oauth-section.tsx | 4 +- .../renderer/settings/settings-error-copy.ts | 3 +- .../use-memory-settings-controller.ts | 16 +- .../renderer/settings/use-oauth-login-flow.ts | 19 +- .../settings/settings-pages.stories.tsx | 4 + packages/core/src/capabilities.ts | 41 ++++ packages/core/src/oauth-subscription.ts | 8 +- packages/runtime/src/test-connection.ts | 2 - scripts/storybook-visual-smoke.mjs | 13 +- scripts/storybook-visual-smoke.test.mjs | 33 ++- 38 files changed, 930 insertions(+), 282 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/oauth-result-copy.test.ts create mode 100644 apps/desktop/src/main/__tests__/settings-memory-copy.test.ts delete mode 100644 apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts diff --git a/apps/desktop/src/main/__tests__/computer-use-host.test.ts b/apps/desktop/src/main/__tests__/computer-use-host.test.ts index 51c89b636e..3fe5bd3a1e 100644 --- a/apps/desktop/src/main/__tests__/computer-use-host.test.ts +++ b/apps/desktop/src/main/__tests__/computer-use-host.test.ts @@ -36,7 +36,7 @@ describe('Computer Use host health', () => { it('does not report a binary-only executor as healthy before first use', () => { assert.deepEqual(computerUseServiceHealth('maka-cu', snapshot('idle')), { state: 'not_run', - reason: 'maka-cu 已可用,将在首次调用时启动。', + reason: 'cu_executor_lazy_start', }); }); @@ -44,7 +44,7 @@ describe('Computer Use host health', () => { assert.equal(computerUseServiceHealth('maka-cu', snapshot('ready')).state, 'healthy'); assert.equal( computerUseServiceHealth('maka-cu', snapshot('backing_off')).reason, - 'maka-cu executor 正在启动或恢复。', + 'cu_executor_recovering', ); assert.equal( computerUseServiceHealth('maka-cu', snapshot('starting')).state, @@ -52,11 +52,11 @@ describe('Computer Use host health', () => { ); assert.deepEqual(computerUseServiceHealth('maka-cu', snapshot('unavailable')), { state: 'not_available', - reason: 'maka-cu executor 启动失败或已退出。', + reason: 'cu_executor_start_failed', }); assert.deepEqual(computerUseServiceHealth('maka-cu', snapshot('disposed')), { state: 'not_available', - reason: 'maka-cu executor 已停止。', + reason: 'cu_executor_stopped', }); }); diff --git a/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts index a034d7cd1d..37d330c738 100644 --- a/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts +++ b/apps/desktop/src/main/__tests__/github-copilot-local-credential.test.ts @@ -71,7 +71,7 @@ describe('importGitHubCopilotLocalCredential', () => { assert.equal(imported.result.ok, false); if (!imported.result.ok) { assert.equal(imported.result.reason, 'token_exchange_failed'); - assert.match(imported.result.message, /不支持 classic PAT/); + assert.equal(imported.result.code, 'copilot_classic_pat_unsupported'); assert.equal(imported.result.message.includes('ghp_classic_pat'), false); } assert.equal(imported.secret, undefined); @@ -83,7 +83,7 @@ describe('importGitHubCopilotLocalCredential', () => { }); assert.equal(imported.result.ok, false); - if (!imported.result.ok) assert.match(imported.result.message, /凭据类型不受支持/); + if (!imported.result.ok) assert.equal(imported.result.code, 'copilot_credential_type_unsupported'); assert.equal(imported.secret, undefined); }); @@ -95,7 +95,7 @@ describe('importGitHubCopilotLocalCredential', () => { }); assert.equal(imported.result.ok, false); - if (!imported.result.ok) assert.match(imported.result.message, /未找到可导入/); + if (!imported.result.ok) assert.equal(imported.result.code, 'copilot_local_credential_missing'); assert.equal(imported.secret, undefined); }); }); diff --git a/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts new file mode 100644 index 0000000000..6e1c75e2d7 --- /dev/null +++ b/apps/desktop/src/main/__tests__/oauth-result-copy.test.ts @@ -0,0 +1,60 @@ +/* + * 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 assert from "node:assert/strict"; +import test from "node:test"; +// Lives under main/__tests__ on purpose: desktop main tests run from dist via +// `node --test "dist/main/**/*.test.js"`, and settings-provider-copy is a pure +// copy module (no react), so it is safe to exercise from node. Same precedent +// as permission-center-copy.test.ts. +import { + connectionTestFailureMessage, + subscriptionResultMessage, +} from "../../renderer/features/connection-settings/index.js"; + +test("renders a coded Copilot import failure per locale, ignoring its machine message", () => { + const result = { code: "copilot_subscription_unavailable", message: "copilot_subscription_unavailable" }; + assert.equal(subscriptionResultMessage(result, "fallback", "zh-CN"), "当前 GitHub 账号没有可用的 Copilot 订阅权限。"); + assert.equal(subscriptionResultMessage(result, "fallback", "en"), "This GitHub account has no usable Copilot subscription."); +}); + +test("renders the typed experimental_disabled reason per locale", () => { + const result = { reason: "experimental_disabled", message: "enrollment is disabled for this provider" }; + assert.equal(subscriptionResultMessage(result, "fallback", "zh-CN"), "本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。"); + assert.equal(subscriptionResultMessage(result, "fallback", "en"), "This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it."); +}); + +test("falls back to catalog copy for an unknown code instead of the raw message", () => { + const result = { code: "not_a_known_code", message: "内部错误" }; + assert.equal(subscriptionResultMessage(result, "fallback", "en"), "fallback"); + assert.equal(subscriptionResultMessage(result, "fallback", "zh-CN"), "fallback"); +}); + +test("renders provider rate limits consistently from the stable status code", () => { + const result = { ok: false, statusCode: 429, errorClass: "provider_unavailable" } as const; + const troubleshooting = { auth: "auth", recheck: "recheck" }; + assert.equal( + connectionTestFailureMessage(result, troubleshooting, "zh-CN"), + "当前账号或模型服务触发速率限制,请稍后重试。", + ); + assert.equal( + connectionTestFailureMessage(result, troubleshooting, "en"), + "This account or model service is rate-limited. Try again later.", + ); +}); diff --git a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts index 318b15a877..f68a808474 100644 --- a/apps/desktop/src/main/__tests__/permission-center-copy.test.ts +++ b/apps/desktop/src/main/__tests__/permission-center-copy.test.ts @@ -25,3 +25,29 @@ test('presents a granted OS permission as a verified success', () => { assert.equal(getPermissionCenterCopy('zh-CN').osStates.granted.tone, 'success'); assert.equal(getPermissionCenterCopy('en').osStates.granted.tone, 'success'); }); + +test('renders capability reason codes per locale', () => { + const zh = getPermissionCenterCopy('zh-CN'); + const en = getPermissionCenterCopy('en'); + assert.equal(zh.reasons['missing platform credentials'], '未配置平台凭据'); + assert.equal(en.reasons['missing platform credentials'], 'Platform credentials are not configured'); + assert.equal(zh.reasons.cu_executor_recovering, 'maka-cu executor 正在启动或恢复。'); + assert.equal(en.reasons.cu_executor_recovering, 'The maka-cu executor is starting or recovering.'); +}); + +test('composes the computer-use backend status from snapshot facts per locale', () => { + const zh = getPermissionCenterCopy('zh-CN'); + const en = getPermissionCenterCopy('en'); + assert.equal( + zh.cuBackendStatus(['辅助功能', '屏幕录制'], 'healthy'), + 'maka-cu artifact 已通过本地完整性检查。等待辅助功能、屏幕录制权限。操作与截图 service 已就绪;按目标与动作类别授权后可操作本机应用。', + ); + assert.equal( + zh.cuBackendStatus([], 'not_run'), + 'maka-cu artifact 已通过本地完整性检查。service 将在首次调用时启动;按目标与动作类别授权后可操作本机应用。', + ); + assert.equal( + en.cuBackendStatus(['Accessibility'], 'degraded'), + 'The maka-cu artifact passed the local integrity check. Waiting for Accessibility permission. The maka-cu service is starting or recovering.', + ); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 283d33f342..56a462ea95 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -52,6 +52,7 @@ function attachmentReadHandler( ): Handler { const handlers = new Map(); registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler), }, @@ -119,6 +120,7 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" try { registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler), }, diff --git a/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts index 17391c12fc..fe7e199457 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-memory-ipc-main.test.ts @@ -175,6 +175,7 @@ test('does not project or open remote Runtime Host file paths', async () => { assert.deepEqual(projected.backups.map(({ path }) => path), ['']); assert.deepEqual(opened, { ok: false, + code: 'remote_host_owned', message: 'Memory files are owned by the remote Runtime Host', }); }); diff --git a/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts b/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts new file mode 100644 index 0000000000..fa404ac45b --- /dev/null +++ b/apps/desktop/src/main/__tests__/settings-memory-copy.test.ts @@ -0,0 +1,194 @@ +/* + * 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 assert from 'node:assert/strict'; +import test from 'node:test'; +import { UI_LOCALES, type UiCatalog } from '@maka/core/ui-locale'; +import { + getMemorySettingsCopy, + memoryResultMessage, + type MemoryResultCode, +} from '../../renderer/locales/settings-memory-copy.js'; + +const rejectionCopy = { + invalid_content: ['MEMORY.md content is invalid. Check its format and try again.', 'MEMORY.md 内容无效,请检查格式后重试。'], + invalid_scope: ['The memory operation has an invalid scope.', '当前记忆操作的作用域无效。'], + not_found: ['The memory entry was not found.', '找不到对应的记忆条目。'], + not_pending: ['The memory entry is not pending review.', '对应的记忆条目不在待确认状态。'], + upload_not_found: ['The memory upload session does not exist or has expired.', '记忆上传会话不存在或已过期。'], + upload_incomplete: ['The memory content has not finished uploading.', '记忆内容尚未上传完整。'], + upload_conflict: ['Another memory upload is in progress. Try again.', '另一个记忆上传正在进行,请重试。'], +} satisfies Partial>; + +test('renders Runtime Host memory rejection codes per locale', () => { + for (const [code, [en, zh]] of Object.entries(rejectionCopy)) { + assert.equal(memoryResultMessage({ code }, getMemorySettingsCopy('en'), 'fallback'), en); + assert.equal(memoryResultMessage({ code }, getMemorySettingsCopy('zh-CN'), 'fallback'), zh); + } +}); + +test('falls back for an unknown Runtime Host memory rejection code', () => { + assert.equal( + memoryResultMessage({ code: 'future_host_code' }, getMemorySettingsCopy('en'), 'fallback'), + 'fallback', + ); +}); + +const formattedCopy = { + 'zh-CN': { + counts: [ + ['0 条生效', '草稿 0 条生效', '0 条已归档', '草稿 0 条已归档', '0 条记忆'], + ['1 条生效', '草稿 1 条生效', '1 条已归档', '草稿 1 条已归档', '1 条记忆'], + ['2 条生效', '草稿 2 条生效', '2 条已归档', '草稿 2 条已归档', '2 条记忆'], + ], + summaries: [ + ['当前 0 条生效;已保留上一版备份。', '0 条生效'], + ['当前 1 条生效;已保留上一版备份。', '1 条生效'], + ['当前 2 条生效;已保留上一版备份。', '2 条生效'], + ['当前 0 条生效 / 2 条已归档;已保留上一版备份。', '0 条生效 / 2 条已归档'], + ['当前 1 条生效 / 1 条已归档;已保留上一版备份。', '1 条生效 / 1 条已归档'], + ['当前 1 条生效 / 2 条已归档;已保留上一版备份。', '1 条生效 / 2 条已归档'], + ['当前 2 条生效 / 1 条已归档;已保留上一版备份。', '2 条生效 / 1 条已归档'], + ['当前 2 条生效 / 2 条已归档;已保留上一版备份。', '2 条生效 / 2 条已归档'], + ], + redacted: [ + '写入前已替换疑似 token、API key 或密码;当前 1 条生效;已保留上一版备份。', + '写入前已替换疑似 token、API key 或密码;当前 1 条生效 / 2 条已归档;已保留上一版备份。', + ], + backupFailures: ['打开重置前备份失败', '打开恢复前备份失败', '打开保存前备份失败'], + preview: ['预览已按 8,000 字符上限截断', '预览 1,234 / 8,000 字符', 'prompt 上限 8,000 字符'], + labels: [ + '0 / 0 条匹配', '1 / 2 条匹配', 'Memory #2列表', 'Memory #2 记忆操作', + '归档:Memory #2', '打开备份候选 Backup #2', '恢复备份候选 Backup #2', + '复制备份候选引用 Backup #2', '归档到草稿,保存前不会写入 MEMORY.md', + '恢复到草稿,保存前不会写入 MEMORY.md', + '会先备份当前 MEMORY.md,再用最近一次备份覆盖当前文件。将恢复:Backup #2', + '会先备份当前 MEMORY.md,再用选中的备份覆盖当前文件。将恢复:Backup #2', + ], + }, + 'zh-TW': { + counts: [ + ['0 則生效', '草稿 0 則生效', '0 則已歸檔', '草稿 0 則已歸檔', '0 則記憶'], + ['1 則生效', '草稿 1 則生效', '1 則已歸檔', '草稿 1 則已歸檔', '1 則記憶'], + ['2 則生效', '草稿 2 則生效', '2 則已歸檔', '草稿 2 則已歸檔', '2 則記憶'], + ], + summaries: [ + ['目前 0 則生效;已保留上一版備份。', '0 則生效'], + ['目前 1 則生效;已保留上一版備份。', '1 則生效'], + ['目前 2 則生效;已保留上一版備份。', '2 則生效'], + ['目前 0 則生效 / 2 則已歸檔;已保留上一版備份。', '0 則生效 / 2 則已歸檔'], + ['目前 1 則生效 / 1 則已歸檔;已保留上一版備份。', '1 則生效 / 1 則已歸檔'], + ['目前 1 則生效 / 2 則已歸檔;已保留上一版備份。', '1 則生效 / 2 則已歸檔'], + ['目前 2 則生效 / 1 則已歸檔;已保留上一版備份。', '2 則生效 / 1 則已歸檔'], + ['目前 2 則生效 / 2 則已歸檔;已保留上一版備份。', '2 則生效 / 2 則已歸檔'], + ], + redacted: [ + '寫入前已遮蔽疑似 token、API key 或密碼;目前 1 則生效;已保留上一版備份。', + '寫入前已遮蔽疑似 token、API key 或密碼;目前 1 則生效 / 2 則已歸檔;已保留上一版備份。', + ], + backupFailures: ['開啟重置前備份失敗', '開啟恢復前備份失敗', '開啟儲存前備份失敗'], + preview: ['預覽已依 8,000 字元上限截斷', '預覽 1,234 / 8,000 字元', 'prompt 上限 8,000 字元'], + labels: [ + '0 / 0 則符合', '1 / 2 則符合', 'Memory #2清單', 'Memory #2 記憶操作', + '歸檔:Memory #2', '開啟備份候選 Backup #2', '還原備份候選 Backup #2', + '複製備份候選引用 Backup #2', '歸檔到草稿,儲存前不會寫入 MEMORY.md', + '恢復到草稿,儲存前不會寫入 MEMORY.md', + '會先備份目前的 MEMORY.md,再以最近一次備份覆蓋目前檔案。將還原:Backup #2', + '會先備份目前的 MEMORY.md,再以選取的備份覆蓋目前檔案。將還原:Backup #2', + ], + }, + en: { + counts: [ + ['0 active entries', 'Draft · 0 active entries', '0 archived entries', 'Draft · 0 archived entries', '0 memories'], + ['1 active entry', 'Draft · 1 active entry', '1 archived entry', 'Draft · 1 archived entry', '1 memory'], + ['2 active entries', 'Draft · 2 active entries', '2 archived entries', 'Draft · 2 archived entries', '2 memories'], + ], + summaries: [ + ['0 active entries; the previous version was backed up.', '0 active entries'], + ['1 active entry; the previous version was backed up.', '1 active entry'], + ['2 active entries; the previous version was backed up.', '2 active entries'], + ['0 active entries / 2 archived entries; the previous version was backed up.', '0 active entries / 2 archived entries'], + ['1 active entry / 1 archived entry; the previous version was backed up.', '1 active entry / 1 archived entry'], + ['1 active entry / 2 archived entries; the previous version was backed up.', '1 active entry / 2 archived entries'], + ['2 active entries / 1 archived entry; the previous version was backed up.', '2 active entries / 1 archived entry'], + ['2 active entries / 2 archived entries; the previous version was backed up.', '2 active entries / 2 archived entries'], + ], + redacted: [ + 'Suspected tokens, API keys, or passwords were redacted before writing; 1 active entry; the previous version was backed up.', + 'Suspected tokens, API keys, or passwords were redacted before writing; 1 active entry / 2 archived entries; the previous version was backed up.', + ], + backupFailures: ['Failed to open Before reset', 'Failed to open Before restore', 'Failed to open Before save'], + preview: ['Preview truncated at the 8,000-character limit', 'Preview 1,234 / 8,000 characters', 'Prompt limit: 8,000 characters'], + labels: [ + '0 / 0 matching', '1 / 2 matching', 'Memory #2 list', 'Memory #2 memory actions', + 'Archive: Memory #2', 'Open backup candidate Backup #2', 'Restore backup candidate Backup #2', + 'Copy backup candidate reference Backup #2', 'Archive in draft; MEMORY.md is not written until you save', + 'Restore to draft; MEMORY.md is not written until you save', + 'The current MEMORY.md will be backed up before the latest backup replaces it. Restore: Backup #2', + 'The current MEMORY.md will be backed up before the selected backup replaces it. Restore: Backup #2', + ], + }, +} satisfies UiCatalog<{ + counts: string[][]; + summaries: string[][]; + redacted: string[]; + backupFailures: string[]; + preview: string[]; + labels: string[]; +}>; + +for (const locale of UI_LOCALES) { + const expected = formattedCopy[locale]; + const copy = getMemorySettingsCopy(locale); + + test(`renders complete memory counts and summaries in ${locale}`, () => { + for (const count of [0, 1, 2]) { + assert.deepEqual([ + copy.countActive(count), copy.countActive(count, true), + copy.countArchived(count), copy.countArchived(count, true), copy.countEntries(count), + ], expected.counts[count]); + assert.equal(copy.countActive(count, false), expected.counts[count][0]); + assert.equal(copy.countArchived(count, false), expected.counts[count][2]); + } + const summaryCounts = [[0, 0], [1, 0], [2, 0], [0, 2], [1, 1], [1, 2], [2, 1], [2, 2]] as const; + assert.deepEqual(summaryCounts.map(([active, archived]) => [ + copy.saveSummary(active, archived), copy.backupSummary(active, archived), + ]), expected.summaries); + }); + + test(`renders complete redaction, backup failure, and preview messages in ${locale}`, () => { + assert.deepEqual([0, 2].map((archived) => copy.redactedDetail(copy.saveSummary(1, archived))), expected.redacted); + assert.deepEqual((['reset', 'restore', 'save'] as const).map((kind) => ( + copy.openBackupFailed(copy.backupKinds[kind]) + )), expected.backupFailures); + assert.deepEqual([ + copy.previewTruncated('8,000'), copy.previewUsage('1,234', '8,000'), copy.previewLimit('8,000'), + ], expected.preview); + }); + + test(`renders complete memory action and restore descriptions in ${locale}`, () => { + assert.deepEqual([ + copy.countMatches(0, 0), copy.countMatches(1, 2), copy.listAria('Memory #2'), + copy.entryActionsAria('Memory #2'), copy.entryActionAria(copy.text.archiveAction, 'Memory #2'), + copy.openBackupAria('Backup #2'), copy.restoreBackupAria('Backup #2'), copy.copyBackupAria('Backup #2'), + copy.draftStatusAria(copy.text.archiveDraftAction), copy.draftStatusAria(copy.text.restoreDraftAction), + copy.restoreLatestDescription('Backup #2'), copy.restoreCandidateDescription('Backup #2'), + ], expected.labels); + }); +} diff --git a/apps/desktop/src/main/browser-message-box.ts b/apps/desktop/src/main/browser-message-box.ts index 7ab4302cd7..0c5519dc96 100644 --- a/apps/desktop/src/main/browser-message-box.ts +++ b/apps/desktop/src/main/browser-message-box.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { UiCatalog } from '@maka/core/ui-locale'; import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -348,7 +349,7 @@ export function buildBrowserMessageBoxHtml( function renderBrowserMessageBoxHtml(input: BrowserMessageBoxPresentation): string { const nonce = randomUUID().replaceAll('-', ''); - const closeLabel = input.locale === 'zh-CN' ? '关闭' : input.locale === 'zh-TW' ? '關閉' : 'Close'; + const closeLabel = CLOSE_LABEL[input.locale]; const closeButton = ``; @@ -638,3 +639,5 @@ function escapeHtml(value: string): string { return entities[character] ?? character; }); } + +const CLOSE_LABEL = { 'zh-CN': '关闭', 'zh-TW': '關閉', en: 'Close' } satisfies UiCatalog; diff --git a/apps/desktop/src/main/capability-snapshot.ts b/apps/desktop/src/main/capability-snapshot.ts index 8c430d9b00..4d54a92a78 100644 --- a/apps/desktop/src/main/capability-snapshot.ts +++ b/apps/desktop/src/main/capability-snapshot.ts @@ -27,6 +27,7 @@ import { type CapabilityFeatureSignal, type CapabilityMemoryAcceptanceSignal, type CapabilityPermissionRequirement, + type CapabilityReasonCode, type CapabilityRuntimeProbeSignal, type CapabilitySnapshot, type CapabilitySnapshotCollection, @@ -80,7 +81,7 @@ export function buildCapabilitySnapshotCollection(input: { feature: { state: 'partial', source: 'runtime', - reason: 'Daily Review 已聚合本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制', + reason: 'activity_recorder_partial', }, requiredPermissions: [ { id: 'screen_recording', required: false, status: permissions.screen_recording.status }, @@ -90,7 +91,7 @@ export function buildCapabilitySnapshotCollection(input: { runtimeProbe: { state: 'not_run', source: 'runtime_probe', - reason: '打开 Daily Review 可查看本地活动聚合结果', + reason: 'activity_recorder_probe_hint', }, }), staticCapability({ @@ -100,7 +101,7 @@ export function buildCapabilitySnapshotCollection(input: { feature: { state: 'partial', source: 'runtime', - reason: '本地 MEMORY.md 已可见;自动抽取/写入仍需用户确认', + reason: 'memory_partial', }, requiredPermissions: [], actionApproval: { state: 'not_required', source: 'not_applicable' }, @@ -108,7 +109,7 @@ export function buildCapabilitySnapshotCollection(input: { runtimeProbe: { state: 'not_run', source: 'runtime_probe', - reason: '透明本地记忆为文件读写能力,不做后台探测', + reason: 'memory_no_probe', }, }), ...BOT_PROVIDERS.map((provider) => @@ -138,7 +139,7 @@ function computerUseCapability( feature: { state: artifactAvailable ? 'enabled' : 'not_available', source: 'runtime', - reason: computerUseCapabilityReason(input, permissions), + reason: input === undefined || input.backendId === 'none' ? 'cu_artifact_missing' : 'cu_backend_status', }, requiredPermissions: [ { id: 'accessibility', required: true, status: permissions.accessibility.status }, @@ -153,47 +154,11 @@ function computerUseCapability( state: input?.health.state ?? 'not_available', source: 'runtime_probe', lastCheckedAt: now, - reason: input?.health.reason ?? 'Computer Use 后端当前不可用。', + reason: input?.health.reason ?? 'cu_backend_unavailable', }, }); } -function computerUseCapabilityReason( - input: { - backendId: CuBackendId | 'none'; - health: ReturnType; - } | undefined, - permissions: PermissionSnapshot['permissions'], -): string { - if (input === undefined || input.backendId === 'none') { - return '未找到通过完整性检查的 Computer Use 执行器 artifact。'; - } - - const reasons = [`${input.backendId} artifact 已通过本地完整性检查。`]; - const missingPermissions = [ - ['辅助功能', permissions.accessibility.status], - ['屏幕录制', permissions.screen_recording.status], - ].filter((entry) => entry[1] !== 'granted').map((entry) => entry[0]); - if (missingPermissions.length > 0) { - reasons.push(`等待${missingPermissions.join('、')}权限。`); - } - switch (input.health.state) { - case 'not_available': - reasons.push(`${input.backendId} service 启动失败、已退出或已停止。`); - break; - case 'degraded': - reasons.push(`${input.backendId} service 正在启动或恢复。`); - break; - case 'healthy': - reasons.push('操作与截图 service 已就绪;按目标与动作类别授权后可操作本机应用。'); - break; - case 'not_run': - reasons.push('service 将在首次调用时启动;按目标与动作类别授权后可操作本机应用。'); - break; - } - return reasons.join(''); -} - function staticCapability(input: { id: CapabilitySnapshot['id']; label: string; @@ -243,7 +208,7 @@ function botCapability( }; const configuration: CapabilityConfigurationSignal = hasConfig ? { state: 'present', source: 'settings' } - : { state: 'missing', source: 'settings', reason: '未配置平台凭据' }; + : { state: 'missing', source: 'settings', reason: 'missing platform credentials' }; const runtimeProbe = runtimeProbeFromBotReadiness( status.readiness, channel.readinessUpdatedAt, @@ -274,7 +239,7 @@ function botCapability( } function accessibilitySnapshot(now: number, platform: NodeJS.Platform): OsPermissionSnapshot { - if (platform !== 'darwin') return unsupportedPermission('accessibility', now, '仅 macOS TCC 权限适用'); + if (platform !== 'darwin') return unsupportedPermission('accessibility', now, 'macOS TCC only'); try { const granted = systemPreferences.isTrustedAccessibilityClient(false); return { @@ -282,12 +247,12 @@ function accessibilitySnapshot(now: number, platform: NodeJS.Platform): OsPermis status: granted ? 'granted' : 'not_determined', source: 'electron', checkedAt: now, - reason: granted ? undefined : 'macOS 不区分辅助功能权限是未授权还是未申请', + reason: granted ? undefined : 'accessibility_status_ambiguous', canOpenSettings: true, canRequest: false, }; - } catch (error) { - return unknownPermission('accessibility', now, generalizedReason(error), true); + } catch { + return unknownPermission('accessibility', now, true); } } @@ -298,11 +263,7 @@ function mediaPermissionSnapshot( platform: NodeJS.Platform, ): OsPermissionSnapshot { if (!supportsMediaPermissionProbe(id, platform)) { - return unsupportedPermission( - id, - now, - '屏幕录制权限状态仅能在 macOS 上读取', - ); + return unsupportedPermission(id, now, 'screen_recording_status_mac_only'); } try { const status = mapMediaAccessStatus(systemPreferences.getMediaAccessStatus(mediaType)); @@ -314,8 +275,8 @@ function mediaPermissionSnapshot( checkedAt: now, ...actions, }; - } catch (error) { - return unknownPermission(id, now, generalizedReason(error), platform === 'darwin'); + } catch { + return unknownPermission(id, now, platform === 'darwin'); } } @@ -328,9 +289,9 @@ function notificationSnapshot(now: number, platform: NodeJS.Platform): OsPermiss checkedAt: now, reason: supported ? platform === 'darwin' - ? 'Electron 无法可靠读取 macOS 通知授权状态,请在系统设置中确认' - : 'Electron 无法可靠读取当前系统的通知授权状态' - : 'Electron 通知能力不可用', + ? 'notifications_status_unreadable_macos' + : 'notifications_status_unreadable' + : 'notifications_unsupported', canOpenSettings: platform === 'darwin', // Showing a Notification is not an authorization API and does not report // whether macOS delivered or suppressed it. Never present that probe as a @@ -340,19 +301,23 @@ function notificationSnapshot(now: number, platform: NodeJS.Platform): OsPermiss } function automationSnapshot(now: number, platform: NodeJS.Platform): OsPermissionSnapshot { - if (platform !== 'darwin') return unsupportedPermission('automation', now, '仅 macOS TCC 权限适用'); + if (platform !== 'darwin') return unsupportedPermission('automation', now, 'macOS TCC only'); return { id: 'automation', status: 'unknown', source: 'static', checkedAt: now, - reason: 'Electron 暂不支持读取逐 App 的 Apple Events 授权状态', + reason: 'no Electron API for per-target Apple Events TCC status', canOpenSettings: true, canRequest: false, }; } -function unsupportedPermission(id: OsPermissionId, now: number, reason: string): OsPermissionSnapshot { +function unsupportedPermission( + id: OsPermissionId, + now: number, + reason: CapabilityReasonCode, +): OsPermissionSnapshot { return { id, status: 'unsupported', @@ -367,7 +332,6 @@ function unsupportedPermission(id: OsPermissionId, now: number, reason: string): function unknownPermission( id: OsPermissionId, now: number, - reason: string, canOpenSettings: boolean, ): OsPermissionSnapshot { return { @@ -375,12 +339,8 @@ function unknownPermission( status: 'unknown', source: 'electron', checkedAt: now, - reason, + reason: 'permission_probe_failed', canOpenSettings, canRequest: false, }; } - -function generalizedReason(error: unknown): string { - return error instanceof Error ? error.message : 'permission probe failed'; -} diff --git a/apps/desktop/src/main/computer-use-host.ts b/apps/desktop/src/main/computer-use-host.ts index 5b5c964b74..aefef19cec 100644 --- a/apps/desktop/src/main/computer-use-host.ts +++ b/apps/desktop/src/main/computer-use-host.ts @@ -34,6 +34,7 @@ import { selectComputerUseBackend, type SelectedComputerUseBackend, } from '@maka/computer-use'; +import type { CapabilityReasonCode } from '@maka/core/capabilities'; import type { CuOverlayHook } from '@maka/runtime/computer-use-types'; export interface ComputerUseHostState { @@ -147,25 +148,22 @@ export function computerUseServiceHealth( state: MakaCuServiceSnapshot | undefined, ): { state: 'not_available' | 'not_run' | 'healthy' | 'degraded'; - reason: string; + reason: CapabilityReasonCode; } { if (backendId === 'none' || !state) { - return { - state: 'not_available', - reason: '未找到通过完整性检查且可分发的 maka-cu executor。', - }; + return { state: 'not_available', reason: 'cu_executor_undistributable' }; } switch (state.state) { case 'disposed': - return { state: 'not_available', reason: 'maka-cu executor 已停止。' }; + return { state: 'not_available', reason: 'cu_executor_stopped' }; case 'unavailable': - return { state: 'not_available', reason: 'maka-cu executor 启动失败或已退出。' }; + return { state: 'not_available', reason: 'cu_executor_start_failed' }; case 'starting': case 'backing_off': - return { state: 'degraded', reason: 'maka-cu executor 正在启动或恢复。' }; + return { state: 'degraded', reason: 'cu_executor_recovering' }; case 'ready': - return { state: 'healthy', reason: 'maka-cu executor 已就绪。' }; + return { state: 'healthy', reason: 'cu_executor_ready' }; default: - return { state: 'not_run', reason: 'maka-cu 已可用,将在首次调用时启动。' }; + return { state: 'not_run', reason: 'cu_executor_lazy_start' }; } } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 565583503f..cad8498460 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -17,7 +17,7 @@ * under the License. */ -import { resolveSystemUiLocale } from '@maka/core/ui-locale'; +import { resolveSystemUiLocale, type UiCatalog } from '@maka/core/ui-locale'; import { DEV_LOSER_EXIT_CODE, developmentLaunchResultFile, @@ -105,27 +105,14 @@ if (!app.requestSingleInstanceLock()) { .whenReady() .then(() => { const locale = resolveSystemUiLocale(app.getPreferredSystemLanguages()); - const isSimplifiedChinese = locale === 'zh-CN'; - const isTraditionalChinese = locale === 'zh-TW'; + const copy = DEV_SINGLETON_COPY[locale]; return showBrowserMessageBox( { type: 'warning', - title: isSimplifiedChinese - ? 'Maka Dev 已在运行' - : isTraditionalChinese - ? 'Maka Dev 已在執行' - : 'Maka Dev is already running', - message: isSimplifiedChinese - ? '另一个 Maka Dev 实例正在使用此开发配置。' - : isTraditionalChinese - ? '另一個 Maka Dev 執行個體正在使用此開發設定。' - : 'Another Maka Dev instance is using this development profile.', - detail: isSimplifiedChinese - ? `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。` - : isTraditionalChinese - ? `開發設定:${profilePath}\n\n請先退出正在執行的執行個體,然後重試。` - : `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, - buttons: [isSimplifiedChinese ? '退出' : isTraditionalChinese ? '退出' : 'Exit'], + title: copy.title, + message: copy.message, + detail: copy.detail(profilePath), + buttons: [copy.exit], defaultId: 0, cancelId: 0, }, @@ -246,3 +233,29 @@ if (!app.requestSingleInstanceLock()) { } }); } + +const DEV_SINGLETON_COPY = { + 'zh-CN': { + title: 'Maka Dev 已在运行', + message: '另一个 Maka Dev 实例正在使用此开发配置。', + detail: (profilePath: string) => `开发配置:${profilePath}\n\n请先退出正在运行的实例,然后重试。`, + exit: '退出', + }, + 'zh-TW': { + title: 'Maka Dev 已在執行', + message: '另一個 Maka Dev 執行個體正在使用此開發設定。', + detail: (profilePath: string) => `開發設定:${profilePath}\n\n請先退出正在執行的執行個體,然後重試。`, + exit: '退出', + }, + en: { + title: 'Maka Dev is already running', + message: 'Another Maka Dev instance is using this development profile.', + detail: (profilePath: string) => `Development profile: ${profilePath}\n\nQuit the running instance, then retry.`, + exit: 'Exit', + }, +} satisfies UiCatalog<{ + title: string; + message: string; + detail(profilePath: string): string; + exit: string; +}>; diff --git a/apps/desktop/src/main/oauth/github-copilot-local-credential.ts b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts index 2a39fed564..21bcc4854a 100644 --- a/apps/desktop/src/main/oauth/github-copilot-local-credential.ts +++ b/apps/desktop/src/main/oauth/github-copilot-local-credential.ts @@ -56,8 +56,8 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: - 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', + code: 'copilot_classic_pat_unsupported', + message: 'GitHub Copilot does not accept classic PATs.', }, }; } @@ -66,7 +66,8 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', + code: 'copilot_credential_type_unsupported', + message: 'Unsupported GitHub credential type.', }, }; } @@ -79,7 +80,8 @@ export async function importGitHubCopilotLocalCredential( result: { ok: false, reason: 'token_exchange_failed', - message: '未找到可导入的 GitHub 凭据;请先使用 gh 登录或配置兼容凭据。', + code: 'copilot_local_credential_missing', + message: 'No importable GitHub credential found.', }, }; } diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index f5c3b7200b..2bf9df9b82 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import { randomUUID } from "node:crypto"; import { open, mkdir, rename, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -36,6 +37,7 @@ import type { createMainWindowController } from "./main-window.js"; import type { DesktopRuntimeHostClient } from "./runtime-host-client.js"; interface RuntimeHostArtifactsIpcDeps { + uiLocale(): UiLocale; readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; @@ -111,7 +113,7 @@ export function registerRuntimeHostArtifactsIpc( const artifact = await deps.client.getArtifact(sessionId, artifactId); if (!artifact) return { ok: false, reason: "not_found" }; const result = await deps.mainWindowController.showSaveDialog({ - title: `另存为 ${artifact.name}`, + title: ARTIFACT_DIALOG_COPY[deps.uiLocale()].saveAs(artifact.name), defaultPath: artifact.name, }); if (result.canceled || !result.filePath) { @@ -228,3 +230,9 @@ async function materializeArtifact( throw error; } } + +const ARTIFACT_DIALOG_COPY = { + 'zh-CN': { saveAs: (name: string) => `另存为 ${name}` }, + 'zh-TW': { saveAs: (name: string) => `另存為 ${name}` }, + en: { saveAs: (name: string) => `Save ${name} as` }, +} satisfies UiCatalog<{ saveAs(name: string): string }>; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index ef0005c406..b1530e81be 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1064,6 +1064,7 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( const chatId = requireScheduledTaskEffectString(input.chatId, "chatId"); const title = requireScheduledTaskEffectString(input.title, "title"); const body = typeof input.body === "string" ? input.body.trim() : ""; + // Bot-channel notices follow the bot audience language; localization tracked under #2672 const text = [`【定时任务】${title}`, ...(body ? ["", body] : [])].join("\n"); const sent = await botRegistry.sendMessage(platform, chatId, text); if (!sent) throw new Error("ScheduledTask bot channel is unavailable"); @@ -1495,6 +1496,7 @@ function registerHostClientIpc( }); registerRuntimeHostRendererIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostArtifactsIpc({ + uiLocale: () => desktopLocale.current(), ipcMain: scopedIpc, client, mainWindowController, @@ -1530,6 +1532,7 @@ function registerHostClientIpc( module: runtimeHostSettings, }); registerRuntimeHostConfigIpc({ + uiLocale: () => desktopLocale.current(), ipcMain: scopedIpc, client, mainWindowController, diff --git a/apps/desktop/src/main/runtime-host-config-ipc-main.ts b/apps/desktop/src/main/runtime-host-config-ipc-main.ts index 073283c34b..a4458df2b7 100644 --- a/apps/desktop/src/main/runtime-host-config-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-config-ipc-main.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import { readFile, writeFile } from 'node:fs/promises'; import { isDeepStrictEqual } from 'node:util'; import type { IpcMain } from 'electron'; @@ -67,6 +68,7 @@ import { } from '@maka/storage/config-transfer'; interface RuntimeHostConfigIpcDeps { + uiLocale(): UiLocale; readonly ipcMain: Pick; readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; @@ -100,7 +102,7 @@ export function registerRuntimeHostConfigIpc( } const today = new Date().toISOString().slice(0, 10); const result = await deps.mainWindowController.showSaveDialog({ - title: '导出 Maka 配置', + title: CONFIG_DIALOG_COPY[deps.uiLocale()].exportTitle, defaultPath: `maka-config-${today}.json`, filters: [{ name: 'Maka Config', extensions: ['json'] }], }); @@ -129,7 +131,7 @@ export function registerRuntimeHostConfigIpc( 'config:import', async (_event, input: { strategy?: unknown } = {}) => { const result = await deps.mainWindowController.showOpenDialog({ - title: '导入 Maka 配置', + title: CONFIG_DIALOG_COPY[deps.uiLocale()].importTitle, properties: ['openFile'], filters: [{ name: 'Maka Config', extensions: ['json'] }], }); @@ -656,3 +658,9 @@ function sanitizeCategories(value: unknown): ConfigCategory[] { function sanitizeStrategy(value: unknown): ConnectionConflictStrategy { return value === 'overwrite' ? 'overwrite' : 'skip'; } + +const CONFIG_DIALOG_COPY = { + 'zh-CN': { exportTitle: '导出 Maka 配置', importTitle: '导入 Maka 配置' }, + 'zh-TW': { exportTitle: '匯出 Maka 設定', importTitle: '匯入 Maka 設定' }, + en: { exportTitle: 'Export Maka configuration', importTitle: 'Import Maka configuration' }, +} satisfies UiCatalog<{ exportTitle: string; importTitle: string }>; diff --git a/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts b/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts index d463637e23..45de4a1174 100644 --- a/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-github-copilot-ipc-main.ts @@ -53,7 +53,7 @@ export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopil deps.ipcMain.handle('github-copilot:connect-existing-login', async () => { const imported = await importExistingLogin(); if (!imported.result.ok) return imported.result; - if (!imported.secret) return storageFailure('GitHub Copilot login produced no credential'); + if (!imported.secret) return storageFailure('copilot_import_no_credential'); try { const before = await deps.client.loadConnectionCatalog(); @@ -69,23 +69,23 @@ export function registerRuntimeHostGitHubCopilotIpc(deps: RuntimeHostGitHubCopil }); if (adopted.kind === 'rejected') { if (adopted.reason === 'superseded') { - return storageFailure('GitHub Copilot 账号在导入期间发生变化,请重试。'); + return storageFailure('copilot_import_superseded'); } return adopted.reason === 'model_unavailable' - ? actionFailure('当前 GitHub 账号没有可用的 Copilot 订阅权限。') - : actionFailure('当前 GitHub 凭据无法导入,请检查凭据后重试。'); + ? actionFailure('copilot_subscription_unavailable') + : actionFailure('copilot_credential_import_rejected'); } if (adopted.kind === 'failed') { return adopted.errorClass === 'auth' - ? actionFailure('当前 GitHub 账号没有可用的 Copilot 订阅权限。') - : actionFailure('暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。'); + ? actionFailure('copilot_subscription_unavailable') + : actionFailure('copilot_subscription_check_failed'); } await selectAccountDefaultIfMissing(deps.client, adopted.connection.connectionId); deps.emitConnectionListChanged(); return { ok: true as const }; } catch { - return storageFailure('GitHub Copilot login could not be committed to Runtime Host'); + return storageFailure('copilot_import_commit_failed'); } }); } @@ -108,10 +108,10 @@ async function selectAccountDefaultIfMissing( } } -function actionFailure(message: string) { - return { ok: false as const, reason: 'token_exchange_failed' as const, message }; +function actionFailure(code: string) { + return { ok: false as const, reason: 'token_exchange_failed' as const, code, message: code }; } -function storageFailure(message: string) { - return { ok: false as const, reason: 'storage_failed' as const, message }; +function storageFailure(code: string) { + return { ok: false as const, reason: 'storage_failed' as const, code, message: code }; } diff --git a/apps/desktop/src/main/runtime-host-memory-ipc-main.ts b/apps/desktop/src/main/runtime-host-memory-ipc-main.ts index 1004f497e3..2474cfa9f7 100644 --- a/apps/desktop/src/main/runtime-host-memory-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-memory-ipc-main.ts @@ -54,6 +54,7 @@ type LocalMemoryMutationResult = readonly ok: false; readonly state: LocalMemoryState; readonly reason: string; + readonly code: string; readonly message: string; }; @@ -99,6 +100,7 @@ export function registerRuntimeHostMemoryIpc( return { ok: false as const, state, + code: "no_backup", message: "No Memory backup is available", }; return restoreBackup(deps, backup.kind); @@ -108,6 +110,7 @@ export function registerRuntimeHostMemoryIpc( return { ok: false as const, state: await getMemoryState(deps), + code: "invalid_backup_kind", message: "Invalid Memory backup kind", }; } @@ -140,12 +143,20 @@ export function registerRuntimeHostMemoryIpc( deps, BACKUP_FILES[state.latestBackup.kind], ) - : { ok: false as const, message: "No Memory backup is available" }; + : { + ok: false as const, + code: "no_backup", + message: "No Memory backup is available", + }; }); deps.ipcMain.handle("memory:openBackup", async (_event, kind: unknown) => { return isBackupKind(kind) ? openMemoryPath(deps, BACKUP_FILES[kind]) - : { ok: false as const, message: "Invalid Memory backup kind" }; + : { + ok: false as const, + code: "invalid_backup_kind", + message: "Invalid Memory backup kind", + }; }); } @@ -479,13 +490,14 @@ async function restoreBackup( kind: MemoryBackupKind, ): Promise< | { ok: true; state: LocalMemoryState } - | { ok: false; state: LocalMemoryState; message: string } + | { ok: false; state: LocalMemoryState; code: string; message: string } > { const state = await deps.client.queryMemory({ kind: "state" }); if (state.kind !== "state") { return { ok: false, state: await getMemoryState(deps), + code: "memory_unavailable", message: "Memory is unavailable", }; } @@ -494,6 +506,7 @@ async function restoreBackup( return { ok: false, state: await getMemoryState(deps), + code: "backup_not_found", message: "Memory backup not found", }; } @@ -505,7 +518,7 @@ async function restoreBackup( })); return result.ok ? { ok: true, state: result.state } - : { ok: false, state: result.state, message: result.message }; + : { ok: false, state: result.state, code: result.code, message: result.message }; } async function openMemoryPath( @@ -514,10 +527,11 @@ async function openMemoryPath( "workspaceRoot" | "allowLocalPaths" | "openPath" >, fileName: string, -): Promise<{ ok: true } | { ok: false; message: string }> { +): Promise<{ ok: true } | { ok: false; code: string; message: string }> { if (deps.allowLocalPaths === false) { return { ok: false, + code: "remote_host_owned", message: "Memory files are owned by the remote Runtime Host", }; } @@ -529,15 +543,20 @@ async function openMemoryPath( if (!isPathInside(directory, path) || !(await lstat(path)).isFile()) { return { ok: false, + code: "not_regular_file", message: "Memory path is not an allowed regular file", }; } const error = await deps.openPath(path); return error - ? { ok: false, message: "The system could not open the Memory file" } + ? { + ok: false, + code: "open_failed", + message: "The system could not open the Memory file", + } : { ok: true }; } catch { - return { ok: false, message: "Memory file not found" }; + return { ok: false, code: "file_not_found", message: "Memory file not found" }; } } @@ -600,6 +619,7 @@ async function mutationFailure( ok: false, state: await getMemoryState(deps), reason, + code: reason, message: memoryReasonMessage(reason), }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 779c6651a6..d951b4b3ae 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1507,13 +1507,13 @@ export interface MakaBridge { getState(sessionId?: string, host?: DesktopRuntimeHostRef): Promise; save(content: string, host?: DesktopRuntimeHostRef): Promise; reset(host?: DesktopRuntimeHostRef): Promise; - restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }>; - restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }>; + restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string; message: string }>; + restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string; message: string }>; setEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise; setAgentReadEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise; - openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; - openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; - openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }>; + openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }>; + openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }>; + openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }>; }; attachments: { pickDirectory(): Promise<{ ok: true; reference: import('@maka/core/events').DirectoryReference } | { ok: false; reason: 'cancelled' }>; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1f7e01f312..98a6cdb61f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2947,10 +2947,10 @@ const makaBridge = { reset(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'memory:reset'); }, - restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }> { + restoreLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:restoreLatestBackup'); }, - restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; message: string }> { + restoreBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true; state: LocalMemoryState } | { ok: false; state: LocalMemoryState; code: string; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:restoreBackup', kind); }, setEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise { @@ -2959,13 +2959,13 @@ const makaBridge = { setAgentReadEnabled(enabled: boolean, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'memory:setAgentReadEnabled', enabled); }, - openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }> { + openFile(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:openFile'); }, - openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }> { + openLatestBackup(host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:openLatestBackup'); }, - openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; message: string }> { + openBackup(kind: 'save' | 'reset' | 'restore', host?: DesktopRuntimeHostRef): Promise<{ ok: true } | { ok: false; code: string; message: string }> { return invokeSelectedRuntimeHost(host, 'memory:openBackup', kind); }, }, diff --git a/apps/desktop/src/renderer/features/connection-settings/index.ts b/apps/desktop/src/renderer/features/connection-settings/index.ts index cf1c5ddb5f..109e3d3824 100644 --- a/apps/desktop/src/renderer/features/connection-settings/index.ts +++ b/apps/desktop/src/renderer/features/connection-settings/index.ts @@ -37,8 +37,7 @@ export { providerPanelActionErrorMessage, } from './provider-panel-shared.js'; export { OnboardingStepForm } from './onboarding-step-form.js'; -export { getProviderSettingsCopy } from './settings-provider-copy.js'; -export { subscriptionResultMessage } from './subscription-result-message.js'; +export { getProviderSettingsCopy, subscriptionActionErrorMessage, subscriptionResultMessage } from './settings-provider-copy.js'; export type { ProviderSettingsCopy } from './settings-provider-copy.js'; export type { CredentialPresenceStatus, diff --git a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts index 39f4017f4d..180c0259c9 100644 --- a/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/features/connection-settings/settings-provider-copy.ts @@ -17,7 +17,8 @@ * under the License. */ -import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; +import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; +import { type UiCatalog, type UiLocale, lookupCopy } from '@maka/core/ui-locale'; type WidenCopy = T extends string ? string @@ -245,6 +246,20 @@ const zhCopy = { loggedOut: '已退出登录', credentialsCleared: '本地凭据已清除。', logoutFailed: '退出失败', logoutFailedRetry: '退出登录失败,请稍后重试。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', logoutTitle: (name: string) => `退出 ${name} 登录?`, + loginConflict: '上一轮浏览器登录仍在进行或已切换,请再点一次登录,或稍后再试。', + browserPresentFailed: '无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。', + resultCodes: { + copilot_classic_pat_unsupported: 'GitHub Copilot 不支持 classic PAT;请使用兼容 OAuth 登录或具有 Copilot Requests 权限的 fine-grained PAT。', + copilot_credential_type_unsupported: '当前 GitHub 凭据类型不受支持;请使用兼容 OAuth 登录或 fine-grained PAT。', + copilot_local_credential_missing: '未找到可导入的 GitHub 凭据;请先使用 gh 登录或配置兼容凭据。', + copilot_import_no_credential: 'GitHub Copilot 登录没有产生可用凭据。', + copilot_import_superseded: 'GitHub Copilot 账号在导入期间发生变化,请重试。', + copilot_subscription_unavailable: '当前 GitHub 账号没有可用的 Copilot 订阅权限。', + copilot_credential_import_rejected: '当前 GitHub 凭据无法导入,请检查凭据后重试。', + copilot_subscription_check_failed: '暂时无法验证 GitHub Copilot 订阅状态,请稍后重试。', + copilot_import_commit_failed: 'GitHub Copilot 登录未能写入 Runtime Host。', + experimental_disabled: '本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。', + }, }, oauthSection: { signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。', @@ -409,6 +424,20 @@ const zhTwCopy = { logoutDescription: '將刪除本機儲存的訂閱憑據,之後需要重新登入才能繼續使用這些 OAuth 模型。', logout: '退出登入', cancel: '取消', loggedOut: '已退出登入', credentialsCleared: '本地憑據已清除。', logoutFailed: '退出失敗', logoutFailedRetry: '退出登入失敗,請稍後重試。', serviceUnavailable: '登入服務暫時不可用,請檢查網路後重試。', + loginConflict: '上一輪瀏覽器登入仍在進行或已切換,請再按一次登入,或稍後再試。', + browserPresentFailed: '無法開啟系統瀏覽器完成登入,請檢查是否封鎖了彈出式視窗後再試。', + resultCodes: { + copilot_classic_pat_unsupported: 'GitHub Copilot 不支援 classic PAT;請使用相容 OAuth 登入或具有 Copilot Requests 權限的 fine-grained PAT。', + copilot_credential_type_unsupported: '目前的 GitHub 憑據類型不受支援;請使用相容 OAuth 登入或 fine-grained PAT。', + copilot_local_credential_missing: '找不到可匯入的 GitHub 憑據;請先使用 gh 登入或設定相容憑據。', + copilot_import_no_credential: 'GitHub Copilot 登入沒有產生可用憑據。', + copilot_import_superseded: 'GitHub Copilot 帳號在匯入期間發生變化,請重試。', + copilot_subscription_unavailable: '目前的 GitHub 帳號沒有可用的 Copilot 訂閱權限。', + copilot_credential_import_rejected: '目前的 GitHub 憑據無法匯入,請檢查憑據後重試。', + copilot_subscription_check_failed: '暫時無法驗證 GitHub Copilot 訂閱狀態,請稍後重試。', + copilot_import_commit_failed: 'GitHub Copilot 登入未能寫入 Runtime Host。', + experimental_disabled: '本機未啟用該帳號登入方式;可改用匯入相容憑據,或由管理員啟用後重試。', + }, logoutTitle: (name: string) => `退出 ${name} 登入?`, }, oauthSection: { @@ -576,6 +605,20 @@ const enCopy: ProviderSettingsCopy = { loggedOut: 'Signed out', credentialsCleared: 'Local credentials cleared.', logoutFailed: 'Sign-out failed', logoutFailedRetry: 'Sign-out failed. Try again later.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', logoutTitle: (name: string) => `Sign out of ${name}?`, + loginConflict: 'A previous browser login is still running or was superseded. Try logging in again shortly.', + browserPresentFailed: 'Could not open the system browser for login. Check popup blockers and try again.', + resultCodes: { + copilot_classic_pat_unsupported: 'GitHub Copilot does not accept classic PATs. Use a compatible OAuth login or a fine-grained PAT with the Copilot Requests permission.', + copilot_credential_type_unsupported: 'This GitHub credential type is not supported. Use a compatible OAuth login or a fine-grained PAT.', + copilot_local_credential_missing: 'No importable GitHub credential was found. Sign in with gh or configure a compatible credential first.', + copilot_import_no_credential: 'The GitHub Copilot login produced no usable credential.', + copilot_import_superseded: 'The GitHub Copilot account changed during import. Try again.', + copilot_subscription_unavailable: 'This GitHub account has no usable Copilot subscription.', + copilot_credential_import_rejected: 'This GitHub credential could not be imported. Check it and try again.', + copilot_subscription_check_failed: 'Could not verify the GitHub Copilot subscription right now. Try again later.', + copilot_import_commit_failed: 'The GitHub Copilot login could not be committed to Runtime Host.', + experimental_disabled: 'This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it.', + }, }, oauthSection: { signedIn: 'Signed in', codexDescription: 'Use a ChatGPT Plus / Pro account to add a connection.', xaiDescription: 'Use a SuperGrok or X Premium account to add a connection.', @@ -606,3 +649,35 @@ const PROVIDER_SETTINGS_COPY = { export function getProviderSettingsCopy(locale: UiLocale): ProviderSettingsCopy { return PROVIDER_SETTINGS_COPY[locale]; } + +export function subscriptionActionErrorMessage(error: unknown, locale: UiLocale): string { + const message = error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : ''; + return subscriptionResultMessage(message, getProviderSettingsCopy(locale).oauthFlow.serviceUnavailable, locale); +} + +export type SubscriptionResultInput = + // `code`/`reason` stay `string` on the wire: a newer host may send a code + // this client does not know yet, and the guard below maps only known codes. + | string + | undefined + | { readonly code?: string; readonly reason?: string; readonly message?: string }; + +export function subscriptionResultMessage(input: SubscriptionResultInput, fallback: string, locale: UiLocale): string { + const { code, reason, message } = typeof input === 'object' && input !== null ? input : { message: input }; + const copy = getProviderSettingsCopy(locale).oauthFlow; + const mapped = lookupCopy(copy.resultCodes, code) ?? lookupCopy(copy.resultCodes, reason); + if (mapped) return mapped; + const raw = redactSecrets(message ?? '').trim(); + if (!raw) return fallback; + // Stable Host messages, matched before the coarse keyword classifier turns + // "authorization" into a generic auth failure that does not tell the user what to do. + if (/enrollment is disabled for this provider/i.test(raw)) return copy.resultCodes.experimental_disabled; + if (/already in progress|superseded by a new attempt/i.test(raw)) return copy.loginConflict; + if (/did not present OAuth|no matching OAuth presentation/i.test(raw)) return copy.browserPresentFailed; + const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); + return classified || fallback; +} diff --git a/apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts b/apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts deleted file mode 100644 index 125e073d68..0000000000 --- a/apps/desktop/src/renderer/features/connection-settings/subscription-result-message.ts +++ /dev/null @@ -1,53 +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 { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; -import type { UiLocale } from '@maka/core/ui-locale'; - -export function subscriptionResultMessage( - message: string | undefined, - fallback: string, - locale: UiLocale = 'zh-CN', - reason?: string, -): string { - const raw = redactSecrets(message ?? '').trim(); - // The Host refuses an enrollment this install has not opted into and says so - // with a typed reason. Read the reason, not the English message: a reworded - // string or an added locale must not silently disable this branch. The - // message match stays only as a fallback for callers without a typed reason. - if (reason === 'experimental_disabled' || /enrollment is disabled for this provider/i.test(raw)) { - if (locale === 'zh-CN') return '本机未启用该账号登录方式;可改用导入兼容凭据,或由管理员启用后重试。'; - if (locale === 'zh-TW') return '本機未啟用該帳號登入方式;可改用匯入相容憑據,或由管理員啟用後重試。'; - return 'This sign-in is not enabled on this install. Import a compatible credential instead, or ask an operator to enable it.'; - } - if (!raw) return fallback; - if (/already in progress|superseded by a new attempt/i.test(raw)) { - if (locale === 'zh-CN') return '上一轮浏览器登录仍在进行或已切换,请再点一次登录,或稍后再试。'; - if (locale === 'zh-TW') return '上一輪瀏覽器登入仍在進行或已切換,請再按一次登入,或稍後再試。'; - return 'A previous browser login is still running or was superseded. Try logging in again shortly.'; - } - if (/did not present OAuth|no matching OAuth presentation/i.test(raw)) { - if (locale === 'zh-CN') return '无法打开系统浏览器完成登录,请检查是否拦截了弹窗后重试。'; - if (locale === 'zh-TW') return '無法開啟系統瀏覽器完成登入,請檢查是否封鎖了彈出式視窗後再試。'; - return 'Could not open the system browser for login. Check popup blockers and try again.'; - } - const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); - if (classified) return classified; - return locale === 'zh-CN' || !/[\u4e00-\u9fff]/.test(raw) ? raw : fallback; -} diff --git a/apps/desktop/src/renderer/locales/permission-center-copy.ts b/apps/desktop/src/renderer/locales/permission-center-copy.ts index 91df593854..92953a7a25 100644 --- a/apps/desktop/src/renderer/locales/permission-center-copy.ts +++ b/apps/desktop/src/renderer/locales/permission-center-copy.ts @@ -20,9 +20,11 @@ import type { StatusSemantic } from '@maka/ui'; import type { CapabilityReadinessState, + CapabilityReasonCode, CapabilitySnapshot, OsPermissionId, OsPermissionState, + RuntimeProbeState, } from '@maka/core/capabilities'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; @@ -94,6 +96,11 @@ export type PermissionCenterCopy = { /** macOS drag-to-grant onboarding (accessibility / screen recording). */ dragGrant: string; dragGranting: string; + reasons: Record, string>; + // Single-backend assumption: CU_BACKEND_IDS is ['maka-cu'], so the backend + // name stays a literal in copy. Revisit when a second backend lands. + cuBackendStatus(missingPermissionLabels: readonly string[], health: RuntimeProbeState): string; + reasonFallback: string; }; const PERMISSION_CENTER_COPY = { @@ -143,6 +150,40 @@ const PERMISSION_CENTER_COPY = { requiredPermissions: '所需系统权限', requiredPermissionsAria: (label) => `${label}所需系统权限列表`, guidance: '处理建议', guidanceAria: (label) => `${label}处理建议列表`, auditSection: '审计记录', noAudit: '暂无审计记录', auditAria: (label) => `${label}审计记录列表`, impact: '影响功能', opening: '打开中…', openSettings: '前往系统设置', requesting: '请求中…', request: '请求授权', dragGrant: '引导授权', dragGranting: '引导中…', + reasons: { + disabled: '该能力当前已关闭。', + 'missing platform credentials': '未配置平台凭据', + 'macOS TCC only': '仅 macOS TCC 权限适用', + 'no Electron API for per-target Apple Events TCC status': 'Electron 暂不支持读取逐 App 的 Apple Events 授权状态', + cu_artifact_missing: '未找到通过完整性检查的 Computer Use 执行器 artifact。', + cu_backend_unavailable: 'Computer Use 后端当前不可用。', + cu_executor_undistributable: '未找到通过完整性检查且可分发的 maka-cu executor。', + cu_executor_stopped: 'maka-cu executor 已停止。', + cu_executor_start_failed: 'maka-cu executor 启动失败或已退出。', + cu_executor_recovering: 'maka-cu executor 正在启动或恢复。', + cu_executor_ready: 'maka-cu executor 已就绪。', + cu_executor_lazy_start: 'maka-cu 已可用,将在首次调用时启动。', + activity_recorder_partial: 'Daily Review 已聚合本地任务 / 工具 / 模型活动;当前不包含屏幕与应用级录制', + activity_recorder_probe_hint: '打开 Daily Review 可查看本地活动聚合结果', + memory_partial: '本地 MEMORY.md 已可见;自动抽取/写入仍需用户确认', + memory_no_probe: '透明本地记忆为文件读写能力,不做后台探测', + accessibility_status_ambiguous: 'macOS 不区分辅助功能权限是未授权还是未申请', + screen_recording_status_mac_only: '屏幕录制权限状态仅能在 macOS 上读取', + notifications_status_unreadable_macos: 'Electron 无法可靠读取 macOS 通知授权状态,请在系统设置中确认', + notifications_status_unreadable: 'Electron 无法可靠读取当前系统的通知授权状态', + notifications_unsupported: 'Electron 通知能力不可用', + permission_probe_failed: '权限探测失败', + }, + cuBackendStatus: (missing, health) => + 'maka-cu artifact 已通过本地完整性检查。' + + (missing.length > 0 ? `等待${missing.join('、')}权限。` : '') + + ({ + not_available: 'maka-cu service 启动失败、已退出或已停止。', + degraded: 'maka-cu service 正在启动或恢复。', + healthy: '操作与截图 service 已就绪;按目标与动作类别授权后可操作本机应用。', + not_run: 'service 将在首次调用时启动;按目标与动作类别授权后可操作本机应用。', + } satisfies Record)[health], + reasonFallback: '状态详情请查看运行日志。', }, 'zh-TW': { readiness: { @@ -190,6 +231,40 @@ const PERMISSION_CENTER_COPY = { requiredPermissions: '所需系統權限', requiredPermissionsAria: (label) => `${label}所需系統權限列表`, guidance: '處理建議', guidanceAria: (label) => `${label}處理建議列表`, auditSection: '審計記錄', noAudit: '暫無審計記錄', auditAria: (label) => `${label}審計記錄列表`, impact: '影響功能', opening: '開啟中…', openSettings: '前往系統設定', requesting: '請求中…', request: '請求授權', dragGrant: '引導授權', dragGranting: '引導中…', + reasons: { + disabled: '此能力目前已關閉。', + 'missing platform credentials': '未設定平台憑據', + 'macOS TCC only': '僅 macOS TCC 權限適用', + 'no Electron API for per-target Apple Events TCC status': 'Electron 暫不支援讀取逐 App 的 Apple Events 授權狀態', + cu_artifact_missing: '找不到通過完整性檢查的 Computer Use 執行器 artifact。', + cu_backend_unavailable: 'Computer Use 後端目前無法使用。', + cu_executor_undistributable: '找不到通過完整性檢查且可分發的 maka-cu executor。', + cu_executor_stopped: 'maka-cu executor 已停止。', + cu_executor_start_failed: 'maka-cu executor 啟動失敗或已退出。', + cu_executor_recovering: 'maka-cu executor 正在啟動或恢復。', + cu_executor_ready: 'maka-cu executor 已就緒。', + cu_executor_lazy_start: 'maka-cu 已可用,將在首次呼叫時啟動。', + activity_recorder_partial: 'Daily Review 已彙整本機任務 / 工具 / 模型活動;目前不包含螢幕與應用程式層級錄製', + activity_recorder_probe_hint: '開啟 Daily Review 可檢視本機活動彙整結果', + memory_partial: '本機 MEMORY.md 已可見;自動擷取/寫入仍需使用者確認', + memory_no_probe: '透明本機記憶為檔案讀寫能力,不做背景探測', + accessibility_status_ambiguous: 'macOS 不區分輔助使用權限是未授權還是未申請', + screen_recording_status_mac_only: '螢幕錄製權限狀態僅能在 macOS 上讀取', + notifications_status_unreadable_macos: 'Electron 無法可靠讀取 macOS 通知授權狀態,請在系統設定中確認', + notifications_status_unreadable: 'Electron 無法可靠讀取目前系統的通知授權狀態', + notifications_unsupported: 'Electron 通知能力無法使用', + permission_probe_failed: '權限探測失敗', + }, + cuBackendStatus: (missing, health) => + 'maka-cu artifact 已通過本機完整性檢查。' + + (missing.length > 0 ? `等待${missing.join('、')}權限。` : '') + + ({ + not_available: 'maka-cu service 啟動失敗、已退出或已停止。', + degraded: 'maka-cu service 正在啟動或恢復。', + healthy: '操作與截圖 service 已就緒;依目標與動作類別授權後可操作本機應用程式。', + not_run: 'service 將在首次呼叫時啟動;依目標與動作類別授權後可操作本機應用程式。', + } satisfies Record)[health], + reasonFallback: '狀態詳情請查看執行日誌。', }, en: { readiness: { @@ -237,6 +312,40 @@ const PERMISSION_CENTER_COPY = { requiredPermissions: 'Required system permissions', requiredPermissionsAria: (label) => `${label} required system permissions`, guidance: 'Suggested actions', guidanceAria: (label) => `${label} suggested actions`, auditSection: 'Audit records', noAudit: 'No audit records', auditAria: (label) => `${label} audit records`, impact: 'Affects', opening: 'Opening…', openSettings: 'Open System Settings', requesting: 'Requesting…', request: 'Request permission', dragGrant: 'Guide me', dragGranting: 'Opening…', + reasons: { + disabled: 'This capability is turned off.', + 'missing platform credentials': 'Platform credentials are not configured', + 'macOS TCC only': 'Only macOS TCC permissions apply', + 'no Electron API for per-target Apple Events TCC status': 'Electron cannot read per-app Apple Events authorization status', + cu_artifact_missing: 'No Computer Use executor artifact passed the integrity check.', + cu_backend_unavailable: 'The Computer Use backend is currently unavailable.', + cu_executor_undistributable: 'No distributable maka-cu executor passed the integrity check.', + cu_executor_stopped: 'The maka-cu executor has stopped.', + cu_executor_start_failed: 'The maka-cu executor failed to start or has exited.', + cu_executor_recovering: 'The maka-cu executor is starting or recovering.', + cu_executor_ready: 'The maka-cu executor is ready.', + cu_executor_lazy_start: 'maka-cu is available and starts on first use.', + activity_recorder_partial: 'Daily Review aggregates local task, tool, and model activity; screen and app-level recording is not included.', + activity_recorder_probe_hint: 'Open Daily Review to see the local activity summary.', + memory_partial: 'The local MEMORY.md is visible; automatic extraction and writes still require confirmation.', + memory_no_probe: 'Transparent local memory is plain file access, so no background probe runs.', + accessibility_status_ambiguous: 'macOS does not distinguish denied from never-requested Accessibility permission', + screen_recording_status_mac_only: 'Screen Recording permission status can only be read on macOS', + notifications_status_unreadable_macos: 'Electron cannot reliably read macOS notification authorization; check System Settings', + notifications_status_unreadable: 'Electron cannot reliably read notification authorization on this system', + notifications_unsupported: 'Electron notifications are unavailable', + permission_probe_failed: 'Permission probe failed', + }, + cuBackendStatus: (missing, health) => + 'The maka-cu artifact passed the local integrity check. ' + + (missing.length > 0 ? `Waiting for ${missing.join(', ')} permission. ` : '') + + ({ + not_available: 'The maka-cu service failed to start, exited, or was stopped.', + degraded: 'The maka-cu service is starting or recovering.', + healthy: 'The action and screenshot service is ready; grant by target and action category to operate local apps.', + not_run: 'The service starts on first use; grant by target and action category to operate local apps.', + } satisfies Record)[health], + reasonFallback: 'See the runtime logs for details.', }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/locales/settings-data-copy.ts b/apps/desktop/src/renderer/locales/settings-data-copy.ts index 9ad0c79a31..b8bce4bc15 100644 --- a/apps/desktop/src/renderer/locales/settings-data-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-data-copy.ts @@ -28,7 +28,8 @@ export type DataSettingsCopy = { }; loadFailed: string; openFailed(label: string): string; pathCopied: string; copyFailed: string; copyFailedDetail: string; historyCleared: string; historyClearedDetail: string; selectCategory: string; exported: string; exportedDetail(items: readonly string[]): string; - exportFailed: string; noCategories: string; tryAgain: string; imported: string; importFailed: string; invalidFile: string; + exportFailed: string; noCategories: string; tryAgain: string; imported: string; importFailed: string; + importFailures: Record<'not_json' | 'malformed' | 'unsupported_version', string>; rows: { workspace: string; workspaceDetail: string; loadValueFailed: string; loading: string; history: string; historyDetail: string; @@ -55,7 +56,8 @@ const SETTINGS_DATA_COPY = { loadFailed: '载入数据目录失败', openFailed: (label) => `无法打开${label}`, pathCopied: '已复制工作区路径', copyFailed: '复制失败', copyFailedDetail: '剪贴板不可用或被系统拒绝。', historyCleared: '已清空输入历史', historyClearedDetail: '已发送的提示词记录已从本机移除。', selectCategory: '请至少选择一个类别', exported: '已导出配置', exportedDetail: (items) => `包含:${items.join('、')}`, exportFailed: '导出失败', noCategories: '未选择任何类别', tryAgain: '请稍后重试', - imported: '已导入配置', importFailed: '导入失败', invalidFile: '文件无效或版本不受支持。', + imported: '已导入配置', importFailed: '导入失败', + importFailures: { not_json: '文件不是有效的 JSON。', malformed: '配置文件结构无效。', unsupported_version: '配置文件版本不受支持。' }, rows: { workspace: '工作区路径', workspaceDetail: '任务、设置、凭据和技能文件都存在这个目录下。', loadValueFailed: '载入失败', loading: '正在加载…', history: '输入历史', historyDetail: '上箭头 / 下箭头调出的已发送提示词记录,保存在本机、重启后仍在。清空后无法恢复。', @@ -82,7 +84,8 @@ const SETTINGS_DATA_COPY = { loadFailed: '載入資料目錄失敗', openFailed: (label) => `無法開啟${label}`, pathCopied: '已複製工作區路徑', copyFailed: '複製失敗', copyFailedDetail: '剪貼簿不可用或被系統拒絕。', historyCleared: '已清空輸入歷史', historyClearedDetail: '已傳送的提示詞記錄已從本機移除。', selectCategory: '請至少選擇一個類別', exported: '已匯出設定', exportedDetail: (items) => `包含:${items.join('、')}`, exportFailed: '匯出失敗', noCategories: '未選擇任何類別', tryAgain: '請稍後重試', - imported: '已匯入設定', importFailed: '匯入失敗', invalidFile: '檔案無效或版本不受支援。', + imported: '已匯入設定', importFailed: '匯入失敗', + importFailures: { not_json: '檔案不是有效的 JSON。', malformed: '設定檔結構無效。', unsupported_version: '設定檔版本不受支援。' }, rows: { workspace: '工作區路徑', workspaceDetail: '任務、設定、憑據和技能檔案都存在這個目錄下。', loadValueFailed: '載入失敗', loading: '正在載入…', history: '輸入歷史', historyDetail: '上箭頭 / 下箭頭調出的已傳送提示詞記錄,儲存在本機、重啟後仍在。清空後無法恢復。', @@ -109,7 +112,8 @@ const SETTINGS_DATA_COPY = { loadFailed: 'Failed to load data directory', openFailed: (label) => `Could not open ${label}`, pathCopied: 'Workspace path copied', copyFailed: 'Copy failed', copyFailedDetail: 'The clipboard is unavailable or access was denied by the system.', historyCleared: 'Input history cleared', historyClearedDetail: 'Sent prompt history was removed from this device.', selectCategory: 'Select at least one category', exported: 'Configuration exported', exportedDetail: (items) => `Included: ${items.join(', ')}`, exportFailed: 'Export failed', noCategories: 'No categories selected', tryAgain: 'Try again later', - imported: 'Configuration imported', importFailed: 'Import failed', invalidFile: 'The file is invalid or its version is unsupported.', + imported: 'Configuration imported', importFailed: 'Import failed', + importFailures: { not_json: 'The file is not valid JSON.', malformed: 'The config bundle is malformed.', unsupported_version: 'The config file version is unsupported.' }, rows: { workspace: 'Workspace path', workspaceDetail: 'Tasks, settings, credentials, and skill files are stored in this directory.', loadValueFailed: 'Failed to load', loading: 'Loading…', history: 'Input history', historyDetail: 'Previously sent prompts recalled with the Up and Down arrows are kept on this machine and persist across restarts. Clearing them cannot be undone.', diff --git a/apps/desktop/src/renderer/locales/settings-memory-copy.ts b/apps/desktop/src/renderer/locales/settings-memory-copy.ts index 39dfd21856..5038cb8a12 100644 --- a/apps/desktop/src/renderer/locales/settings-memory-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-memory-copy.ts @@ -45,9 +45,18 @@ type MemoryTextKey = | 'entryRestoreFailed' | 'promptCopied' | 'promptCopiedDetail' | 'restoreDraftAction' | 'archiveDraftAction' | 'restoreAction' | 'archiveAction'; +export type MemoryResultCode = + | 'no_backup' | 'invalid_backup_kind' | 'memory_unavailable' | 'backup_not_found' + | 'remote_host_owned' | 'not_regular_file' | 'open_failed' | 'file_not_found' + | 'disabled' | 'incognito_active' | 'safe_mode' | 'oversize' + | 'revision_conflict' | 'backup_revision_conflict' | 'invalid_state' + | 'invalid_content' | 'invalid_scope' | 'not_found' | 'not_pending' + | 'upload_not_found' | 'upload_incomplete' | 'upload_conflict'; + export type MemorySettingsCopy = { intlLocale: string; text: Record; + results: Record; origins: Record['origin'], string>; entryStatuses: Record; backupKinds: Record['kind'], string>; @@ -91,43 +100,148 @@ const enText = { } satisfies Record; const SETTINGS_MEMORY_COPY = { - 'zh-CN': makeCopy('zh-CN', zhText, { + 'zh-CN': { + intlLocale: 'zh-CN', + text: zhText, + countActive: (count, draft) => draft ? `草稿 ${count} 条生效` : `${count} 条生效`, + countArchived: (count, draft) => draft ? `草稿 ${count} 条已归档` : `${count} 条已归档`, + saveSummary: (active, archived) => archived > 0 + ? `当前 ${active} 条生效 / ${archived} 条已归档;已保留上一版备份。` + : `当前 ${active} 条生效;已保留上一版备份。`, + backupSummary: (active, archived) => archived > 0 + ? `${active} 条生效 / ${archived} 条已归档` + : `${active} 条生效`, + countEntries: (count) => `${count} 条记忆`, + countMatches: (filtered, total) => `${filtered} / ${total} 条匹配`, + listAria: (title) => `${title}列表`, + entryActionsAria: (title) => `${title} 记忆操作`, + entryActionAria: (action, identity) => `${action}:${identity}`, + openBackupAria: (label) => `打开备份候选 ${label}`, + restoreBackupAria: (label) => `恢复备份候选 ${label}`, + copyBackupAria: (label) => `复制备份候选引用 ${label}`, + draftStatusAria: (action) => `${action},保存前不会写入 MEMORY.md`, + restoreLatestDescription: (label) => `会先备份当前 MEMORY.md,再用最近一次备份覆盖当前文件。将恢复:${label}`, + restoreCandidateDescription: (label) => `会先备份当前 MEMORY.md,再用选中的备份覆盖当前文件。将恢复:${label}`, + redactedDetail: (summary) => `写入前已替换疑似 token、API key 或密码;${summary}`, + openBackupFailed: (kind) => `打开${kind}失败`, + previewTruncated: (limit) => `预览已按 ${limit} 字符上限截断`, + previewUsage: (length, limit) => `预览 ${length} / ${limit} 字符`, + previewLimit: (limit) => `prompt 上限 ${limit} 字符`, + results: { + no_backup: '当前没有可用的 MEMORY.md 备份。', invalid_backup_kind: '备份类型无法识别。', + memory_unavailable: '本地记忆服务当前不可用。', backup_not_found: '找不到对应的备份文件。', + remote_host_owned: '记忆文件由远程 Runtime Host 管理,无法在本机打开。', not_regular_file: '记忆路径不是允许打开的常规文件。', + open_failed: '系统无法打开记忆文件。', file_not_found: '找不到记忆文件。', + disabled: '本地记忆已关闭。', incognito_active: '隐身模式下不可用。', + safe_mode: 'MEMORY.md 内容过大,已进入安全模式。', oversize: 'MEMORY.md 超出安全上限,请先删减旧内容。', + revision_conflict: '记忆内容刚被其他操作修改,请重试。', backup_revision_conflict: '备份内容刚被其他操作修改,请重试。', + invalid_state: 'Runtime Host 返回了无效的记忆状态。', + invalid_content: 'MEMORY.md 内容无效,请检查格式后重试。', invalid_scope: '当前记忆操作的作用域无效。', + not_found: '找不到对应的记忆条目。', not_pending: '对应的记忆条目不在待确认状态。', + upload_not_found: '记忆上传会话不存在或已过期。', upload_incomplete: '记忆内容尚未上传完整。', + upload_conflict: '另一个记忆上传正在进行,请重试。', + }, origins: { manual: '手动记录', imported: '导入记录', extracted: '确认提取', unknown: '手写条目' }, entryStatuses: { draft: '草稿', review_required: '待确认', active: '生效', archived: '已归档', rejected: '已拒绝', unknown: '未识别' }, backupKinds: { reset: '重置前备份', restore: '恢复前备份', save: '保存前备份' }, memoryStatuses: { ok: '本地文件已就绪', disabled: '已关闭', safe_mode: '安全模式', incognito_blocked: '隐身禁用', error: '读取失败' }, promptBlocked: { disabled: '本地记忆已关闭。', incognito: '隐身模式下不会提供本地记忆。', safeMode: 'MEMORY.md 过大,当前不会提供。', agentRead: '模型上下文读取未开启。' }, backupOversize: '备份过大,无法预览条目', previewOversize: '草稿过大,条目预览已暂停;保存前请先删减 MEMORY.md 内容。', previewTruncationMarker: '[本地记忆已按长度截断]', - }), - 'zh-TW': makeCopy('zh-TW', zhTwText, { + }, + 'zh-TW': { + intlLocale: 'zh-TW', + text: zhTwText, + countActive: (count, draft) => draft ? `草稿 ${count} 則生效` : `${count} 則生效`, + countArchived: (count, draft) => draft ? `草稿 ${count} 則已歸檔` : `${count} 則已歸檔`, + saveSummary: (active, archived) => archived > 0 + ? `目前 ${active} 則生效 / ${archived} 則已歸檔;已保留上一版備份。` + : `目前 ${active} 則生效;已保留上一版備份。`, + backupSummary: (active, archived) => archived > 0 + ? `${active} 則生效 / ${archived} 則已歸檔` + : `${active} 則生效`, + countEntries: (count) => `${count} 則記憶`, + countMatches: (filtered, total) => `${filtered} / ${total} 則符合`, + listAria: (title) => `${title}清單`, + entryActionsAria: (title) => `${title} 記憶操作`, + entryActionAria: (action, identity) => `${action}:${identity}`, + openBackupAria: (label) => `開啟備份候選 ${label}`, + restoreBackupAria: (label) => `還原備份候選 ${label}`, + copyBackupAria: (label) => `複製備份候選引用 ${label}`, + draftStatusAria: (action) => `${action},儲存前不會寫入 MEMORY.md`, + restoreLatestDescription: (label) => `會先備份目前的 MEMORY.md,再以最近一次備份覆蓋目前檔案。將還原:${label}`, + restoreCandidateDescription: (label) => `會先備份目前的 MEMORY.md,再以選取的備份覆蓋目前檔案。將還原:${label}`, + redactedDetail: (summary) => `寫入前已遮蔽疑似 token、API key 或密碼;${summary}`, + openBackupFailed: (kind) => `開啟${kind}失敗`, + previewTruncated: (limit) => `預覽已依 ${limit} 字元上限截斷`, + previewUsage: (length, limit) => `預覽 ${length} / ${limit} 字元`, + previewLimit: (limit) => `prompt 上限 ${limit} 字元`, + results: { + no_backup: '目前沒有可用的 MEMORY.md 備份。', invalid_backup_kind: '備份類型無法識別。', + memory_unavailable: '本機記憶服務目前無法使用。', backup_not_found: '找不到對應的備份檔案。', + remote_host_owned: '記憶檔案由遠端 Runtime Host 管理,無法在本機開啟。', not_regular_file: '記憶路徑不是允許開啟的一般檔案。', + open_failed: '系統無法開啟記憶檔案。', file_not_found: '找不到記憶檔案。', + disabled: '本機記憶已關閉。', incognito_active: '隱身模式下無法使用。', + safe_mode: 'MEMORY.md 內容過大,已進入安全模式。', oversize: 'MEMORY.md 超出安全上限,請先刪減舊內容。', + revision_conflict: '記憶內容剛被其他操作修改,請重試。', backup_revision_conflict: '備份內容剛被其他操作修改,請重試。', + invalid_state: 'Runtime Host 回傳了無效的記憶狀態。', + invalid_content: 'MEMORY.md 內容無效,請檢查格式後重試。', invalid_scope: '目前記憶操作的作用域無效。', + not_found: '找不到對應的記憶條目。', not_pending: '對應的記憶條目不在待確認狀態。', + upload_not_found: '記憶上傳工作階段不存在或已過期。', upload_incomplete: '記憶內容尚未上傳完整。', + upload_conflict: '另一個記憶上傳正在進行,請重試。', + }, origins: { manual: '手動記錄', imported: '匯入記錄', extracted: '確認提取', unknown: '手寫條目' }, entryStatuses: { draft: '草稿', review_required: '待確認', active: '生效', archived: '已歸檔', rejected: '已拒絕', unknown: '未識別' }, backupKinds: { reset: '重置前備份', restore: '恢復前備份', save: '儲存前備份' }, memoryStatuses: { ok: '本地檔案已就緒', disabled: '已關閉', safe_mode: '安全模式', incognito_blocked: '隱身停用', error: '讀取失敗' }, promptBlocked: { disabled: '本地記憶已關閉。', incognito: '隱身模式下不會提供本地記憶。', safeMode: 'MEMORY.md 過大,目前不會提供。', agentRead: '模型上下文讀取未開啟。' }, backupOversize: '備份過大,無法預覽條目', previewOversize: '草稿過大,條目預覽已暫停;儲存前請先刪減 MEMORY.md 內容。', previewTruncationMarker: '[本地記憶已按長度截斷]', - }), - en: makeCopy('en-US', enText, { + }, + en: { + intlLocale: 'en-US', + text: enText, + countActive: (count, draft) => draft + ? `Draft · ${count} active ${count === 1 ? 'entry' : 'entries'}` + : `${count} active ${count === 1 ? 'entry' : 'entries'}`, + countArchived: (count, draft) => draft + ? `Draft · ${count} archived ${count === 1 ? 'entry' : 'entries'}` + : `${count} archived ${count === 1 ? 'entry' : 'entries'}`, + saveSummary: (active, archived) => archived > 0 + ? `${active} active ${active === 1 ? 'entry' : 'entries'} / ${archived} archived ${archived === 1 ? 'entry' : 'entries'}; the previous version was backed up.` + : `${active} active ${active === 1 ? 'entry' : 'entries'}; the previous version was backed up.`, + backupSummary: (active, archived) => archived > 0 + ? `${active} active ${active === 1 ? 'entry' : 'entries'} / ${archived} archived ${archived === 1 ? 'entry' : 'entries'}` + : `${active} active ${active === 1 ? 'entry' : 'entries'}`, + countEntries: (count) => count === 1 ? `${count} memory` : `${count} memories`, + countMatches: (filtered, total) => `${filtered} / ${total} matching`, + listAria: (title) => `${title} list`, + entryActionsAria: (title) => `${title} memory actions`, + entryActionAria: (action, identity) => `${action}: ${identity}`, + openBackupAria: (label) => `Open backup candidate ${label}`, + restoreBackupAria: (label) => `Restore backup candidate ${label}`, + copyBackupAria: (label) => `Copy backup candidate reference ${label}`, + draftStatusAria: (action) => `${action}; MEMORY.md is not written until you save`, + restoreLatestDescription: (label) => `The current MEMORY.md will be backed up before the latest backup replaces it. Restore: ${label}`, + restoreCandidateDescription: (label) => `The current MEMORY.md will be backed up before the selected backup replaces it. Restore: ${label}`, + redactedDetail: (summary) => `Suspected tokens, API keys, or passwords were redacted before writing; ${summary}`, + openBackupFailed: (kind) => `Failed to open ${kind}`, + previewTruncated: (limit) => `Preview truncated at the ${limit}-character limit`, + previewUsage: (length, limit) => `Preview ${length} / ${limit} characters`, + previewLimit: (limit) => `Prompt limit: ${limit} characters`, + results: { + no_backup: 'No MEMORY.md backup is available.', invalid_backup_kind: 'Unrecognized backup kind.', + memory_unavailable: 'Local memory is currently unavailable.', backup_not_found: 'The backup file was not found.', + remote_host_owned: 'Memory files are owned by the remote Runtime Host and cannot be opened locally.', not_regular_file: 'The memory path is not an allowed regular file.', + open_failed: 'The system could not open the memory file.', file_not_found: 'The memory file was not found.', + disabled: 'Local memory is disabled.', incognito_active: 'Unavailable in incognito mode.', + safe_mode: 'MEMORY.md is too large and entered safe mode.', oversize: 'MEMORY.md exceeds the safety limit. Remove older content first.', + revision_conflict: 'Memory was just changed by another operation. Try again.', backup_revision_conflict: 'The backup was just changed by another operation. Try again.', + invalid_state: 'The Runtime Host returned an invalid memory state.', + invalid_content: 'MEMORY.md content is invalid. Check its format and try again.', invalid_scope: 'The memory operation has an invalid scope.', + not_found: 'The memory entry was not found.', not_pending: 'The memory entry is not pending review.', + upload_not_found: 'The memory upload session does not exist or has expired.', upload_incomplete: 'The memory content has not finished uploading.', + upload_conflict: 'Another memory upload is in progress. Try again.', + }, origins: { manual: 'Manual entry', imported: 'Imported entry', extracted: 'Confirmed extraction', unknown: 'Handwritten entry' }, entryStatuses: { draft: 'Draft', review_required: 'Needs review', active: 'Active', archived: 'Archived', rejected: 'Rejected', unknown: 'Unrecognized' }, backupKinds: { reset: 'Before reset', restore: 'Before restore', save: 'Before save' }, memoryStatuses: { ok: 'Local file ready', disabled: 'Off', safe_mode: 'Safe mode', incognito_blocked: 'Disabled in incognito', error: 'Read failed' }, promptBlocked: { disabled: 'Local memory is disabled.', incognito: 'Local memory is never added in incognito mode.', safeMode: 'MEMORY.md is too large and will not be added.', agentRead: 'Model context access is disabled.' }, backupOversize: 'Backup is too large to preview entries', previewOversize: 'The draft is too large, so entry preview is paused. Reduce MEMORY.md before saving.', previewTruncationMarker: '[Local memory truncated to the length limit]', - }), + }, } satisfies UiCatalog; export function getMemorySettingsCopy(locale: UiLocale): MemorySettingsCopy { return SETTINGS_MEMORY_COPY[locale]; } -function makeCopy(intlLocale: string, text: Record, values: Pick): MemorySettingsCopy { - const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; - const isZh = intlLocale !== 'en-US'; - const isZhTw = intlLocale === 'zh-TW'; - const zh = (simplified: string, traditional: string) => isZhTw ? traditional : simplified; - return { - intlLocale, text, ...values, - countActive: (count, draft) => isZh ? `${draft ? '草稿 ' : ''}${count} ${zh('条生效', '則生效')}` : `${draft ? 'Draft · ' : ''}${plural(count, 'active entry', 'active entries')}`, - countArchived: (count, draft) => isZh ? `${draft ? '草稿 ' : ''}${count} ${zh('条已归档', '則已歸檔')}` : `${draft ? 'Draft · ' : ''}${plural(count, 'archived entry', 'archived entries')}`, - saveSummary: (active, archived) => isZh ? `${zh('当前', '目前')} ${active} ${zh('条生效', '則生效')}${archived > 0 ? ` / ${archived} ${zh('条已归档', '則已歸檔')}` : ''};${zh('已保留上一版备份。', '已保留上一版備份。')}` : `${plural(active, 'active entry', 'active entries')}${archived > 0 ? ` / ${plural(archived, 'archived entry', 'archived entries')}` : ''}; the previous version was backed up.`, - backupSummary: (active, archived) => isZh ? `${active} ${zh('条生效', '則生效')}${archived > 0 ? ` / ${archived} ${zh('条已归档', '則已歸檔')}` : ''}` : `${plural(active, 'active entry', 'active entries')}${archived > 0 ? ` / ${plural(archived, 'archived entry', 'archived entries')}` : ''}`, - countEntries: (count) => isZh ? `${count} ${zh('条记忆', '則記憶')}` : plural(count, 'memory', 'memories'), - countMatches: (filtered, total) => isZh ? `${filtered} / ${total} ${zh('条匹配', '則符合')}` : `${filtered} / ${total} matching`, - listAria: (title) => isZh ? `${title}${zh('列表', '清單')}` : `${title} list`, - entryActionsAria: (title) => isZh ? `${title} ${zh('记忆操作', '記憶操作')}` : `${title} memory actions`, - entryActionAria: (action, identity) => isZh ? `${action}:${identity}` : `${action}: ${identity}`, - openBackupAria: (label) => isZh ? `${zh('打开备份候选', '開啟備份候選')} ${label}` : `Open backup candidate ${label}`, restoreBackupAria: (label) => isZh ? `${zh('恢复备份候选', '還原備份候選')} ${label}` : `Restore backup candidate ${label}`, copyBackupAria: (label) => isZh ? `${zh('复制备份候选引用', '複製備份候選引用')} ${label}` : `Copy backup candidate reference ${label}`, - draftStatusAria: (action) => isZh ? `${action},${zh('保存前不会写入', '儲存前不會寫入')} MEMORY.md` : `${action}; MEMORY.md is not written until you save`, - restoreLatestDescription: (label) => isZh ? `${zh('会先备份当前 MEMORY.md,再用最近一次备份覆盖当前文件。将恢复', '會先備份目前的 MEMORY.md,再以最近一次備份覆蓋目前檔案。將還原')}:${label}` : `The current MEMORY.md will be backed up before the latest backup replaces it. Restore: ${label}`, - restoreCandidateDescription: (label) => isZh ? `${zh('会先备份当前 MEMORY.md,再用选中的备份覆盖当前文件。将恢复', '會先備份目前的 MEMORY.md,再以選取的備份覆蓋目前檔案。將還原')}:${label}` : `The current MEMORY.md will be backed up before the selected backup replaces it. Restore: ${label}`, - redactedDetail: (summary) => isZh ? `${zh('写入前已替换疑似', '寫入前已遮蔽疑似')} token、API key ${zh('或密码', '或密碼')};${summary}` : `Suspected tokens, API keys, or passwords were redacted before writing; ${summary}`, - openBackupFailed: (kind) => isZh ? `${zh('打开', '開啟')}${kind}${zh('失败', '失敗')}` : `Failed to open ${kind}`, - previewTruncated: (limit) => isZh ? `${zh('预览已按', '預覽已依')} ${limit} ${zh('字符上限截断', '字元上限截斷')}` : `Preview truncated at the ${limit}-character limit`, - previewUsage: (length, limit) => isZh ? `${zh('预览', '預覽')} ${length} / ${limit} ${zh('字符', '字元')}` : `Preview ${length} / ${limit} characters`, - previewLimit: (limit) => isZh ? `prompt ${zh('上限', '上限')} ${limit} ${zh('字符', '字元')}` : `Prompt limit: ${limit} characters`, - }; +export function memoryResultMessage( + result: { code?: string }, + copy: MemorySettingsCopy, + fallback: string, +): string { + const results: Readonly> = copy.results; + return (result.code && results[result.code]) || fallback; } diff --git a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts index ee5ce9b90d..5e0f4f005e 100644 --- a/apps/desktop/src/renderer/locales/settings-test-result-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-test-result-copy.ts @@ -163,9 +163,7 @@ export function settingsTestResultMessage( case "bot_connection_failed": return copy.bot.connectionFailed; default: - return locale === "en" && result.message.trim() - ? result.message - : copy.bot.connectionFailed; + return copy.bot.connectionFailed; } } diff --git a/apps/desktop/src/renderer/settings/data-settings-page.tsx b/apps/desktop/src/renderer/settings/data-settings-page.tsx index 7ab341d52d..ecf86f43e9 100644 --- a/apps/desktop/src/renderer/settings/data-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/data-settings-page.tsx @@ -220,10 +220,7 @@ export function DataSettingsPage(props: { if (res.ok) { toast.success(copy.imported, summarizeImportResult(res.result, copy)); } else if (res.reason !== 'canceled') { - const detail = res.message && (locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(res.message)) - ? res.message - : copy.invalidFile; - toast.error(copy.importFailed, detail, undefined, diagnosticTarget); + toast.error(copy.importFailed, copy.importFailures[res.reason], undefined, diagnosticTarget); } } catch (error) { toast.error( diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index ec4c6517d7..e0cdf1bf2a 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -36,7 +36,11 @@ import type { PermissionSnapshot, } from '@maka/core/capabilities'; import type { UiLocale } from '@maka/core/ui-locale'; -import { isDragGrantPermissionId, OS_PERMISSION_IDS } from '@maka/core/capabilities'; +import { + isCapabilityReasonCode, + isDragGrantPermissionId, + OS_PERMISSION_IDS, +} from '@maka/core/capabilities'; import { Banner, Button, @@ -421,10 +425,10 @@ function CapabilityRow(props: { const { copy, locale } = props; const readinessCopy = copy.readiness[capability.readiness]; const capabilityLabel = localizedCapabilityLabel(capability, locale); - const featureReason = localizedSnapshotText(capability.feature.reason, locale); - const configurationReason = localizedSnapshotText(capability.configuration.reason, locale); - const runtimeReason = localizedSnapshotText(capability.runtimeProbe.reason, locale); - const guidance = localizedCapabilityGuidance(capability, locale, copy); + const featureReason = capabilityReasonText(capability.feature.reason, capability, copy); + const configurationReason = capabilityReasonText(capability.configuration.reason, capability, copy); + const runtimeReason = capabilityReasonText(capability.runtimeProbe.reason, capability, copy); + const guidance = localizedCapabilityGuidance(capability, copy); const layers: Array<{ label: string; value: string; reason?: string }> = [ { @@ -589,7 +593,7 @@ function OsPermissionRow(props: { const purpose = permissionCopy?.purpose ?? ''; const impact = permissionCopy?.impact ?? ''; const stateCopy = props.copy.osStates[snapshot.status]; - const reason = localizedSnapshotText(snapshot.reason, props.locale); + const reason = osPermissionReasonText(snapshot, props.copy); const showRequest = snapshot.canRequest && snapshot.status !== 'granted'; const showOpenSettings = snapshot.canOpenSettings && snapshot.status !== 'granted'; @@ -704,17 +708,45 @@ function localizedCapabilityLabel(capability: CapabilitySnapshot, locale: UiLoca return capability.label; } -function localizedSnapshotText(value: string | undefined, locale: UiLocale): string | undefined { - if (!value || (locale !== 'zh-CN' && /[\u3400-\u9fff]/u.test(value))) return undefined; - return value; +function capabilityReasonText( + reason: string | undefined, + capability: CapabilitySnapshot, + copy: PermissionCenterCopy, +): string | undefined { + if (!reason) return undefined; + if (reason === 'cu_backend_status') { + const missing = capability.osPermissions + .filter((permission) => permission.required && permission.status !== 'granted') + .map((permission) => copy.osPermissions[permission.id]?.label ?? permission.id); + return copy.cuBackendStatus(missing, capability.runtimeProbe.state); + } + if (isCapabilityReasonCode(reason) && reason !== 'cu_backend_status') { + return copy.reasons[reason]; + } + // Bot capabilities pass bridge status reasons through as machine codes; the + // bot settings page owns their full copy, this summary shows the generic line. + return copy.reasonFallback; } function localizedCapabilityGuidance( capability: CapabilitySnapshot, - locale: UiLocale, copy: PermissionCenterCopy, ): readonly string[] { - return capability.guidance.filter((item) => locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(item)); + return capability.guidance + .map((code) => capabilityReasonText(code, capability, copy)) + .filter((item): item is string => Boolean(item)); +} + +function osPermissionReasonText( + snapshot: OsPermissionSnapshot, + copy: PermissionCenterCopy, +): string | undefined { + const text = snapshot.reason + ? isCapabilityReasonCode(snapshot.reason) && snapshot.reason !== 'cu_backend_status' + ? copy.reasons[snapshot.reason] + : copy.reasonFallback + : undefined; + return text; } function featureTone(state: CapabilitySnapshot['feature']['state']): StatusSemantic { diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx index 3f144ded6b..4bc1ac26a7 100644 --- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx +++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx @@ -29,6 +29,7 @@ import { } from '@maka/ui'; import { getProviderSettingsCopy, + subscriptionActionErrorMessage, subscriptionResultMessage, type ConnectionOAuthProviderBridge, type ConnectionsBridge, @@ -36,7 +37,6 @@ import { } from '../features/connection-settings'; import { useOAuthLoginFlow, - subscriptionActionErrorMessage, type OAuthAuthorizationFlowBridge, type OAuthConnectionIdentity, type SubscriptionSnapshot, @@ -256,7 +256,7 @@ function GitHubCopilotLoginPanel(props: { if (!result.ok) { flow.reportError( copy.copilotActionFailed, - subscriptionResultMessage(result.message, copy.copilotActionFailed, locale, result.reason), + subscriptionResultMessage(result, copy.copilotActionFailed, locale), ); return; } diff --git a/apps/desktop/src/renderer/settings/settings-error-copy.ts b/apps/desktop/src/renderer/settings/settings-error-copy.ts index 37fa24949e..c6137b3be7 100644 --- a/apps/desktop/src/renderer/settings/settings-error-copy.ts +++ b/apps/desktop/src/renderer/settings/settings-error-copy.ts @@ -30,7 +30,6 @@ export function settingsActionErrorMessage(error: unknown, locale: UiLocale): st : ''; const classified = generalizedErrorMessageForLocale(new Error(raw), '', locale); if (classified) return classified; - const redacted = redactSecrets(raw).trim(); - if (locale === 'zh-CN' && redacted && /[\u4E00-\u9FFF]/.test(redacted)) return redacted; + if (raw) console.error('[settings] operation failed:', redactSecrets(raw)); return getSettingsSharedCopy(locale).unknownError; } diff --git a/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts b/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts index 2bfa9a4732..cb08daac78 100644 --- a/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts +++ b/apps/desktop/src/renderer/settings/use-memory-settings-controller.ts @@ -40,7 +40,7 @@ import { } from './memory-settings-labels'; import { deriveMemorySettingsViewModel } from './memory-settings-view-model'; import { useKeyedActionGuard } from './use-action-guard'; -import { getMemorySettingsCopy } from '../locales/settings-memory-copy'; +import { getMemorySettingsCopy, memoryResultMessage } from '../locales/settings-memory-copy'; import { readScrollMotionBehavior } from '../scroll-motion-policy'; import { useRuntimeHostSettingsErrorReporter, @@ -287,7 +287,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps } else { reportHostError( copy.text.restoreFailed, - memoryResultMessage(result.message, locale, copy.text.restoreFailed), + memoryResultMessage(result, copy, copy.text.restoreFailed), ); } }); @@ -324,7 +324,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps } else { reportHostError( copy.text.restoreFailed, - memoryResultMessage(result.message, locale, copy.text.restoreFailed), + memoryResultMessage(result, copy, copy.text.restoreFailed), ); } }); @@ -345,7 +345,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps if (!result.ok) { reportHostError( copy.text.openFailed, - memoryResultMessage(result.message, locale, copy.text.openFailed), + memoryResultMessage(result, copy, copy.text.openFailed), ); } } catch (error) { @@ -364,7 +364,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps if (!result.ok) { reportHostError( copy.text.openPreviousFailed, - memoryResultMessage(result.message, locale, copy.text.openPreviousFailed), + memoryResultMessage(result, copy, copy.text.openPreviousFailed), ); } } catch (error) { @@ -386,7 +386,7 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps if (!result.ok) { reportHostError( copy.openBackupFailed(localMemoryBackupKindLabel(backup.kind, copy)), - memoryResultMessage(result.message, locale, copy.text.openFailed), + memoryResultMessage(result, copy, copy.text.openFailed), ); } } catch (error) { @@ -687,7 +687,3 @@ export function useMemoryDocumentController(props: MemoryDocumentControllerProps copyLocalMemoryPromptPreview, }; } - -function memoryResultMessage(message: string, locale: UiLocale, fallback: string): string { - return locale === 'zh-CN' || !/[\u3400-\u9fff]/u.test(message) ? message : fallback; -} diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts index f237429d4d..d399135b6f 100644 --- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts +++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts @@ -25,7 +25,7 @@ import { useUiLocale, } from '@maka/ui'; import { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-login-flow-guard'; -import { getProviderSettingsCopy, subscriptionResultMessage } from '../features/connection-settings'; +import { getProviderSettingsCopy, subscriptionActionErrorMessage, subscriptionResultMessage } from '../features/connection-settings'; import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js'; // Shared browser-assisted OAuth login-flow controller (device-code polling). @@ -210,7 +210,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC const payload = await authorizationBridge.getAuthUrl(); if ('ok' in payload) { if (!oauthLoginFlowMountedRef.current) return; - const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload.message, copy.startFailedRetry, locale, payload.reason); + const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload, copy.startFailedRetry, locale); reportHostError(copy.startFailed, failureMessage); setErrorMessage(failureMessage); return; @@ -226,7 +226,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC const opened = await authorizationBridge.openAuthUrl(payload.authRequestId); if (!oauthLoginFlowMountedRef.current) return; if (!opened.ok) { - const message = subscriptionResultMessage(opened.message, copy.openFailedRetry, locale, opened.reason); + const message = subscriptionResultMessage(opened, copy.openFailedRetry, locale); reportHostError(copy.openFailed, message); setErrorMessage(message); void authorizationBridge.cancelAuthorization(payload.authRequestId); @@ -249,7 +249,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC if (!oauthLoginFlowMountedRef.current) return; if (params.onLoginSuccess) await params.onLoginSuccess(result.connection); } else { - const message = subscriptionResultMessage(result.message, copy.incompleteRetry, locale, result.reason); + const message = subscriptionResultMessage(result, copy.incompleteRetry, locale); reportHostError(copy.incomplete, message); setErrorMessage(message); } @@ -293,7 +293,7 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC } else { reportHostError( copy.logoutFailed, - subscriptionResultMessage(result.message, copy.logoutFailedRetry, locale), + subscriptionResultMessage(result, copy.logoutFailedRetry, locale), ); } } catch (error) { @@ -327,12 +327,3 @@ export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowC refresh, }; } - -export function subscriptionActionErrorMessage(error: unknown, locale: UiLocale = 'zh-CN'): string { - const message = error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : ''; - return subscriptionResultMessage(message, getProviderSettingsCopy(locale).oauthFlow.serviceUnavailable, locale); -} diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index 0b1bd9efb4..63b525f258 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -70,6 +70,7 @@ import type { ConnectionsBridge } from '../../src/renderer/settings/providers-pa import type { ProjectRecord } from '@maka/core/project'; import type { ArchivedTasksBridge } from '../../src/renderer/settings/tasks-settings-page'; import type { + DesktopLocalRuntimeHostRemoteAccessSnapshot, DesktopRuntimeHostProfileChangedEvent, DesktopRuntimeHostProfileSnapshot, DesktopSessionSummary, @@ -748,6 +749,9 @@ const makaBridge = { setDefault: async () => runtimeHostProfiles, subscribeChanges: () => () => undefined, }, + localRuntimeHostRemoteAccess: { + getSnapshot: async (): Promise => ({ state: 'off' }), + }, // Projects always mounts the Runtime Host management dialog shell, even // before a remote profile is selected. Keep the shared Settings fixture in // sync with the full preload surface so mount-time subscriptions stay real. diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index bdc33a1fd1..fbd355e39d 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -103,6 +103,47 @@ export type CapabilityId = | 'memory_write' | `bot:${BotProvider}`; +/** + * Stable machine codes for capability and OS-permission reasons. Producers + * emit these instead of locale-bound prose; presenters own the code→copy map + * per locale. Bot capabilities pass their bridge status reasons through as-is + * (`rate-limited`, `gateway-closed-4004`, …), so signal `reason` fields stay + * `string` — this union types the desktop producers and the presenter maps. + */ +export const CAPABILITY_REASON_CODES = [ + 'disabled', + 'missing platform credentials', + 'macOS TCC only', + 'no Electron API for per-target Apple Events TCC status', + 'cu_artifact_missing', + 'cu_backend_status', + 'cu_backend_unavailable', + 'cu_executor_undistributable', + 'cu_executor_stopped', + 'cu_executor_start_failed', + 'cu_executor_recovering', + 'cu_executor_ready', + 'cu_executor_lazy_start', + 'activity_recorder_partial', + 'activity_recorder_probe_hint', + 'memory_partial', + 'memory_no_probe', + 'accessibility_status_ambiguous', + 'screen_recording_status_mac_only', + 'notifications_status_unreadable_macos', + 'notifications_status_unreadable', + 'notifications_unsupported', + 'permission_probe_failed', +] as const; + +export type CapabilityReasonCode = (typeof CAPABILITY_REASON_CODES)[number]; + +export function isCapabilityReasonCode(value: unknown): value is CapabilityReasonCode { + return ( + typeof value === 'string' && (CAPABILITY_REASON_CODES as readonly string[]).includes(value) + ); +} + export interface OsPermissionSnapshot { id: OsPermissionId; status: OsPermissionState; diff --git a/packages/core/src/oauth-subscription.ts b/packages/core/src/oauth-subscription.ts index c8f271b92d..9459597cce 100644 --- a/packages/core/src/oauth-subscription.ts +++ b/packages/core/src/oauth-subscription.ts @@ -38,7 +38,13 @@ */ export type SubscriptionActionResult = | { ok: true } - | { ok: false; reason: SubscriptionActionFailureReason; message: string }; + | { + ok: false; + reason: SubscriptionActionFailureReason; + /** Stable machine code the presenter maps to per-locale copy. */ + code?: string; + message: string; + }; export type SubscriptionActionFailureReason = | 'authorization_pending' // no startAuthorization called yet diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 3c95c80fe4..875bea65cc 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -508,8 +508,6 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise { - if (message.type() === 'error') browserFailures.push(`console.error: ${message.text()}`); + if (message.type() === 'error' && !isExpectedConsoleError(job.storyId, message.text())) { + browserFailures.push(`console.error: ${message.text()}`); + } }; const onPageError = (error) => { browserFailures.push(`uncaught page error: ${describeBrowserValue(error)}`); diff --git a/scripts/storybook-visual-smoke.test.mjs b/scripts/storybook-visual-smoke.test.mjs index eca595657a..783ca373ec 100644 --- a/scripts/storybook-visual-smoke.test.mjs +++ b/scripts/storybook-visual-smoke.test.mjs @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { catalogJobs, storyUrl } from './storybook-visual-smoke.mjs'; +import { catalogJobs, isExpectedConsoleError, storyUrl } from './storybook-visual-smoke.mjs'; const REFERENCE_STORY_ID = 'product-shell-official-appshell--native-conversation'; const THEME_PALETTES = [ @@ -135,3 +135,34 @@ test('story URLs encode the selected colour scheme and palette', () => { assert.equal(url.searchParams.get('viewMode'), 'story'); assert.equal(url.searchParams.get('globals'), 'colorScheme:dark;palette:tokyo-night'); }); + +const errorStory = 'product-settings-pages--general-host-settings-error'; +const expectedError = + '[settings] operation failed: Runtime Host settings read failed in this story.'; + +test('allows the intentional settings read failure only in its error story', () => { + assert.equal(isExpectedConsoleError(errorStory, expectedError), true); + assert.equal(isExpectedConsoleError('product-settings-pages--general', expectedError), false); + assert.equal( + isExpectedConsoleError( + 'product-settings-pages--projects-cached-host-revalidation', + expectedError, + ), + false, + ); +}); + +test('keeps unexpected settings errors fatal, including errors in the error story', () => { + const missingBridgeError = + "[settings] operation failed: Cannot read properties of undefined (reading 'getSnapshot')"; + assert.equal(isExpectedConsoleError(errorStory, missingBridgeError), false); + assert.equal( + isExpectedConsoleError( + 'product-settings-pages--projects-cached-host-revalidation', + missingBridgeError, + ), + false, + ); + assert.equal(isExpectedConsoleError(errorStory, `${expectedError} unexpected detail`), false); + assert.equal(isExpectedConsoleError(errorStory, 'unexpected render failure'), false); +});