feat: add desktop bug report collection

This commit is contained in:
2026-05-31 21:55:29 +08:00
parent 0a9dcec70f
commit e490a267ff
27 changed files with 2938 additions and 3 deletions
+18
View File
@@ -33,6 +33,11 @@ import {
type DesktopAppSettingKey,
} from './app-settings'
import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version'
import {
initDesktopBugReporter,
submitManualDesktopBugReport,
syncDesktopBugReporterSession,
} from './bug-reporter'
import {
defaultDesktopUpdateChannel,
startDesktopClientUpdate,
@@ -131,6 +136,7 @@ function silencePackagedConsole(): void {
}
silencePackagedConsole()
initDesktopBugReporter()
if (isDevelopmentRuntime) {
installObservedGlobalFetch()
}
@@ -957,6 +963,17 @@ function registerBridgeHandlers(): void {
ipcMain.handle('desktop:runtime-account-snapshot', (_event, accountId: string) =>
captureRuntimeAccountSnapshot(accountId),
)
safeHandle(
'desktop:submit-bug-report',
async (
_event,
input: {
title?: string
description?: string
severity?: string
},
) => submitManualDesktopBugReport(input ?? {}),
)
safeHandle('desktop:bind-publish-account', async (_event, platformId: string) => {
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo
noteRuntimeAccountBound(account)
@@ -1052,6 +1069,7 @@ function registerBridgeHandlers(): void {
'desktop:runtime-session-sync',
(_event, session: DesktopRuntimeSessionSyncRequest | null) => {
syncRuntimeSession(session)
syncDesktopBugReporterSession(session)
return null
},
)
@@ -0,0 +1,306 @@
import { readdirSync, statSync } from 'node:fs'
import { appendFile, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { DesktopRuntimeSessionSyncRequest } from '@geo/shared-types'
import { crashReporter } from 'electron'
import { app } from 'electron/main'
import { normalizeDesktopApiBaseURL } from '../shared/api-base-url'
import { getDesktopAppVersion, getDesktopReleaseChannel } from './app-version'
import { resolveDesktopDeviceInfo } from './device-id'
import { createRuntimeSnapshot } from './runtime-snapshot'
const BUG_REPORT_LOG_FILE = 'desktop-bug-reporter.jsonl'
const MAX_LOG_TAIL_BYTES = 96 * 1024
const MAX_RUNTIME_SNAPSHOT_BYTES = 180 * 1024
const CRASH_EXTRA_KEYS = [
'app_version',
'channel',
'device_name',
'platform',
'cpu_arch',
'client_id',
'tenant_id',
'workspace_id',
'user_id',
]
interface BugReporterSession {
baseURL: string
clientToken: string | null
tenantID: number | null
workspaceID: number | null
userID: number | null
clientID: string | null
deviceName: string | null
platform: string | null
osArch: string | null
appVersion: string | null
channel: string | null
}
export interface ManualDesktopBugReportInput {
title?: string
description?: string
severity?: string
}
let currentSession: BugReporterSession = {
baseURL: normalizeDesktopApiBaseURL(process.env.DESKTOP_API_BASE_URL),
clientToken: null,
tenantID: null,
workspaceID: null,
userID: null,
clientID: null,
deviceName: null,
platform: process.platform,
osArch: process.arch,
appVersion: getDesktopAppVersion(),
channel: getDesktopReleaseChannel(),
}
let crashReporterStarted = false
export function initDesktopBugReporter(): void {
if (crashReporterStarted) {
return
}
crashReporterStarted = true
const device = resolveDesktopDeviceInfo()
currentSession = {
...currentSession,
deviceName: device.device_name,
platform: device.os,
osArch: device.cpu_arch,
}
crashReporter.start({
submitURL: buildBugReportURL(currentSession.baseURL, false),
productName: 'ShengxinPush',
uploadToServer: true,
rateLimit: true,
compress: true,
globalExtra: buildStaticCrashReporterExtra(),
})
}
export function syncDesktopBugReporterSession(
session: DesktopRuntimeSessionSyncRequest | null,
): void {
const client = session?.desktop_client ?? null
currentSession = {
...currentSession,
baseURL: normalizeDesktopApiBaseURL(session?.api_base_url ?? process.env.DESKTOP_API_BASE_URL),
clientToken: session?.client_token ?? null,
tenantID: normalizePositiveNumber(client?.tenant_id),
workspaceID: normalizePositiveNumber(client?.workspace_id),
userID: normalizePositiveNumber(client?.user_id),
clientID: client?.id ?? null,
deviceName: client?.device_name ?? currentSession.deviceName,
platform: client?.os ?? currentSession.platform,
osArch: client?.cpu_arch ?? currentSession.osArch,
appVersion: client?.client_version ?? getDesktopAppVersion(),
channel: client?.channel ?? getDesktopReleaseChannel(),
}
updateCrashReporterParameters()
}
export async function submitManualDesktopBugReport(
input: ManualDesktopBugReportInput,
): Promise<{ id: string; dump_object_key?: string | null }> {
const title = String(input.title ?? '').trim()
const description = String(input.description ?? '').trim()
if (!title) {
throw new Error('bug_report_title_required')
}
if (!description) {
throw new Error('bug_report_description_required')
}
const form = new FormData()
appendBaseFields(form, 'bug')
form.set('title', limitText(title, 200))
form.set('description', limitText(description, 4000))
form.set('severity', normalizeSeverity(input.severity))
form.set('runtime_snapshot', await safeRuntimeSnapshotJSON())
form.set('app_log_tail', await readLogTail())
const url = buildBugReportURL(currentSession.baseURL, Boolean(currentSession.clientToken))
const headers: Record<string, string> = {}
if (currentSession.clientToken) {
headers.Authorization = `Bearer ${currentSession.clientToken}`
}
const response = await fetch(url, {
method: 'POST',
headers,
body: form,
})
const payload = (await response.json().catch(() => null)) as {
code?: number
message?: string
detail?: string
data?: { id?: string; dump_object_key?: string | null }
} | null
if (!response.ok || payload?.code !== 0) {
throw new Error(
payload?.detail || payload?.message || `bug_report_upload_failed_${response.status}`,
)
}
const id = payload.data?.id
if (!id) {
throw new Error('bug_report_upload_invalid_response')
}
await appendBugReporterLog({ action: 'manual_bug_report_uploaded', id })
return {
id,
dump_object_key: payload.data?.dump_object_key ?? null,
}
}
function buildBugReportURL(baseURL: string, authed: boolean): string {
const path = authed ? '/api/desktop/clients/bug-reports' : '/api/desktop/bug-reports'
return new URL(path, normalizeDesktopApiBaseURL(baseURL)).toString()
}
function appendBaseFields(form: FormData, reportType: 'crash' | 'bug'): void {
const fields = buildCrashReporterExtra()
form.set('report_type', reportType)
form.set('occurred_at', new Date().toISOString())
for (const [key, value] of Object.entries(fields)) {
if (value) {
form.set(key, value)
}
}
}
function buildCrashReporterExtra(): Record<string, string> {
return {
...buildStaticCrashReporterExtra(),
client_id: limitExtra(currentSession.clientID ?? ''),
tenant_id: limitExtra(currentSession.tenantID ? String(currentSession.tenantID) : ''),
workspace_id: limitExtra(currentSession.workspaceID ? String(currentSession.workspaceID) : ''),
user_id: limitExtra(currentSession.userID ? String(currentSession.userID) : ''),
}
}
function buildStaticCrashReporterExtra(): Record<string, string> {
return {
app_version: limitExtra(currentSession.appVersion ?? getDesktopAppVersion()),
channel: limitExtra(currentSession.channel ?? getDesktopReleaseChannel()),
device_name: limitExtra(currentSession.deviceName ?? ''),
platform: limitExtra(currentSession.platform ?? process.platform),
cpu_arch: limitExtra(currentSession.osArch ?? process.arch),
}
}
function updateCrashReporterParameters(): void {
if (!crashReporterStarted) {
return
}
for (const key of CRASH_EXTRA_KEYS) {
crashReporter.removeExtraParameter(key)
}
for (const [key, value] of Object.entries(buildCrashReporterExtra())) {
if (value) {
crashReporter.addExtraParameter(key, value)
}
}
}
async function safeRuntimeSnapshotJSON(): Promise<string> {
try {
const raw = JSON.stringify(createRuntimeSnapshot())
return raw.length > MAX_RUNTIME_SNAPSHOT_BYTES ? raw.slice(0, MAX_RUNTIME_SNAPSHOT_BYTES) : raw
} catch {
return '{}'
}
}
async function readLogTail(): Promise<string> {
const candidates = resolveLogCandidates()
for (const candidate of candidates) {
try {
const stat = statSync(candidate)
if (!stat.isFile()) {
continue
}
const content = await readFile(candidate, 'utf8')
return content.slice(-MAX_LOG_TAIL_BYTES)
} catch {
// Try the next known log file.
}
}
return ''
}
function resolveLogCandidates(): string[] {
const dirs = [app.getPath('logs'), app.getPath('userData')]
const candidates: string[] = []
for (const dir of dirs) {
try {
for (const name of readdirSync(dir)) {
const lower = name.toLowerCase()
if (lower.endsWith('.log') || lower.endsWith('.jsonl')) {
candidates.push(join(dir, name))
}
}
} catch {
// Logs may not exist in dev or first-run installs.
}
}
return candidates.sort((left, right) => {
try {
return statSync(right).mtimeMs - statSync(left).mtimeMs
} catch {
return 0
}
})
}
function normalizeSeverity(value: string | undefined): string {
switch (
String(value ?? '')
.trim()
.toLowerCase()
) {
case 'low':
case 'medium':
case 'high':
case 'critical':
return String(value).trim().toLowerCase()
default:
return 'medium'
}
}
function normalizePositiveNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
function limitExtra(value: string): string {
return limitText(value, 120)
}
function limitText(value: string, max: number): string {
const trimmed = value.trim()
if (!trimmed) {
return ''
}
const runes = [...trimmed]
return runes.length > max ? runes.slice(0, max).join('') : trimmed
}
async function appendBugReporterLog(event: Record<string, unknown>): Promise<void> {
try {
await appendFile(
join(app.getPath('userData'), BUG_REPORT_LOG_FILE),
`${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`,
'utf8',
)
} catch {
// Best-effort local breadcrumbs only.
}
}