diff --git a/apps/desktop/e2e/about-page.spec.ts b/apps/desktop/e2e/about-page.spec.ts new file mode 100644 index 0000000000..ad34ab3b23 --- /dev/null +++ b/apps/desktop/e2e/about-page.spec.ts @@ -0,0 +1,47 @@ +/* + * 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 { ensureSidebarExpanded, expect, test } from './fixtures'; + +test('About renders channel facts, support, and privacy on a dev checkout', async ({ window: page }) => { + await ensureSidebarExpanded(page); + await page.getByRole('button', { name: '设置' }).click(); + await page.getByRole('button', { name: '关于', exact: true }).click(); + + // The channel token must agree with the version string: the fixture app is a + // dev checkout, so it reads 本地开发版. + await expect(page.getByText('本地开发版', { exact: true })).toBeVisible(); + await expect(page.getByText('本地开发构建,不检查更新。')).toBeVisible(); + + // A dev checkout follows no feed, so the whole update row is absent — it is + // not a disabled button next to a sentence repeating the line above it. + await expect(page.getByRole('button', { name: '检查更新' })).toHaveCount(0); + + // Support lives outside the info conditional: usable even when `app.info` + // fails, which is exactly when a user reaches for it. Each row-end control is + // named by its row, not by the verb on its face. + await expect(page.getByRole('heading', { name: '支持', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '复制诊断信息' })).toBeEnabled(); + await expect(page.getByRole('link', { name: '报告问题' })).toBeVisible(); + await expect(page.getByRole('button', { name: '键盘快捷键' })).toBeEnabled(); + + // Three commitments, not the old wall of five bullets. + const privacyList = page.getByRole('list', { name: '隐私承诺' }); + await expect(privacyList.getByRole('listitem')).toHaveCount(3); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 77fd99edad..7714dfcaff 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -2585,17 +2585,14 @@ "../../preload/bridge-contract.js": 1, "../default-runtime-host-operation.js": 1, "../locales/settings-preferences-copy.js": 1, - "../locales/settings-shared-copy.js": 1, "./about-update-status.js": 1, "./settings-error-copy.js": 1, - "./settings-rows.js": 1, "./settings-section.js": 1, "./settings-skeleton.js": 1, "./use-action-guard.js": 1, "@astryxdesign/core": 1, "@astryxdesign/core/Kbd": 1, "@maka/ui": 1, - "@maka/ui/icons": 1, "react": 1 } }, diff --git a/apps/desktop/src/main/__tests__/about-settings-page.test.ts b/apps/desktop/src/main/__tests__/about-settings-page.test.ts index 8cc5f24002..5bfe81621f 100644 --- a/apps/desktop/src/main/__tests__/about-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/about-settings-page.test.ts @@ -32,6 +32,8 @@ test('keeps manual diagnostics available while About metadata is pending', () => createElement(LocaleProvider, { locale: 'en', children: withAstryxLocale }), ); - assert.match(markup, />Copy diagnostics]*aria-label="Copy diagnostics"/); assert.match(markup, /role="status"[^>]*aria-busy="true"/); }); diff --git a/apps/desktop/src/main/__tests__/about-update-status.test.ts b/apps/desktop/src/main/__tests__/about-update-status.test.ts new file mode 100644 index 0000000000..c3a33fae15 --- /dev/null +++ b/apps/desktop/src/main/__tests__/about-update-status.test.ts @@ -0,0 +1,77 @@ +/* + * 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 { + aboutChannelFacts, + aboutUpdateStatusDetail, +} from '../../renderer/settings/about-update-status.js'; +import { getSettingsPreferencesCopy } from '../../renderer/locales/settings-preferences-copy.js'; + +const copy = getSettingsPreferencesCopy('zh').about; + +test('a packaged nightly is tokened Nightly, never 正式版', () => { + const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'nightly' }, copy); + assert.deepEqual(facts.token, { label: 'Nightly', color: 'orange' }); + assert.match(facts.summary, /会覆盖正式版安装/); +}); + +test('a packaged release wears no token — it is the default state', () => { + const facts = aboutChannelFacts({ buildMode: 'packaged', updateChannel: 'release' }, copy); + assert.equal(facts.token, null); + assert.equal(facts.summary, '正式发布版,自动接收稳定更新。'); +}); + +test('buildMode decides before updateChannel, whose dev value is a placeholder', () => { + const facts = aboutChannelFacts({ buildMode: 'dev', updateChannel: 'nightly' }, copy); + assert.deepEqual(facts.token, { label: '本地开发版', color: 'gray' }); + assert.equal(facts.summary, '本地开发构建,不检查更新。'); +}); + +test('the nightly steady states each read as themselves', () => { + const detail = (status: Parameters[0]) => + aboutUpdateStatusDetail(status, copy); + + assert.equal( + detail({ + state: 'downloading', + currentVersion: '0.2.0-dev.11.20260831', + latestVersion: '0.2.0-dev.12.20260901', + progress: { percent: 42.4, bytesPerSecond: 1, transferred: 1, total: 2 }, + }), + '正在下载 v0.2.0-dev.12.20260901(42%)…', + ); + assert.equal( + detail({ + state: 'verifying', + currentVersion: '0.2.0-dev.11.20260831', + latestVersion: '0.2.0-dev.12.20260901', + }), + '正在验证 v0.2.0-dev.12.20260901 的发布来源…', + ); + assert.equal( + detail({ + state: 'downloaded', + currentVersion: '0.2.0-dev.11.20260831', + latestVersion: '0.2.0-dev.12.20260901', + }), + 'v0.2.0-dev.12.20260901 已下载,可在侧栏选择重启安装。', + ); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts index 7a548982ec..b9cf1f8f30 100644 --- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -27,6 +27,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { + desktopDiagnosticUpdateChannel, desktopUpdateChannelFromManifest, verifyDownloadedUpdateAttestation, } from '../app-update-attestation.js'; @@ -294,6 +295,34 @@ test('packaged update trust accepts only an explicit release or nightly channel' ); }); +test('a diagnostic report names the channel it can prove, and admits when it cannot', async () => { + const appPath = await mkdtemp(join(tmpdir(), 'maka-channel-')); + const channelOf = (isPackaged: boolean) => desktopDiagnosticUpdateChannel({ isPackaged, appPath }); + try { + // Packaged: the manifest electron-builder stamped is the authority, and the + // two feeds must come back distinct — they carry different signers. + await writeFile(join(appPath, 'package.json'), JSON.stringify({ makaUpdateChannel: 'nightly' })); + assert.equal(channelOf(true), 'nightly'); + await writeFile(join(appPath, 'package.json'), JSON.stringify({ makaUpdateChannel: 'release' })); + assert.equal(channelOf(true), 'release'); + + // A checkout follows no feed, and never reads the manifest: the `release` + // value the updater falls back to is a placeholder, not a fact. + assert.equal(channelOf(false), 'dev'); + + // Unlike the strict parser, this one must not throw — the report has to + // copy even when the manifest is the thing that is broken. + await writeFile(join(appPath, 'package.json'), '{ not json'); + assert.equal(channelOf(true), 'unknown'); + await writeFile(join(appPath, 'package.json'), JSON.stringify({ makaUpdateChannel: 'preview' })); + assert.equal(channelOf(true), 'unknown'); + await rm(join(appPath, 'package.json')); + assert.equal(channelOf(true), 'unknown'); + } finally { + await rm(appPath, { recursive: true, force: true }); + } +}); + test('TUF verifies ECDSA without breaking Ed25519 in the packaged Electron runtime', () => { assertElectronVerification( String.raw` diff --git a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts index ec6b42eba7..b96c0114f4 100644 --- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts +++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts @@ -32,11 +32,13 @@ import { MAIN_PROCESS_DIAGNOSTIC_LOG_MAX_BYTES, mainProcessLogBuffer, parseDesktopDiagnosticInput, + type DesktopDiagnosticChannel, } from '../main-process-diagnostics.js'; const environment = { appVersion: '0.1.8', buildMode: 'dev' as const, + updateChannel: 'dev' as const, buildCommit: 'a'.repeat(40), electronVersion: '38.0.0', nodeVersion: '22.0.0', @@ -107,6 +109,25 @@ test('formats one redacted Desktop and Runtime Host diagnostic report', () => { assert.doesNotMatch(report, /sk-secretvalue123|\/home\/tester/); }); +test('names the update channel, which buildMode alone cannot distinguish', () => { + const reportFor = (channel: DesktopDiagnosticChannel, buildMode: 'dev' | 'packaged') => + formatDesktopDiagnosticReport( + { surface: 'manual', hostTarget: 'default' }, + { ...environment, buildMode, updateChannel: channel }, + [], + { ok: false, error: 'not started' }, + undefined, + new Date('2026-08-09T00:00:00Z'), + ); + + // Both packaged installs report `Build: packaged`; only the channel line + // says which feed and which attestation signer they trust. + assert.match(reportFor('nightly', 'packaged'), /^Channel: nightly$/mu); + assert.match(reportFor('release', 'packaged'), /^Channel: release$/mu); + // A checkout follows no feed, so it is neither of them. + assert.match(reportFor('dev', 'dev'), /^Channel: dev$/mu); +}); + test('bounds renderer diagnostic text and rejects unknown fields', () => { const input = parseDesktopDiagnosticInput({ surface: 'toast', diff --git a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts index fd5a621e6c..954f0d89b6 100644 --- a/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/native-diagnostic-dialog.test.ts @@ -36,6 +36,7 @@ import { const diagnosticEnvironment = () => ({ appVersion: '0.1.8', buildMode: 'packaged' as const, + updateChannel: 'release' as const, buildCommit: null, electronVersion: '38.0.0', nodeVersion: '22.0.0', diff --git a/apps/desktop/src/main/app-ipc-main.ts b/apps/desktop/src/main/app-ipc-main.ts index ceb9ac4dd2..0260ccf205 100644 --- a/apps/desktop/src/main/app-ipc-main.ts +++ b/apps/desktop/src/main/app-ipc-main.ts @@ -26,6 +26,7 @@ import type { ProjectRootController } from './project-root-controller.js'; import { resolveOpenPath, type OpenPathResult } from './open-path-guard.js'; import { getE2eFixtureState, type resolveE2eFixture } from './e2e-fixture.js'; import type { resolveBuildInfo } from './build-info.js'; +import type { DesktopUpdateChannel } from './app-update-attestation.js'; import type { AppUpdateInstallRequest, AppUpdateService, @@ -47,6 +48,7 @@ export interface AppIpcDeps { getProjectRoot(sessionId: unknown): Promise; workspaceRoot: string; buildInfo: BuildInfo; + updateChannel: DesktopUpdateChannel; e2eFixture: E2eFixture; projectManagement: ProjectManagementService; allowLocalProjectPaths?: boolean; @@ -89,7 +91,7 @@ export function registerAppIpc( deps: AppIpcDeps, targetIpc: ReconnectableReadIpcMain = ipcMain, ): void { - const { projectRoot, workspaceRoot, buildInfo, e2eFixture } = deps; + const { projectRoot, workspaceRoot, buildInfo, updateChannel, e2eFixture } = deps; const allowLocalProjectPaths = deps.allowLocalProjectPaths !== false; // Call-time read of the shared project-root authority: every handler must // observe the latest selection, not a snapshot taken at registration. @@ -120,6 +122,7 @@ export function registerAppIpc( : { isGitRepo: false }, buildMode: buildInfo.mode, buildCommit: buildInfo.commit, + updateChannel, }; }); handleReconnectableRead(targetIpc, 'projects:getSnapshot', () => diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts index bc8a4775ef..bc2d01478f 100644 --- a/apps/desktop/src/main/app-update-attestation.ts +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -21,8 +21,8 @@ import { bundleFromJSON, type Bundle } from '@sigstore/bundle'; import { getTrustedRoot } from '@sigstore/tuf'; import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify'; import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { basename } from 'node:path'; +import { createReadStream, readFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; const PRODUCT_REPOSITORY = 'apache/maka'; const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml'; @@ -80,6 +80,29 @@ export function desktopUpdateChannelFromManifest(manifest: unknown): DesktopUpda return channel; } +/** + * The channel a diagnostic report names for this binary. + * + * Total by construction, unlike `desktopUpdateChannelFromManifest`: a report + * must still copy when the manifest is the very thing that is broken, and the + * report saying `unknown` is more useful than the copy failing. A checkout + * follows no feed at all, so it reports `dev` rather than the updater's + * `release` placeholder. + */ +export function desktopDiagnosticUpdateChannel(input: { + readonly isPackaged: boolean; + readonly appPath: string; +}): DesktopUpdateChannel | 'dev' | 'unknown' { + if (!input.isPackaged) return 'dev'; + try { + return desktopUpdateChannelFromManifest( + JSON.parse(readFileSync(join(input.appPath, 'package.json'), 'utf8')), + ); + } catch { + return 'unknown'; + } +} + function productWorkflowSigner(channel: DesktopUpdateChannel): RegExp { const workflow = channel === 'nightly' ? PRODUCT_NIGHTLY_WORKFLOW : PRODUCT_RELEASE_WORKFLOW; return new RegExp( diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts index dba428ab43..a5b39dccdd 100644 --- a/apps/desktop/src/main/main-process-diagnostics.ts +++ b/apps/desktop/src/main/main-process-diagnostics.ts @@ -44,9 +44,18 @@ const INPUT_TRUNCATION_MARKER = '\n'; const EXECUTION_DIAGNOSTIC_TIMEOUT_MS = 2_000; export const MAIN_PROCESS_DIAGNOSTIC_LOG_MAX_BYTES = 256 * 1024; +/** + * The release feed a build follows, as a report names it. `buildMode` alone + * cannot say this: every packaged install reports `packaged`, so a nightly and + * a release looked identical in a report while following different feeds and + * different attestation signers. `dev` is not a feed — a checkout follows none. + */ +export type DesktopDiagnosticChannel = 'release' | 'nightly' | 'dev' | 'unknown'; + export interface DesktopDiagnosticEnvironment { readonly appVersion: string; readonly buildMode: 'dev' | 'packaged'; + readonly updateChannel: DesktopDiagnosticChannel; readonly buildCommit: string | null; readonly electronVersion: string; readonly nodeVersion: string; @@ -63,6 +72,7 @@ export interface DesktopDiagnosticEnvironment { export interface DesktopDiagnosticEnvironmentSource { readonly appVersion: string; readonly buildMode: 'dev' | 'packaged'; + readonly updateChannel: DesktopDiagnosticChannel; readonly buildCommit: string | null; readonly locale: string; readonly workspacePath: string; @@ -431,6 +441,7 @@ export function formatDesktopDiagnosticReport( 'Environment', `Maka: ${environment.appVersion}`, `Build: ${environment.buildMode}${environment.buildCommit ? ` @ ${environment.buildCommit.slice(0, 12)}` : ''}`, + `Channel: ${environment.updateChannel}`, `Electron: ${environment.electronVersion}`, `Chrome: ${environment.chromeVersion}`, `Node: ${environment.nodeVersion}`, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 8cea0b9d1d..16133b4515 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -27,6 +27,7 @@ import { app, clipboard, dialog, ipcMain } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; import { resolveUpdateTestUserDataDirectory } from './app-update-test-context.js'; +import { desktopDiagnosticUpdateChannel } from './app-update-attestation.js'; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -176,6 +177,10 @@ if (!app.requestSingleInstanceLock()) { captureDesktopDiagnosticEnvironment({ appVersion: app.getVersion(), buildMode: buildInfo.mode, + updateChannel: desktopDiagnosticUpdateChannel({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + }), buildCommit: buildInfo.commit, locale: app.getLocale(), workspacePath: join(app.getPath('userData'), 'workspaces', 'default'), @@ -212,6 +217,10 @@ if (!app.requestSingleInstanceLock()) { captureDesktopDiagnosticEnvironment({ appVersion: app.getVersion(), buildMode: buildInfo.mode, + updateChannel: desktopDiagnosticUpdateChannel({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + }), buildCommit: buildInfo.commit, locale: app.getLocale(), workspacePath: join(app.getPath('userData'), 'workspaces', 'default'), diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index cee0633822..e65193172d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -70,6 +70,7 @@ import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; import { + desktopDiagnosticUpdateChannel, desktopUpdateChannelFromManifest, verifyDownloadedUpdateAttestation, } from "./app-update-attestation.js"; @@ -326,6 +327,10 @@ const desktopDiagnostics: DesktopDiagnosticsDeps = { captureDesktopDiagnosticEnvironment({ appVersion: app.getVersion(), buildMode: buildInfo.mode, + updateChannel: desktopDiagnosticUpdateChannel({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + }), buildCommit: buildInfo.commit, locale: app.getLocale(), workspacePath: workspaceRoot, @@ -1530,6 +1535,7 @@ function registerHostClientIpc( getProjectRoot: resolveProjectRootForContext, workspaceRoot, buildInfo, + updateChannel: desktopUpdateChannel, e2eFixture, projectManagement: targetProjectManagement, allowLocalProjectPaths: !usesHostWorkspace, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index f825aaf1cb..8c4c5431d9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -711,6 +711,10 @@ export interface DesktopAppInfo { readonly projectGit: { readonly isGitRepo: boolean; readonly branch?: string }; readonly buildMode: 'dev' | 'packaged'; readonly buildCommit: string | null; + /** Packaged update channel, resolved from the packaged manifest — the same + * authority the updater itself reads. 'release' is also the dev-mode + * fallback, where the channel is meaningless. */ + readonly updateChannel: 'release' | 'nightly'; } /** diff --git a/apps/desktop/src/renderer/locales/settings-navigation-copy.ts b/apps/desktop/src/renderer/locales/settings-navigation-copy.ts index 0bfb6ba761..cd81e72bf8 100644 --- a/apps/desktop/src/renderer/locales/settings-navigation-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-navigation-copy.ts @@ -50,7 +50,7 @@ const SETTINGS_NAVIGATION_COPY_BY_LOCALE = { data: { label: '数据', description: '本地工作区路径、备份与恢复。' }, permissions: { label: '权限与能力', description: '系统权限授予状态与 Maka 能力运行时检查。' }, health: { label: '健康', description: '运行时连接、模型探针与本地健康状态。' }, - about: { label: '关于', description: '版本、运行环境与隐私承诺。' }, + about: { label: '关于', description: '版本、更新与隐私承诺。' }, }, }, en: { @@ -76,7 +76,7 @@ const SETTINGS_NAVIGATION_COPY_BY_LOCALE = { data: { label: 'Data', description: 'Local workspace paths, backup, and restore.' }, permissions: { label: 'Permissions & Capabilities', description: 'System grants and runtime checks for Maka capabilities.' }, health: { label: 'Health', description: 'Runtime connections, model probes, and local health status.' }, - about: { label: 'About', description: 'Version, runtime environment, and privacy commitments.' }, + about: { label: 'About', description: 'Version, updates, and privacy commitments.' }, }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 3c742388d7..d4f31f31eb 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -226,8 +226,14 @@ export type SettingsPreferencesCopy = { copyFailed: string; clipboardUnavailable: string; devBuild: string; - packagedBuild: string; - subtitle: string; + nightlyBuild: string; + buildLabel: string; + /** One sentence saying what following this channel means for the user. */ + channelSummaries: Record<'dev' | 'nightly' | 'release', string>; + supportTitle: string; + reportIssueHelp: string; + reportIssueOpen: string; + copyAction: string; privacyLabel: string; privacyTitle: string; privacyPoints: readonly string[]; @@ -238,11 +244,8 @@ export type SettingsPreferencesCopy = { keyboardShortcutsHelp: string; keyboardShortcutsOpen: string; reportIssueLabel: string; - updatesTitle: string; checkForUpdates: string; checkingForUpdates: string; - updateHelp: string; - updateDevBuildHelp: string; updateIdle: string; updateNotAvailable: string; updateAvailable: (version: string) => string; @@ -339,12 +342,19 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { passwordSavedPlaceholder: '密码已保存;输入新密码以替换', }, about: { - loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], copying: '复制中…', copyDiagnostics: '复制诊断信息', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径,以及近期脱敏的 Desktop 与 Runtime Host 日志;仅写入剪贴板,不会自动上传。', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', reportIssueLabel: '报告问题', - updatesTitle: '软件更新', + loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制诊断信息', pasteHint: '检查内容后,可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', nightlyBuild: 'Nightly', buildLabel: '构建', + channelSummaries: { + dev: '本地开发构建,不检查更新。', + nightly: '每日构建的预发布版,自动更新到最新 nightly,会覆盖正式版安装。', + release: '正式发布版,自动接收稳定更新。', + }, + supportTitle: '支持', + copying: '复制中…', copyDiagnostics: '复制诊断信息', copyAction: '复制', copyHelp: '复制版本、平台、隐藏主目录后的工作区路径与近期脱敏日志;仅写入剪贴板,不会自动上传。', + reportIssueLabel: '报告问题', reportIssueHelp: '带上诊断信息去 GitHub Issues,回复更快。', reportIssueOpen: '打开', + keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', + privacyLabel: '隐私承诺', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['任务、设置、凭据和 Skill 指令文件都留在本机;模型密钥保存在本机凭据文件内,订阅令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权;每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], checkForUpdates: '检查更新', checkingForUpdates: '检查中…', - updateHelp: '后台也会定期检查;需要重启安装时侧栏会提示。', - updateDevBuildHelp: '本地开发版不检查 GitHub 发布更新。请使用正式安装包。', updateIdle: '尚未检查更新。', updateNotAvailable: '已是最新版本。', updateAvailable: (version) => `发现新版本 v${version},正在准备下载…`, @@ -393,12 +403,19 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { passwordSavedPlaceholder: 'Password saved; enter a new password to replace it', }, about: { - loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', packagedBuild: 'Release build', subtitle: 'A local-first AI assistant · Desktop runtime', privacyLabel: 'Privacy and security', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay in the local workspace.', 'Model keys stay in a local credential file; subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task.', 'Messages, tool calls, permission decisions, and mode changes are retained locally for each task.'], copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted Desktop and Runtime Host logs. The report is written only to the clipboard and is never uploaded automatically.', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', reportIssueLabel: 'Report an issue', - updatesTitle: 'Software updates', + loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Diagnostics copied', pasteHint: 'Review the content, then paste it into an issue report', copyFailed: 'Copy failed', clipboardUnavailable: 'The clipboard is unavailable or access was denied.', devBuild: 'Local development build', nightlyBuild: 'Nightly', buildLabel: 'Build', + channelSummaries: { + dev: 'A local development build. It does not check for updates.', + nightly: 'A daily prerelease build. It updates itself to the latest nightly and replaces a release install.', + release: 'The official release build. It receives stable updates automatically.', + }, + supportTitle: 'Support', + copying: 'Copying…', copyDiagnostics: 'Copy diagnostics', copyAction: 'Copy', copyHelp: 'Copy version, platform, a home-redacted workspace path, and recent redacted logs. The report is written only to the clipboard and is never uploaded automatically.', + reportIssueLabel: 'Report an issue', reportIssueHelp: 'Open a GitHub issue with your diagnostics attached — replies come faster.', reportIssueOpen: 'Open', + keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', + privacyLabel: 'Privacy commitments', privacyTitle: 'Local first · Private by default', privacyPoints: ['Tasks, settings, credentials, and Skill instructions stay on this machine; model keys live in a local credential file and subscription tokens use secure system storage.', 'Maka sends no usage telemetry and contacts a model provider only when you enable it.', 'High-risk tool operations require explicit permission in the task; messages, tool calls, permission decisions, and mode changes are retained locally for each task.'], checkForUpdates: 'Check for updates', checkingForUpdates: 'Checking…', - updateHelp: 'Maka also checks in the background. When a restart is required, the sidebar will prompt you.', - updateDevBuildHelp: 'Development builds do not check GitHub releases. Use a packaged install.', updateIdle: 'No update check has run yet.', updateNotAvailable: 'You are on the latest version.', updateAvailable: (version) => `Version v${version} is available and will download shortly…`, diff --git a/apps/desktop/src/renderer/locales/settings-shared-copy.ts b/apps/desktop/src/renderer/locales/settings-shared-copy.ts index 834619160d..f414806ed0 100644 --- a/apps/desktop/src/renderer/locales/settings-shared-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-shared-copy.ts @@ -64,8 +64,6 @@ export type SettingsSharedCopy = { memoryEntriesHelp: string; reviewSchedule: string; reviewScheduleHelp: string; - buildInfo: string; - reference: string; }; }; @@ -108,8 +106,6 @@ const SETTINGS_SHARED_COPY_BY_LOCALE = { dataLocationHelp: '任务、设置、使用统计与凭据都以文件形式存放在本机的这个位置。', reviewSchedule: '回顾计划', reviewScheduleHelp: '每日回顾的生成时间与使用的模型。', - buildInfo: '版本信息', - reference: '参考', }, }, en: { @@ -150,8 +146,6 @@ const SETTINGS_SHARED_COPY_BY_LOCALE = { dataLocationHelp: 'Tasks, settings, usage statistics, and credentials are stored as files in this location on your machine.', reviewSchedule: 'Review schedule', reviewScheduleHelp: 'When the daily review runs, and which model writes it.', - buildInfo: 'Build info', - reference: 'Reference', }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index 6b5c0c056e..aad940a6a5 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -17,27 +17,26 @@ * under the License. */ -import { useEffect, useId, useState, type ReactNode } from 'react'; -import { Badge, Link, List, ListItem } from '@astryxdesign/core'; -import { Kbd } from '@astryxdesign/core/Kbd'; -import { Sparkles } from '@maka/ui/icons'; +import { useEffect, useState, type ReactNode } from 'react'; import { - Banner, - Button, - PageHeader, - useMountedRef, - useToast, - useUiLocale, -} from '@maka/ui'; + Heading, + HStack, + Link, + List, + ListItem, + Text, + Token, + VStack, +} from '@astryxdesign/core'; +import { Kbd } from '@astryxdesign/core/Kbd'; +import { Banner, Button, useMountedRef, useToast, useUiLocale } from '@maka/ui'; import type { AppUpdateStatus } from '../../preload/bridge-contract.js'; -import { SettingsActions, SettingsPage, SettingsSection } from './settings-section.js'; -import { SettingRow } from './settings-rows.js'; +import { SettingsPage, SettingsRow, SettingsSection } from './settings-section.js'; import { settingsActionErrorMessage } from './settings-error-copy.js'; import { SettingsSkeletonStack } from './settings-skeleton.js'; import { useActionGuard } from './use-action-guard.js'; -import { aboutUpdateStatusDetail } from './about-update-status.js'; +import { aboutChannelFacts, aboutUpdateStatusDetail } from './about-update-status.js'; import { getSettingsPreferencesCopy } from '../locales/settings-preferences-copy.js'; -import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { defaultRuntimeHostDiagnosticTarget, runOnDefaultRuntimeHost, @@ -50,7 +49,6 @@ const ISSUE_TRACKER_URL = 'https://github.com/apache/maka/issues'; export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const locale = useUiLocale(); const copy = getSettingsPreferencesCopy(locale).about; - const sharedCopy = getSettingsSharedCopy(locale); const [info, setInfo] = useState(null); const [infoError, setInfoError] = useState(null); const [copyingDiagnostics, setCopyingDiagnostics] = useState(false); @@ -60,8 +58,6 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const checkUpdateGuard = useActionGuard<'check'>(); const aboutPageMountedRef = useMountedRef(); const toast = useToast(); - const diagnosticsHelpId = useId(); - const updateHelpId = useId(); useEffect(() => { let cancelled = false; @@ -73,16 +69,17 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { } }) .catch((error) => { - if (cancelled) return; - const message = settingsActionErrorMessage(error, locale); - setInfoError(message); - toast.error( - copy.loadFailed, - message, - undefined, - defaultRuntimeHostDiagnosticTarget(error), - ); - }); + if (!cancelled) { + const message = settingsActionErrorMessage(error, locale); + setInfoError(message); + toast.error( + copy.loadFailed, + message, + undefined, + defaultRuntimeHostDiagnosticTarget(error), + ); + } + }); return () => { cancelled = true; }; @@ -140,126 +137,153 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { } } - let aboutContent: ReactNode; + let identity: ReactNode; if (!info && !infoError) { - aboutContent = ( - + identity = ( + + + ); } else if (!info) { - aboutContent = ( - + identity = ( + + + ); } else { - aboutContent = ( - <> - /* 64% of the 48px plate, matching .providerLogo's fill */} - iconClassName="settingsAboutLogo" - headingRowClassName="settingsAboutHeading" - title="Maka" - badge={ - <> - - - - } - subtitle={copy.subtitle} - subtitleClassName="settingsAboutTagline" - /> - {/* Detail audit: the five privacy commitments rendered inside an info - Banner — five lines of bold status-blue body copy, the exact blue - flood DESIGN.md's Signal-Not-Texture rule forbids. They are ordinary - statements, so they read as a quiet marker list in a labeled group. */} - - - {/* Fragment-wrapped: ListItem single-line-truncates STRING labels, - and a privacy commitment must wrap, not ellipsize. */} - {copy.privacyPoints.map((point) => {point}} />)} - - - {/* The keyboard sheet's home. It used to be reachable only from the - titlebar's `…` drawer and from two shortcuts — which made the panel - that lists the shortcuts openable only by shortcut. It is reference - material about the app, so it belongs on 关于, and this is the entry - a mouse can find. */} - {props.onOpenKeyboardHelp && ( - - - )} - /> - - )} - - + + + + Maka + {/* Release installs carry no token: they are the default state, + and Astryx keeps colour for what departs from it. */} + {channel.token ? ( + + ) : null} + + + {info.buildCommit + ? `v${info.appVersion} · ${copy.buildLabel} ${info.buildCommit}` + : `v${info.appVersion}`} + + {channel.summary} + + {/* A dev checkout follows no feed, so it gets no status line at all: + its channel sentence above already says it does not update, and + the updater's own dev copy said the same thing a second time in + the next paragraph. The row exists only where it can act. + + The status line says what the button would tell you, so it carries + no label of its own. It wraps rather than crushes: at the 480px + window floor the sentence needs the full width. */} + {isDevBuild ? null : ( + + {aboutUpdateStatusDetail(updateStatus, copy)}