From f1cb162fc2b17217fea95bef14e651b375b0bc05 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 21 Aug 2026 21:07:54 +0800 Subject: [PATCH 1/8] feat(desktop): copy diagnostics on demand Promote the environment-summary action into a manual diagnostic capture that reuses the bounded, redacted Desktop and Runtime Host report pipeline. Manual capture remains available when no Runtime Host or About metadata is available and writes only to the system clipboard. Generated-by: Codex --- .../main-process-diagnostics.test.ts | 85 ++++++++++-- .../src/main/desktop-diagnostics-ipc-main.ts | 48 ++++--- .../src/main/main-process-diagnostics.ts | 46 ++++--- apps/desktop/src/main/runtime-host-boot.ts | 4 + apps/desktop/src/preload/bridge-contract.d.ts | 4 +- .../src/preload/diagnostics-contract.ts | 15 ++- apps/desktop/src/preload/preload.ts | 11 +- .../src/renderer/app-shell-command-actions.ts | 37 ++---- apps/desktop/src/renderer/app-shell.tsx | 2 +- .../src/renderer/command-palette-commands.ts | 23 ++-- apps/desktop/src/renderer/error-boundary.tsx | 2 +- .../locales/settings-preferences-copy.ts | 6 +- .../src/renderer/locales/shell-copy.ts | 28 ++-- .../renderer/settings/about-settings-page.tsx | 122 +++++++----------- 14 files changed, 240 insertions(+), 193 deletions(-) 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 97b5435c24..ece4a9f94e 100644 --- a/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts +++ b/apps/desktop/src/main/__tests__/main-process-diagnostics.test.ts @@ -6,8 +6,8 @@ import { type DesktopDiagnosticsIpcDeps, } from '../desktop-diagnostics-ipc-main.js'; import { - formatDesktopErrorDiagnosticReport, - parseDesktopErrorDiagnosticInput, + formatDesktopDiagnosticReport, + parseDesktopDiagnosticInput, } from '../main-process-diagnostics.js'; const environment = { @@ -48,7 +48,7 @@ const runtimeHostDiagnostics = { }; test('formats one redacted Desktop and Runtime Host diagnostic report', () => { - const report = formatDesktopErrorDiagnosticReport( + const report = formatDesktopDiagnosticReport( { surface: 'toast', title: 'Connection failed', @@ -71,21 +71,48 @@ test('formats one redacted Desktop and Runtime Host diagnostic report', () => { }); test('bounds renderer diagnostic text and rejects unknown fields', () => { - const input = parseDesktopErrorDiagnosticInput({ + const input = parseDesktopDiagnosticInput({ surface: 'toast', title: '🚀'.repeat(513), description: 'x'.repeat(30 * 1024), }); + assert.equal(input.surface, 'toast'); assert.ok(Buffer.byteLength(input.title) <= 512); assert.ok(Buffer.byteLength(input.description ?? '') <= 24 * 1024); assert.match(input.title, /$/); assert.throws( - () => parseDesktopErrorDiagnosticInput({ surface: 'toast', title: 'error', extra: true }), + () => parseDesktopDiagnosticInput({ surface: 'toast', title: 'error', extra: true }), /Invalid Desktop diagnostic input/, ); }); +test('accepts only renderer context for a manual diagnostic capture', () => { + assert.deepEqual( + parseDesktopDiagnosticInput({ surface: 'manual', rendererLocale: 'en-US' }), + { surface: 'manual', rendererLocale: 'en-US' }, + ); + assert.throws( + () => parseDesktopDiagnosticInput({ surface: 'manual', title: 'Not an error' }), + /Invalid Desktop diagnostic input/, + ); +}); + +test('formats a manual capture without inventing an error', () => { + const report = formatDesktopDiagnosticReport( + { surface: 'manual', rendererLocale: 'en-US' }, + environment, + ['main log'], + { ok: true, value: runtimeHostDiagnostics }, + undefined, + new Date('2026-08-09T00:00:00Z'), + ); + + assert.match(report, /Capture\nSurface: manual/); + assert.doesNotMatch(report, /\nError\n|\nTitle:/); + assert.match(report, /Recent Runtime Host logs \(1\)\nhost log/); +}); + test('copies Desktop diagnostics while Runtime Host is unavailable', async () => { type IpcHandler = Parameters['handle']>[1]; const handlers = new Map(); @@ -98,6 +125,7 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () => }, environment: () => environment, mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: () => ({ getDiagnostics: async () => { throw new Error('Runtime Host disconnected'); @@ -109,7 +137,7 @@ test('copies Desktop diagnostics while Runtime Host is unavailable', async () => }, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); const result = await handler( {} as never, @@ -134,13 +162,14 @@ test('copies Desktop diagnostics while the scoped Host is reconnecting', async ( }, environment: () => environment, mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: () => undefined, writeClipboard: (value) => { clipboard = value; }, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); assert.deepEqual( await handler( @@ -231,13 +260,14 @@ test('copies bounded evidence for the exact failed Turn', async () => { }, environment: () => environment, mainLogs: () => [], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: () => runtime, writeClipboard: (value) => { clipboard = value; }, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); const result = await handler( {} as never, @@ -271,6 +301,7 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy }, environment: () => environment, mainLogs: () => [], + resolveActiveRuntimeHost: () => undefined, resolveRuntimeHost: (scope) => { assert.equal(scope.hostId, activeHostId); assert.equal(scope.targetEpoch, 'target-a'); @@ -289,7 +320,7 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy writeClipboard() {}, }); - const handler = handlers.get('diagnostics:copyErrorReport'); + const handler = handlers.get('diagnostics:copyReport'); assert.ok(handler); const copying = handler( {} as never, @@ -310,3 +341,39 @@ test('keeps every diagnostic read bound to the scoped Host during a switch', asy assert.deepEqual(await copying, { ok: true }); assert.deepEqual(traceReads, ['host-a']); }); + +test('copies manual Desktop diagnostics without an active Runtime Host', async () => { + type IpcHandler = Parameters['handle']>[1]; + const handlers = new Map(); + let clipboard = ''; + registerDesktopDiagnosticsIpc({ + ipcMain: { + handle(channel, handler) { + handlers.set(channel, handler); + }, + }, + environment: () => environment, + mainLogs: () => ['main remained available'], + resolveActiveRuntimeHost: () => undefined, + resolveRuntimeHost: () => { + throw new Error('Manual capture must not require a renderer-provided Host scope'); + }, + writeClipboard: (value) => { + clipboard = value; + }, + }); + + const handler = handlers.get('diagnostics:copyReport'); + assert.ok(handler); + assert.deepEqual( + await handler( + {} as never, + undefined, + { surface: 'manual', rendererLocale: 'en-US' }, + ), + { ok: true }, + ); + assert.match(clipboard, /Capture\nSurface: manual/); + assert.match(clipboard, /Recent main-process logs \(1\)\nmain remained available/); + assert.match(clipboard, /Diagnostics unavailable: Runtime Host is unavailable/); +}); diff --git a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts index f0cbfdd655..f6f8c55f61 100644 --- a/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts +++ b/apps/desktop/src/main/desktop-diagnostics-ipc-main.ts @@ -9,39 +9,46 @@ import { type DesktopTargetScope, } from '../shared/runtime-host-identity.js'; import { - formatDesktopErrorDiagnosticReport, - parseDesktopErrorDiagnosticInput, + formatDesktopDiagnosticReport, + parseDesktopDiagnosticInput, type DesktopDiagnosticEnvironment, type RuntimeHostDiagnosticRead, type RuntimeHostExecutionDiagnosticRead, } from './main-process-diagnostics.js'; +type RuntimeHostDiagnosticsClient = { + readonly getDiagnostics: () => Promise; + readonly getTurnTrace: ( + sessionId: string, + turnId: string, + ) => Promise; +}; + export interface DesktopDiagnosticsIpcDeps { readonly ipcMain: Pick; readonly environment: () => DesktopDiagnosticEnvironment; readonly mainLogs: () => readonly string[]; - readonly resolveRuntimeHost: (scope: DesktopTargetScope) => - | { - readonly getDiagnostics: () => Promise; - readonly getTurnTrace: ( - sessionId: string, - turnId: string, - ) => Promise; - } - | undefined; + readonly resolveActiveRuntimeHost: () => RuntimeHostDiagnosticsClient | undefined; + readonly resolveRuntimeHost: (scope: DesktopTargetScope) => RuntimeHostDiagnosticsClient | undefined; readonly writeClipboard: (value: string) => void; } export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): void { deps.ipcMain.handle( - 'diagnostics:copyErrorReport', + 'diagnostics:copyReport', async (_event, scope: unknown, rawInput: unknown): Promise => { - const host = requireDesktopTargetScope(scope); - const runtime = deps.resolveRuntimeHost(host); - const input = parseDesktopErrorDiagnosticInput(rawInput); + const input = parseDesktopDiagnosticInput(rawInput); + const runtime = input.surface === 'manual' + ? deps.resolveActiveRuntimeHost() + : deps.resolveRuntimeHost(requireDesktopTargetScope(scope)); let runtimeHost: RuntimeHostDiagnosticRead; if (!runtime) { - runtimeHost = { ok: false, error: 'Runtime Host is reconnecting' }; + runtimeHost = { + ok: false, + error: input.surface === 'manual' + ? 'Runtime Host is unavailable' + : 'Runtime Host is reconnecting', + }; } else { try { runtimeHost = { ok: true, value: await runtime.getDiagnostics() }; @@ -56,11 +63,12 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): } } let runtimeExecution: RuntimeHostExecutionDiagnosticRead | undefined; - if (input.execution && runtime) { + const execution = input.surface === 'manual' ? undefined : input.execution; + if (execution && runtime) { try { const turn = await runtime.getTurnTrace( - input.execution.sessionId, - input.execution.turnId, + execution.sessionId, + execution.turnId, ); runtimeExecution = turn ? { ok: true, value: turn } @@ -75,7 +83,7 @@ export function registerDesktopDiagnosticsIpc(deps: DesktopDiagnosticsIpcDeps): }; } } - const report = formatDesktopErrorDiagnosticReport( + const report = formatDesktopDiagnosticReport( input, deps.environment(), deps.mainLogs(), diff --git a/apps/desktop/src/main/main-process-diagnostics.ts b/apps/desktop/src/main/main-process-diagnostics.ts index d11c4114a3..19dc9ea949 100644 --- a/apps/desktop/src/main/main-process-diagnostics.ts +++ b/apps/desktop/src/main/main-process-diagnostics.ts @@ -4,7 +4,7 @@ import { redactSecrets } from '@maka/core/redaction'; import type { TurnTrace } from '@maka/core/session-trace'; import type { HostDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { - DesktopErrorDiagnosticInput, + DesktopDiagnosticInput, DesktopExecutionDiagnosticTarget, } from '../preload/diagnostics-contract.js'; @@ -51,19 +51,28 @@ export function installMainProcessLogCapture(buffer: DiagnosticLogBuffer = mainP installConsoleDiagnosticLogCapture(buffer); } -export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDiagnosticInput { +export function parseDesktopDiagnosticInput(input: unknown): DesktopDiagnosticInput { if (!input || typeof input !== 'object' || Array.isArray(input)) { throw new TypeError('Invalid Desktop diagnostic input'); } const record = input as Record; + const rendererKeys = new Set(['surface', 'rendererUserAgent', 'rendererLocale']); + if (record.surface === 'manual') { + if (Object.keys(record).some((key) => !rendererKeys.has(key))) { + throw new TypeError('Invalid Desktop diagnostic input'); + } + return { + surface: 'manual', + ...optionalBoundedString(record, 'rendererUserAgent', INPUT_LIMITS.rendererUserAgent), + ...optionalBoundedString(record, 'rendererLocale', INPUT_LIMITS.rendererLocale), + }; + } const allowedKeys = new Set([ - 'surface', + ...rendererKeys, 'title', 'description', 'details', 'execution', - 'rendererUserAgent', - 'rendererLocale', ]); if (Object.keys(record).some((key) => !allowedKeys.has(key))) { throw new TypeError('Invalid Desktop diagnostic input'); @@ -85,24 +94,22 @@ export function parseDesktopErrorDiagnosticInput(input: unknown): DesktopErrorDi }; } -export function formatDesktopErrorDiagnosticReport( - input: DesktopErrorDiagnosticInput, +export function formatDesktopDiagnosticReport( + input: DesktopDiagnosticInput, environment: DesktopDiagnosticEnvironment, mainLogs: readonly string[], runtimeHost: RuntimeHostDiagnosticRead, runtimeExecution: RuntimeHostExecutionDiagnosticRead | undefined = undefined, capturedAt = new Date(), ): string { - const lines = [ - 'Maka Desktop diagnostic report', - `Captured at: ${capturedAt.toISOString()}`, - '', - 'Error', - `Surface: ${input.surface}`, - `Title: ${input.title}`, - ]; - if (input.description) lines.push(`Description: ${input.description}`); - if (input.details) lines.push('', 'Details:', input.details); + const lines = ['Maka Desktop diagnostic report', `Captured at: ${capturedAt.toISOString()}`]; + if (input.surface === 'manual') { + lines.push('', 'Capture', 'Surface: manual'); + } else { + lines.push('', 'Error', `Surface: ${input.surface}`, `Title: ${input.title}`); + if (input.description) lines.push(`Description: ${input.description}`); + if (input.details) lines.push('', 'Details:', input.details); + } lines.push( '', @@ -140,12 +147,13 @@ export function formatDesktopErrorDiagnosticReport( lines.push(`Diagnostics unavailable: ${runtimeHost.error}`); } - if (input.execution) { + const execution = input.surface === 'manual' ? undefined : input.execution; + if (execution) { lines.push('', 'Runtime Host execution'); if (!runtimeExecution?.ok) { lines.push(`Execution evidence unavailable: ${runtimeExecution?.error ?? 'not queried'}`); } else { - appendTurnTrace(lines, input.execution, runtimeExecution.value); + appendTurnTrace(lines, execution, runtimeExecution.value); } } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 76932aa736..b6ff53e80e 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1235,6 +1235,10 @@ function registerPersistentClientIpc(): void { processUptimeSeconds: process.uptime(), }), mainLogs: () => mainProcessLogBuffer.snapshot(), + resolveActiveRuntimeHost: () => { + const scope = activeRuntimeHostRef(); + return scope ? resolveRuntimeHostDiagnostics(scope) : undefined; + }, resolveRuntimeHost: resolveRuntimeHostDiagnostics, writeClipboard: (report) => clipboard.writeText(report), }); diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70bdff7262..a19f3fc5c9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -100,7 +100,7 @@ import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-sna import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js'; import type { DesktopDiagnosticCopyResult, - DesktopErrorDiagnosticInput, + DesktopDiagnosticInput, } from './diagnostics-contract.js'; import type { Result } from '@maka/core/result'; import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs'; @@ -1121,7 +1121,7 @@ export interface MakaBridge { saveArtifactAs(sessionId: string, artifactId: string): Promise; }; diagnostics: { - copyErrorReport(input: DesktopErrorDiagnosticInput): Promise; + copyReport(input: DesktopDiagnosticInput): Promise; }; workspace: { searchFiles( diff --git a/apps/desktop/src/preload/diagnostics-contract.ts b/apps/desktop/src/preload/diagnostics-contract.ts index 7e0bc92b64..887a581624 100644 --- a/apps/desktop/src/preload/diagnostics-contract.ts +++ b/apps/desktop/src/preload/diagnostics-contract.ts @@ -4,16 +4,25 @@ export interface DesktopExecutionDiagnosticTarget { readonly eventId: string; } -export interface DesktopErrorDiagnosticInput { +interface DesktopDiagnosticRendererContext { + readonly rendererUserAgent?: string; + readonly rendererLocale?: string; +} + +export interface DesktopManualDiagnosticInput extends DesktopDiagnosticRendererContext { + readonly surface: 'manual'; +} + +export interface DesktopErrorDiagnosticInput extends DesktopDiagnosticRendererContext { readonly surface: 'toast' | 'renderer_crash'; readonly title: string; readonly description?: string; readonly details?: string; readonly execution?: DesktopExecutionDiagnosticTarget; - readonly rendererUserAgent?: string; - readonly rendererLocale?: string; } +export type DesktopDiagnosticInput = DesktopManualDiagnosticInput | DesktopErrorDiagnosticInput; + export type DesktopDiagnosticCopyResult = | { readonly ok: true } | { readonly ok: false; readonly reason: 'clipboard_unavailable' }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index e6520dd530..ee1827878b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -49,7 +49,7 @@ import { } from './transcript-contract.js'; import type { DesktopDiagnosticCopyResult, - DesktopErrorDiagnosticInput, + DesktopDiagnosticInput, } from './diagnostics-contract.js'; import type { ConnectionEvent } from '@maka/core/connections'; import type { @@ -2603,12 +2603,15 @@ const makaBridge = { }, }, diagnostics: { - async copyErrorReport(input: DesktopErrorDiagnosticInput): Promise { + async copyReport(input: DesktopDiagnosticInput): Promise { + if (input.surface === 'manual') { + return ipcRenderer.invoke('diagnostics:copyReport', undefined, input); + } if (!input.execution) { - return invokeActiveRuntimeHost('diagnostics:copyErrorReport', input); + return invokeActiveRuntimeHost('diagnostics:copyReport', input); } const session = await runtimeHostSessionRef(input.execution.sessionId); - return ipcRenderer.invoke('diagnostics:copyErrorReport', session.scope, { + return ipcRenderer.invoke('diagnostics:copyReport', session.scope, { ...input, execution: { ...input.execution, sessionId: session.sessionId }, }); diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 78269742b7..8d2cc5ac83 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -363,37 +363,16 @@ export function buildAppShellCommandList( ); } }, - onCopyEnvSummary: async () => { + onCopyDiagnostics: async () => { const { toastApi } = optionsRef.current; try { - const info = await window.maka.app.info(); - const platformPretty = - info.platform === "darwin" - ? "macOS" - : info.platform === "win32" - ? "Windows" - : info.platform === "linux" - ? "Linux" - : info.platform; - const buildLine = - info.buildMode === "dev" - ? `- Build: dev${info.buildCommit ? ` @ ${info.buildCommit}` : ""}` - : "- Build: packaged"; - const summary = [ - `**Maka** v${info.appVersion}`, - ``, - `- Electron: ${info.electronVersion}`, - `- Node: ${info.nodeVersion}`, - `- Chrome: ${info.chromeVersion}`, - `- Platform: ${platformPretty} ${info.osRelease}`, - `- Arch: ${info.arch}`, - buildLine, - ].join("\n"); - await navigator.clipboard.writeText(summary); - toastApi.success( - copy.environmentCopiedTitle, - `Maka v${info.appVersion} · ${platformPretty} · ${info.arch}`, - ); + const result = await window.maka.diagnostics.copyReport({ + surface: "manual", + rendererUserAgent: navigator.userAgent, + rendererLocale: navigator.language, + }); + if (!result.ok) throw new Error(copy.clipboardDenied); + toastApi.success(copy.diagnosticsCopiedTitle, copy.diagnosticsCopiedDescription); } catch (err) { toastApi.error( copy.copyFailedTitle, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 0708c2996e..20a258435f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -279,7 +279,7 @@ export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = { () => ({ label: getShellCopy(uiLocale).errorBoundary.copyReport, onClick: (input) => { - void window.maka.diagnostics.copyErrorReport({ + void window.maka.diagnostics.copyReport({ surface: 'toast', title: input.title, ...(input.description ? { description: input.description } : {}), diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index 31c23de0d1..48a896db61 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -8,7 +8,7 @@ import { Blocks, CalendarDays, Clock, - Database, + Clipboard, Download, FolderOpen, Keyboard, @@ -109,13 +109,8 @@ export function buildCommandList(args: { * round-tripping the clipboard. */ onSaveTodayDailyReviewToFile?(): Promise | void; - /** - * PR-CMD-PALETTE-COPY-ENV-SUMMARY-0: copy the Settings → 关于 - * environment summary (Maka version + Electron / Node / Chrome - * versions + platform + arch + build mode/sha) as Markdown, - * without having to open Settings. Useful for bug reports. - */ - onCopyEnvSummary?(): Promise | void; + /** Copy redacted Desktop and active Runtime Host diagnostics for issue reports. */ + onCopyDiagnostics?(): Promise | void; /** * PR-CMD-PALETTE-NETWORK-PROXY-TEST-0: ⌘K → 测试当前网络代理. Fires * `window.maka.settings.testNetworkProxy()` and surfaces the result @@ -369,14 +364,14 @@ export function buildCommandList(args: { run: () => args.onSaveTodayDailyReviewToFile!(), }); } - if (args.onCopyEnvSummary) { + if (args.onCopyDiagnostics) { cmds.push({ - id: 'diag:copy-env-summary', + id: 'diag:copy-diagnostics', kind: 'action', - ...staticCopy('diag:copy-env-summary'), - Icon: Database, - keywords: [...copy.staticKeywords['diag:copy-env-summary']], - run: () => args.onCopyEnvSummary!(), + ...staticCopy('diag:copy-diagnostics'), + Icon: Clipboard, + keywords: [...copy.staticKeywords['diag:copy-diagnostics']], + run: () => args.onCopyDiagnostics!(), }); } if (args.onTestNetworkProxy) { diff --git a/apps/desktop/src/renderer/error-boundary.tsx b/apps/desktop/src/renderer/error-boundary.tsx index ae6ed59f64..c66800c14f 100644 --- a/apps/desktop/src/renderer/error-boundary.tsx +++ b/apps/desktop/src/renderer/error-boundary.tsx @@ -82,7 +82,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo try { const diagnostics = window.maka?.diagnostics; if (diagnostics) { - const result = await diagnostics.copyErrorReport({ + const result = await diagnostics.copyReport({ surface: 'renderer_crash', title: `${error.name}: ${error.message}`, details: formatRendererErrorDetails(error, errorInfo), diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 603aec2027..7ea0033e8c 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -165,7 +165,7 @@ export type SettingsPreferencesCopy = { privacyTitle: string; privacyPoints: readonly string[]; copying: string; - copyEnvironment: string; + copyDiagnostics: string; copyHelp: string; keyboardShortcuts: string; keyboardShortcutsHelp: string; @@ -239,7 +239,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { proxy: '代理服务器', proxyHelp: '为 AI 模型请求配置网络代理', enableProxy: '启用代理服务器', saveNetworkFailed: '保存网络设置失败', proxyProtocol: '代理协议', serverAddress: '服务器地址', port: '端口', proxyAuth: '代理认证', proxyAuthHelp: '需要用户名和密码时开启。', enableProxyAuth: '启用代理认证', username: '用户名', password: '密码', bypassList: '代理白名单', bypassHelp: '这些域名将绕过代理直连,多个用逗号分隔。', autoBypass: (count) => `已自动添加 ${count} 个域名。代理仅作用于 AI 模型请求。`, testing: '测试中…', testCurrent: '测试当前配置', proxyReachable: '代理可达', proxyTestFailed: '代理测试失败', proxyTestError: '代理测试出错', }, about: { - loadFailed: '载入关于信息失败', loading: '正在加载关于页', unavailable: '无法载入关于信息', copied: '已复制环境信息', pasteHint: '可直接粘贴到问题报告', copyFailed: '复制失败', clipboardUnavailable: '剪贴板不可用或被系统拒绝。', devBuild: '本地开发版', packagedBuild: '正式版', subtitle: '本地优先的 AI 助手 · 桌面端运行环境', privacyLabel: '隐私与安全', privacyTitle: '本地优先 · 隐私默认', privacyPoints: ['所有任务、设置、凭据和 Skill 指令文件都保留在本机工作区。', '模型密钥保存在本机凭据文件内;订阅账号令牌使用系统安全存储。', 'Maka 不发送使用遥测;只在你显式启用时与所选模型供应商通信。', '高风险工具操作需要在任务内明示授权。', '每个任务都会在本机保留消息、工具调用、权限决策与模式变更记录。'], copying: '复制中…', copyEnvironment: '复制环境信息', copyHelp: '复制当前版本与平台信息以便定位问题;内容不包含工作区路径。', keyboardShortcuts: '键盘快捷键', keyboardShortcutsHelp: 'Maka 支持的全部快捷键一览。', keyboardShortcutsOpen: '查看', reportIssueLabel: '报告问题', + 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: '软件更新', checkForUpdates: '检查更新', checkingForUpdates: '检查中…', @@ -289,7 +289,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', saveShell: 'Save shell setting', savingShell: 'Saving…', shellSaved: 'Saved', saveShellFailed: 'Could not save shell setting', shellExecutableRejected: 'The current Runtime Host could not run that path as GNU Bash. Check that the Host runs Windows, the path exists, and the file is named bash.exe.', }, about: { - loadFailed: 'Could not load About information', loading: 'Loading About', unavailable: 'About information is unavailable', copied: 'Environment info copied', pasteHint: 'Paste it directly 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…', copyEnvironment: 'Copy environment info', copyHelp: 'Copy version and platform details to help diagnose an issue. The workspace path is excluded.', keyboardShortcuts: 'Keyboard shortcuts', keyboardShortcutsHelp: 'Every shortcut Maka responds to.', keyboardShortcutsOpen: 'View', reportIssueLabel: 'Report an issue', + 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', checkForUpdates: 'Check for updates', checkingForUpdates: 'Checking…', diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 342143bd67..2bc80bdd5d 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -33,7 +33,7 @@ export const STATIC_COMMAND_IDS = [ 'diag:copy-today-daily-review', 'diag:paste-today-daily-review', 'diag:save-today-daily-review', - 'diag:copy-env-summary', + 'diag:copy-diagnostics', 'diag:test-network-proxy', 'diag:open-local-memory', ] as const; @@ -102,15 +102,18 @@ const STATIC_COMMAND_KEYWORDS: Record = { '文件', '导出', ], - 'diag:copy-env-summary': [ + 'diag:copy-diagnostics': [ 'env', 'environment', 'version', + 'diagnostics', + 'logs', 'about', 'bug', 'report', '环境', '版本', + '日志', '关于', '诊断', '汇报', @@ -221,7 +224,8 @@ type ShellCopy = { reviewSaveFallback: string; pasteFailedTitle: string; reviewUnavailable: string; - environmentCopiedTitle: string; + diagnosticsCopiedTitle: string; + diagnosticsCopiedDescription: string; clipboardDenied: string; networkPassedTitle: string; networkFailedTitle: string; @@ -559,9 +563,9 @@ const ZH_STATIC_COMMANDS: Record = { hint: '用系统保存对话框', group: '诊断', }, - 'diag:copy-env-summary': { - label: '复制环境信息', - hint: 'Markdown · bug report 友好', + 'diag:copy-diagnostics': { + label: '复制诊断信息', + hint: '脱敏日志 · 仅写入剪贴板', group: '诊断', }, 'diag:test-network-proxy': { @@ -655,9 +659,9 @@ const EN_STATIC_COMMANDS: Record = { hint: 'Use the system save dialog', group: 'Diagnostics', }, - 'diag:copy-env-summary': { - label: 'Copy environment information', - hint: 'Markdown · ready for bug reports', + 'diag:copy-diagnostics': { + label: 'Copy diagnostics', + hint: 'Redacted logs · clipboard only', group: 'Diagnostics', }, 'diag:test-network-proxy': { @@ -833,7 +837,8 @@ const SHELL_COPY_BY_LOCALE = { reviewSaveFallback: '保存每日回顾失败,请稍后重试。', pasteFailedTitle: '粘贴失败', reviewUnavailable: '今日回顾暂时不可用,请稍后重试。', - environmentCopiedTitle: '已复制环境信息', + diagnosticsCopiedTitle: '已复制诊断信息', + diagnosticsCopiedDescription: '检查内容后,可直接粘贴到问题报告', clipboardDenied: '剪贴板不可用或被系统拒绝', networkPassedTitle: '网络代理测试通过', networkFailedTitle: '网络代理测试失败', @@ -1333,7 +1338,8 @@ const SHELL_COPY_BY_LOCALE = { reviewSaveFallback: 'The Daily Review could not be saved. Try again later.', pasteFailedTitle: 'Paste failed', reviewUnavailable: "Today's review is temporarily unavailable. Try again later.", - environmentCopiedTitle: 'Environment information copied', + diagnosticsCopiedTitle: 'Diagnostics copied', + diagnosticsCopiedDescription: 'Review the contents, then paste them into the issue report', clipboardDenied: 'The clipboard is unavailable or was denied', networkPassedTitle: 'Network proxy test passed', networkFailedTitle: 'Network proxy test failed', diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index 75a7d58dfd..d48021a29f 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -21,14 +21,7 @@ import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; type AppInfo = Awaited>; -const PLATFORM_LABEL: Record = { - darwin: 'macOS', - win32: 'Windows', - linux: 'Linux', -}; - -/** Where 复制环境信息 is meant to be pasted (owner msg `36501869`). */ -const ISSUE_TRACKER_URL = 'https://github.com/maka-agent/maka-agent/issues'; +const ISSUE_TRACKER_URL = 'https://github.com/apache/maka/issues'; export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const locale = useUiLocale(); @@ -36,14 +29,14 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { const sharedCopy = getSettingsSharedCopy(locale); const [info, setInfo] = useState(null); const [infoError, setInfoError] = useState(null); - const [copyingEnvSummary, setCopyingEnvSummary] = useState(false); + const [copyingDiagnostics, setCopyingDiagnostics] = useState(false); const [updateStatus, setUpdateStatus] = useState(null); const [checkingUpdate, setCheckingUpdate] = useState(false); - const envSummaryCopyGuard = useActionGuard<'copy'>(); + const diagnosticCopyGuard = useActionGuard<'copy'>(); const checkUpdateGuard = useActionGuard<'check'>(); const aboutPageMountedRef = useMountedRef(); const toast = useToast(); - const envSummaryHelpId = useId(); + const diagnosticsHelpId = useId(); const updateHelpId = useId(); useEffect(() => { @@ -84,6 +77,45 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) { }; }, []); + async function copyDiagnostics() { + if (!diagnosticCopyGuard.begin('copy')) return; + setCopyingDiagnostics(true); + try { + const result = await window.maka.diagnostics.copyReport({ + surface: 'manual', + rendererUserAgent: navigator.userAgent, + rendererLocale: navigator.language, + }); + if (!result.ok) throw new Error('Desktop diagnostic clipboard write failed'); + if (aboutPageMountedRef.current) toast.success(copy.copied, copy.pasteHint); + } catch { + if (aboutPageMountedRef.current) { + toast.error(copy.copyFailed, copy.clipboardUnavailable); + } + } finally { + diagnosticCopyGuard.finish(); + if (aboutPageMountedRef.current) setCopyingDiagnostics(false); + } + } + + const diagnosticActions = ( + + +