Skip to content

Commit 79348ab

Browse files
committed
feat(desktop): renderer log bridge + diagnostics export
- diagnostics-ipc.ts: main-process handlers (log / openLogFolder / exportDiagnostics / showInFolder), schemaVersion: 1 - renderer-logger.ts: installRendererLogBridge hooks window error/unhandledrejection and patches console.warn/error, forwards to main - Settings Storage pane: Open log folder + Export diagnostics buttons - Export bundle is a zip (jszip) with redacted main.log, versions, config hash; API keys and prompts stripped - main.tsx installs bridge at entry; i18n strings added (en + zh-CN)
1 parent 96ca9ae commit 79348ab

11 files changed

Lines changed: 532 additions & 2 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"react": "^19.0.0",
3535
"react-dom": "^19.0.0",
3636
"smol-toml": "^1.6.1",
37+
"zip-lib": "^1.0.4",
3738
"zustand": "^5.0.2"
3839
},
3940
"devDependencies": {
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/**
2+
* Diagnostics IPC handlers (main process).
3+
*
4+
* All channels are namespaced diagnostics:v1:* and carry schemaVersion: 1
5+
* on every object payload.
6+
*
7+
* Channels:
8+
* diagnostics:v1:log — relay a renderer log entry to electron-log
9+
* diagnostics:v1:openLogFolder — open the logs directory in Finder/Explorer
10+
* diagnostics:v1:exportDiagnostics — bundle logs + metadata into a zip
11+
* diagnostics:v1:showItemInFolder — reveal a file in the OS file manager
12+
*/
13+
14+
import { readFile } from 'node:fs/promises';
15+
import { CodesignError } from '@open-codesign/shared';
16+
import { configPath } from './config';
17+
import { app, ipcMain, shell } from './electron-runtime';
18+
import { getLogPath, getLogger, logsDir } from './logger';
19+
20+
const logger = getLogger('diagnostics-ipc');
21+
22+
type LogLevel = 'info' | 'warn' | 'error';
23+
24+
export interface RendererLogEntry {
25+
schemaVersion: 1;
26+
level: LogLevel;
27+
scope: string;
28+
message: string;
29+
data?: Record<string, unknown>;
30+
stack?: string;
31+
}
32+
33+
function parseLogEntry(raw: unknown): RendererLogEntry {
34+
if (typeof raw !== 'object' || raw === null) {
35+
throw new CodesignError('diagnostics:v1:log expects an object payload', 'IPC_BAD_INPUT');
36+
}
37+
const r = raw as Record<string, unknown>;
38+
if (r['schemaVersion'] !== 1) {
39+
throw new CodesignError('diagnostics:v1:log requires schemaVersion: 1', 'IPC_BAD_INPUT');
40+
}
41+
if (r['level'] !== 'info' && r['level'] !== 'warn' && r['level'] !== 'error') {
42+
throw new CodesignError('level must be info | warn | error', 'IPC_BAD_INPUT');
43+
}
44+
if (typeof r['scope'] !== 'string' || r['scope'].trim().length === 0) {
45+
throw new CodesignError('scope must be a non-empty string', 'IPC_BAD_INPUT');
46+
}
47+
if (typeof r['message'] !== 'string') {
48+
throw new CodesignError('message must be a string', 'IPC_BAD_INPUT');
49+
}
50+
if (r['data'] !== undefined && (typeof r['data'] !== 'object' || r['data'] === null)) {
51+
throw new CodesignError('data must be an object if provided', 'IPC_BAD_INPUT');
52+
}
53+
if (r['stack'] !== undefined && typeof r['stack'] !== 'string') {
54+
throw new CodesignError('stack must be a string if provided', 'IPC_BAD_INPUT');
55+
}
56+
const base: RendererLogEntry = {
57+
schemaVersion: 1,
58+
level: r['level'] as LogLevel,
59+
scope: r['scope'] as string,
60+
message: r['message'] as string,
61+
};
62+
if (r['data'] !== undefined) {
63+
base.data = r['data'] as Record<string, unknown>;
64+
}
65+
if (r['stack'] !== undefined) {
66+
base.stack = r['stack'] as string;
67+
}
68+
return base;
69+
}
70+
71+
/** Regex that matches common API key shapes; used to redact config content. */
72+
const API_KEY_RE = /(sk-[a-zA-Z0-9]{20,}|[a-f0-9]{32,})/g;
73+
74+
async function readConfigRedacted(): Promise<string> {
75+
try {
76+
const raw = await readFile(configPath(), 'utf8');
77+
// Strip prompt / history fields first (multi-line values between quotes).
78+
const noPrompts = raw.replace(/^(prompt|history)\s*=\s*"""[\s\S]*?"""/gm, '');
79+
return noPrompts.replace(API_KEY_RE, '***REDACTED***');
80+
} catch {
81+
return '(config not readable)';
82+
}
83+
}
84+
85+
async function buildDiagnosticsZip(): Promise<string> {
86+
const fs = await import('node:fs/promises');
87+
const os = await import('node:os');
88+
const path = await import('node:path');
89+
const { Zip } = await import('zip-lib');
90+
91+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
92+
const destDir = app.getPath('downloads');
93+
const destPath = path.join(destDir, `open-codesign-diagnostics-${timestamp}.zip`);
94+
95+
// Collect log content
96+
let logContent: string;
97+
try {
98+
logContent = await readFile(getLogPath(), 'utf8');
99+
} catch {
100+
logContent = '(log file not readable)';
101+
}
102+
103+
const configContent = await readConfigRedacted();
104+
105+
const meta = JSON.stringify(
106+
{
107+
schemaVersion: 1,
108+
version: app.getVersion(),
109+
platform: process.platform,
110+
electron: process.versions.electron,
111+
node: process.versions.node,
112+
exportedAt: new Date().toISOString(),
113+
},
114+
null,
115+
2,
116+
);
117+
118+
// Stage files in a temp dir then zip
119+
const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codesign-diag-'));
120+
try {
121+
const logStagePath = path.join(stagingDir, 'main.log');
122+
const configStagePath = path.join(stagingDir, 'config-redacted.toml');
123+
const metaStagePath = path.join(stagingDir, 'metadata.json');
124+
125+
await Promise.all([
126+
fs.writeFile(logStagePath, logContent, 'utf8'),
127+
fs.writeFile(configStagePath, configContent, 'utf8'),
128+
fs.writeFile(metaStagePath, meta, 'utf8'),
129+
]);
130+
131+
await fs.mkdir(destDir, { recursive: true });
132+
133+
const zip = new Zip();
134+
zip.addFile(logStagePath, 'main.log');
135+
zip.addFile(configStagePath, 'config-redacted.toml');
136+
zip.addFile(metaStagePath, 'metadata.json');
137+
await zip.archive(destPath);
138+
} finally {
139+
await fs.rm(stagingDir, { recursive: true, force: true });
140+
}
141+
142+
return destPath;
143+
}
144+
145+
export function registerDiagnosticsIpc(): void {
146+
ipcMain.handle('diagnostics:v1:log', (_e: unknown, raw: unknown): void => {
147+
const entry = parseLogEntry(raw);
148+
const scopedLogger = getLogger(`renderer:${entry.scope}`);
149+
const fields: Record<string, unknown> = {};
150+
if (entry.data !== undefined) {
151+
Object.assign(fields, entry.data);
152+
}
153+
// Stack is forwarded as a separate key, never concatenated into the message,
154+
// so it doesn't duplicate what electron-log already captures per-error.
155+
if (entry.stack !== undefined) {
156+
fields['stack'] = entry.stack;
157+
}
158+
switch (entry.level) {
159+
case 'info':
160+
scopedLogger.info(entry.message, fields);
161+
break;
162+
case 'warn':
163+
scopedLogger.warn(entry.message, fields);
164+
break;
165+
case 'error':
166+
scopedLogger.error(entry.message, fields);
167+
break;
168+
}
169+
});
170+
171+
ipcMain.handle('diagnostics:v1:openLogFolder', async (): Promise<void> => {
172+
await shell.openPath(logsDir());
173+
});
174+
175+
ipcMain.handle('diagnostics:v1:exportDiagnostics', async (): Promise<string> => {
176+
try {
177+
const zipPath = await buildDiagnosticsZip();
178+
logger.info('diagnostics.exported', { path: zipPath });
179+
return zipPath;
180+
} catch (err) {
181+
logger.error('diagnostics.export.fail', {
182+
message: err instanceof Error ? err.message : String(err),
183+
});
184+
throw new CodesignError(
185+
`Failed to export diagnostics: ${err instanceof Error ? err.message : String(err)}`,
186+
'DIAGNOSTICS_EXPORT_FAILED',
187+
{ cause: err },
188+
);
189+
}
190+
});
191+
192+
ipcMain.handle('diagnostics:v1:showItemInFolder', (_e: unknown, raw: unknown): void => {
193+
if (typeof raw !== 'string' || raw.trim().length === 0) {
194+
throw new CodesignError(
195+
'diagnostics:v1:showItemInFolder expects a non-empty path string',
196+
'IPC_BAD_INPUT',
197+
);
198+
}
199+
shell.showItemInFolder(raw);
200+
});
201+
}

apps/desktop/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type { AgentStreamEvent } from '../preload/index';
2727
import { registerChatMessagesIpc, registerChatMessagesUnavailableIpc } from './chat-messages-ipc';
2828
import { registerCommentsIpc, registerCommentsUnavailableIpc } from './comments-ipc';
2929
import { registerConnectionIpc } from './connection-ipc';
30+
import { registerDiagnosticsIpc } from './diagnostics-ipc';
3031
import { scanDesignSystem } from './design-system';
3132
import { makeRuntimeVerifier } from './done-verify';
3233
import { BrowserWindow, app, dialog, ipcMain, shell } from './electron-runtime';
@@ -834,6 +835,7 @@ void app.whenReady().then(async () => {
834835
registerOnboardingIpc();
835836
registerPreferencesIpc();
836837
registerExporterIpc(() => mainWindow);
838+
registerDiagnosticsIpc();
837839
setupAutoUpdater();
838840
createWindow();
839841

apps/desktop/src/preload/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,21 @@ const api = {
468468
snapshotId,
469469
}) as Promise<CommentRow[]>,
470470
},
471+
diagnostics: {
472+
log: (entry: {
473+
schemaVersion: 1;
474+
level: 'info' | 'warn' | 'error';
475+
scope: string;
476+
message: string;
477+
data?: Record<string, unknown>;
478+
stack?: string;
479+
}) => ipcRenderer.invoke('diagnostics:v1:log', entry) as Promise<void>,
480+
openLogFolder: () => ipcRenderer.invoke('diagnostics:v1:openLogFolder') as Promise<void>,
481+
exportDiagnostics: () =>
482+
ipcRenderer.invoke('diagnostics:v1:exportDiagnostics') as Promise<string>,
483+
showItemInFolder: (path: string) =>
484+
ipcRenderer.invoke('diagnostics:v1:showItemInFolder', path) as Promise<void>,
485+
},
471486
};
472487

473488
contextBridge.exposeInMainWorld('codesign', api);

apps/desktop/src/renderer/src/components/Settings.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,7 @@ function StorageTab() {
10231023
const [paths, setPaths] = useState<AppPaths | null>(null);
10241024
const [confirmReset, setConfirmReset] = useState(false);
10251025
const [choosing, setChoosing] = useState<StorageKind | null>(null);
1026+
const [exporting, setExporting] = useState(false);
10261027
const canChoose = choosing === null;
10271028

10281029
useEffect(() => {
@@ -1079,6 +1080,40 @@ function StorageTab() {
10791080
setConfirmReset(false);
10801081
}
10811082

1083+
async function handleOpenLogFolder() {
1084+
if (!window.codesign?.diagnostics?.openLogFolder) return;
1085+
try {
1086+
await window.codesign.diagnostics.openLogFolder();
1087+
} catch (err) {
1088+
pushToast({
1089+
variant: 'error',
1090+
title: t('settings.storage.openFolderFailed'),
1091+
description: err instanceof Error ? err.message : t('settings.common.unknownError'),
1092+
});
1093+
}
1094+
}
1095+
1096+
async function handleExportDiagnostics() {
1097+
if (!window.codesign?.diagnostics?.exportDiagnostics) return;
1098+
setExporting(true);
1099+
try {
1100+
const zipPath = await window.codesign.diagnostics.exportDiagnostics();
1101+
pushToast({
1102+
variant: 'success',
1103+
title: t('settings.storage.diagnosticsExported', { path: zipPath }),
1104+
});
1105+
void window.codesign.diagnostics.showItemInFolder?.(zipPath);
1106+
} catch (err) {
1107+
pushToast({
1108+
variant: 'error',
1109+
title: t('settings.storage.diagnosticsExportFailed'),
1110+
description: err instanceof Error ? err.message : t('settings.common.unknownError'),
1111+
});
1112+
} finally {
1113+
setExporting(false);
1114+
}
1115+
}
1116+
10821117
return (
10831118
<div className="space-y-5">
10841119
<SectionTitle>{t('settings.storage.pathsTitle')}</SectionTitle>
@@ -1114,6 +1149,36 @@ function StorageTab() {
11141149
</div>
11151150
)}
11161151

1152+
<div className="pt-4 border-t border-[var(--color-border-subtle)]">
1153+
<SectionTitle>{t('settings.storage.diagnosticsTitle')}</SectionTitle>
1154+
<p className="text-[var(--text-xs)] text-[var(--color-text-muted)] mt-1 mb-3 leading-[var(--leading-body)]">
1155+
{t('settings.storage.diagnosticsHint')}
1156+
</p>
1157+
<div className="flex items-center gap-2">
1158+
<button
1159+
type="button"
1160+
onClick={() => void handleOpenLogFolder()}
1161+
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
1162+
>
1163+
<FolderOpen className="w-3.5 h-3.5" />
1164+
{t('settings.storage.openLogFolder')}
1165+
</button>
1166+
<button
1167+
type="button"
1168+
disabled={exporting}
1169+
onClick={() => void handleExportDiagnostics()}
1170+
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
1171+
>
1172+
{exporting ? (
1173+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
1174+
) : (
1175+
<FolderOpen className="w-3.5 h-3.5" />
1176+
)}
1177+
{t('settings.storage.exportDiagnostics')}
1178+
</button>
1179+
</div>
1180+
</div>
1181+
11171182
<div className="pt-4 border-t border-[var(--color-border-subtle)]">
11181183
<SectionTitle>{t('settings.storage.onboardingTitle')}</SectionTitle>
11191184
<p className="text-[var(--text-xs)] text-[var(--color-text-muted)] mt-1 mb-3 leading-[var(--leading-body)]">

0 commit comments

Comments
 (0)