Merge remote-tracking branch 'origin/master' into codex/desktop-zoom-shortcuts

This commit is contained in:
Qiuner
2026-08-18 00:43:00 +08:00
8 changed files with 311 additions and 3 deletions
+84
View File
@@ -0,0 +1,84 @@
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
/** Minimal Electron crash reporter surface used before the app is ready. */
export interface DesktopCrashReporter {
start(options: {
readonly productName: string
readonly uploadToServer: false
readonly globalExtra: Record<string, string>
}): void
}
export interface DesktopCrashMetadata {
readonly productName: string
readonly version: string
readonly platform: string
readonly arch: string
}
/** Start local-only Crashpad collection for native main and child process failures. */
export function startDesktopCrashReporting(
reporter: DesktopCrashReporter,
metadata: DesktopCrashMetadata,
): void {
reporter.start({
productName: metadata.productName,
uploadToServer: false,
globalExtra: {
appVersion: metadata.version,
platform: metadata.platform,
arch: metadata.arch,
},
})
}
/** Identity persisted while one desktop process is active. */
export interface DesktopRunRecord {
readonly startedAt: string
readonly pid: number
readonly version: string
}
export interface UnreadableDesktopRun {
readonly unreadable: true
}
/** Active run marker and evidence recovered from the previous launch. */
export interface DesktopRun {
readonly previousRun: DesktopRunRecord | UnreadableDesktopRun | undefined
/** Remove this process's active marker before a controlled exit. */
markClean(): void
}
function readPreviousRun(statePath: string): DesktopRunRecord | UnreadableDesktopRun | undefined {
if (!existsSync(statePath)) return undefined
try {
const value: unknown = JSON.parse(readFileSync(statePath, 'utf8'))
if (typeof value !== 'object' || value === null) return { unreadable: true }
const record = value as Partial<DesktopRunRecord>
if (typeof record.startedAt !== 'string'
|| typeof record.pid !== 'number'
|| typeof record.version !== 'string') return { unreadable: true }
return { startedAt: record.startedAt, pid: record.pid, version: record.version }
} catch (cause) {
if (cause instanceof SyntaxError) return { unreadable: true }
throw cause
}
}
/** Persist this launch and return a marker left behind by an unclean exit. */
export function beginDesktopRun(statePath: string, currentRun: DesktopRunRecord): DesktopRun {
const previousRun = readPreviousRun(statePath)
mkdirSync(dirname(statePath), { recursive: true })
writeFileSync(statePath, `${JSON.stringify(currentRun)}\n`, 'utf8')
let clean = false
return {
previousRun,
markClean() {
if (clean) return
clean = true
unlinkSync(statePath)
},
}
}
+38
View File
@@ -15,6 +15,44 @@ export interface DesktopUncaughtExceptionProcess {
off(event: 'uncaughtException', listener: (error: Error) => void): unknown
}
export interface DesktopChildProcessDetails {
readonly type: string
readonly reason: string
readonly exitCode: number
readonly serviceName?: string
readonly name?: string
}
/** Electron app events that expose native child process failures. */
export interface DesktopChildProcessSource {
on(event: 'child-process-gone', listener: (event: unknown, details: DesktopChildProcessDetails) => void): unknown
off(event: 'child-process-gone', listener: (event: unknown, details: DesktopChildProcessDetails) => void): unknown
}
/** Render signed Electron exit codes with their Windows NTSTATUS bit pattern. */
export function formatDesktopExitCode(exitCode: number): string {
return `${String(exitCode)} / 0x${(exitCode >>> 0).toString(16).padStart(8, '0')}`
}
/** Persist unexpected utility, GPU, and other Electron child process exits. */
export function installDesktopChildProcessLogging(
app: DesktopChildProcessSource,
logger: DesktopLogger,
): () => void {
const handler = (_event: unknown, details: DesktopChildProcessDetails): void => {
const identity = [
`type: ${details.type}`,
...(details.name === undefined ? [] : [`name: ${details.name}`]),
...(details.serviceName === undefined ? [] : [`service: ${details.serviceName}`]),
]
logger.error(
`dsh-plugin-desktop: child process gone (${identity.join(', ')}, reason: ${details.reason}, exitCode: ${formatDesktopExitCode(details.exitCode)})`,
)
}
app.on('child-process-gone', handler)
return () => { app.off('child-process-gone', handler) }
}
/** Persist the first uncaught exception before requesting a fatal exit. */
export function installDesktopUncaughtExceptionLogging(
proc: DesktopUncaughtExceptionProcess,
+2 -2
View File
@@ -32,7 +32,7 @@ import type {
DesktopUpdateAdapter,
} from './runtime.ts'
import type { RendererBootReport } from './renderer-boot-contract.ts'
import type { DesktopLogger } from './desktop-logger.ts'
import { formatDesktopExitCode, type DesktopLogger } from './desktop-logger.ts'
import { exportDiagnosticsZip } from './diagnostic-export.ts'
import { prepareTrayIcon } from './tray-icons.ts'
import {
@@ -619,7 +619,7 @@ export class ElectronDesktopRuntime implements DesktopRuntime {
window.webContents.on('will-frame-navigate', navigate)
window.webContents.on('will-redirect', navigate)
window.webContents.on('render-process-gone', (_event, details) => {
this.logError(`dsh-plugin-desktop: renderer process gone (reason: ${details.reason}, exitCode: ${details.exitCode})`)
this.logError(`dsh-plugin-desktop: renderer process gone (reason: ${details.reason}, exitCode: ${formatDesktopExitCode(details.exitCode)})`)
})
window.webContents.on('did-fail-load', (_event, errorCode, errorDescription) => {
this.logError(`dsh-plugin-desktop: renderer failed to load (${errorCode}: ${errorDescription})`)
+44 -1
View File
@@ -1,6 +1,6 @@
/** DSH Desktop executable: minimal Electron bootstrap around the Host Cordis root. */
import { app } from 'electron'
import { app, crashReporter } from 'electron'
import type { Context } from '@deepseek-ai/cordis'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -20,9 +20,15 @@ import {
import { desktopProductVersion, ElectronDesktopRuntime } from './electron-runtime.ts'
import {
ElectronStderrLogger,
installDesktopChildProcessLogging,
installDesktopUncaughtExceptionLogging,
type DesktopLogger,
} from './desktop-logger.ts'
import {
beginDesktopRun,
startDesktopCrashReporting,
type DesktopRun,
} from './crash-evidence.ts'
import { FileExporter } from './file-exporter.ts'
import { DESKTOP_SETTINGS_NAMESPACE, type DesktopSettings } from './index.ts'
import { LogFileSink } from './log-files.ts'
@@ -122,6 +128,7 @@ async function start(): Promise<void> {
let shutdown: DesktopShutdown | undefined
let removeShutdownRequests: (() => void) | undefined
let removeUncaughtExceptionLogging: (() => void) | undefined
let removeChildProcessLogging: (() => void) | undefined
let disposeDshRuntime: (() => void) | undefined
let disposePnpmRuntime: (() => void) | undefined
let fileExporter: FileExporter | undefined
@@ -141,6 +148,36 @@ async function start(): Promise<void> {
logSink = undefined
}
const electronLogger = new ElectronStderrLogger(logSink)
try {
startDesktopCrashReporting(crashReporter, {
productName: PRODUCT_NAME,
version: desktopProductVersion(),
platform: process.platform,
arch: process.arch,
})
} catch (cause) {
electronLogger.error(`${BIN_NAME}: local crash reporting unavailable: ${cause instanceof Error ? cause.message : String(cause)}`)
}
let desktopRun: DesktopRun | undefined
try {
desktopRun = beginDesktopRun(
join(app.getPath('userData'), 'crash-evidence', 'active-run.json'),
{
startedAt: new Date().toISOString(),
pid: process.pid,
version: desktopProductVersion(),
},
)
const previousRun = desktopRun.previousRun
if (previousRun !== undefined) {
electronLogger.error('unreadable' in previousRun
? `${BIN_NAME}: previous desktop run did not shut down cleanly (active run marker unreadable)`
: `${BIN_NAME}: previous desktop run did not shut down cleanly (startedAt: ${previousRun.startedAt}, pid: ${String(previousRun.pid)}, version: ${previousRun.version})`)
}
} catch (cause) {
electronLogger.error(`${BIN_NAME}: active run tracking unavailable: ${cause instanceof Error ? cause.message : String(cause)}`)
}
removeChildProcessLogging = installDesktopChildProcessLogging(app, electronLogger)
const nativeExit = createDesktopExitCoordinator(
{
prepareToQuit: () => { runtime.prepareToQuit() },
@@ -150,6 +187,12 @@ async function start(): Promise<void> {
() => {
removeShutdownRequests?.()
removeUncaughtExceptionLogging?.()
removeChildProcessLogging?.()
try {
desktopRun?.markClean()
} catch (cause) {
electronLogger.error(`${BIN_NAME}: failed to clear active run marker: ${cause instanceof Error ? cause.message : String(cause)}`)
}
},
)
let restartRequested = false
@@ -0,0 +1,84 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { beginDesktopRun, startDesktopCrashReporting } from '../src/crash-evidence.ts'
describe('desktop crash evidence', () => {
it('starts Electron crash reporting without uploading dumps', () => {
const reporter = { start: vi.fn() }
startDesktopCrashReporting(reporter, {
productName: 'DSH Desktop',
version: '2.0.1',
platform: 'win32',
arch: 'x64',
})
expect(reporter.start).toHaveBeenCalledWith({
productName: 'DSH Desktop',
uploadToServer: false,
globalExtra: {
appVersion: '2.0.1',
platform: 'win32',
arch: 'x64',
},
})
})
it('reports the previous run when it did not shut down cleanly', () => {
const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json')
beginDesktopRun(statePath, {
startedAt: '2026-08-18T00:00:00.000Z',
pid: 41,
version: '2.0.1',
})
const next = beginDesktopRun(statePath, {
startedAt: '2026-08-18T00:01:00.000Z',
pid: 42,
version: '2.0.1',
})
expect(next.previousRun).toEqual({
startedAt: '2026-08-18T00:00:00.000Z',
pid: 41,
version: '2.0.1',
})
})
it('does not report a run that marked its shutdown clean', () => {
const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json')
const run = beginDesktopRun(statePath, {
startedAt: '2026-08-18T00:00:00.000Z',
pid: 41,
version: '2.0.1',
})
expect(() => {
run.markClean()
run.markClean()
}).not.toThrow()
const next = beginDesktopRun(statePath, {
startedAt: '2026-08-18T00:01:00.000Z',
pid: 42,
version: '2.0.1',
})
expect(next.previousRun).toBeUndefined()
})
it('reports an unreadable previous marker without blocking the next run', () => {
const statePath = join(mkdtempSync(join(tmpdir(), 'dsh-run-')), 'active-run.json')
writeFileSync(statePath, '{partial', 'utf8')
const run = beginDesktopRun(statePath, {
startedAt: '2026-08-18T00:01:00.000Z',
pid: 42,
version: '2.0.1',
})
expect(run.previousRun).toEqual({ unreadable: true })
expect(() => run.markClean()).not.toThrow()
})
})
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
ElectronStderrLogger,
installDesktopChildProcessLogging,
installDesktopUncaughtExceptionLogging,
} from '../src/desktop-logger.ts'
import { LogFileSink } from '../src/log-files.ts'
@@ -23,6 +24,26 @@ function sink(): { s: LogFileSink; dir: string } {
}
describe('ElectronStderrLogger', () => {
it('logs Electron child process crashes with the Windows exception code', () => {
const app = new EventEmitter()
const logger = { error: vi.fn(), errorCause: vi.fn() }
const remove = installDesktopChildProcessLogging(app, logger)
app.emit('child-process-gone', {}, {
type: 'Utility',
reason: 'crashed',
exitCode: -1073741819,
serviceName: 'network.mojom.NetworkService',
name: 'Network Service',
})
expect(logger.error).toHaveBeenCalledWith(
'dsh-plugin-desktop: child process gone (type: Utility, name: Network Service, service: network.mojom.NetworkService, reason: crashed, exitCode: -1073741819 / 0xc0000005)',
)
remove()
expect(app.listenerCount('child-process-gone')).toBe(0)
})
it('writes to the sink and to stderr', () => {
const { s, dir } = sink()
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
@@ -321,6 +321,25 @@ describe('Electron compatibility runtime', () => {
expect(electron.trays[0]?.off).toHaveBeenCalledWith('click', expect.any(Function))
})
it('logs renderer crashes with the Windows exception code', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts')
const logger = { error: vi.fn(), errorCause: vi.fn() }
const runtime = new ElectronDesktopRuntime(async () => {}, undefined, logger)
const release = runtime.schedule(spec)
await runtime.mountScheduled()
const gone = electron.browserWindows[0]?.webContents.on.mock.calls
.find(([event]) => event === 'render-process-gone')?.[1]
expect(gone).toEqual(expect.any(Function))
gone({}, { reason: 'crashed', exitCode: -1073741819 })
expect(logger.error).toHaveBeenCalledWith(
'dsh-plugin-desktop: renderer process gone (reason: crashed, exitCode: -1073741819 / 0xc0000005)',
)
await release()
})
it('starts from the saved locale and rebuilds native tray commands when it changes', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts')
+19
View File
@@ -162,6 +162,25 @@ describe('published package surface', () => {
expect(main).toContain('disposeDshRuntime?.()')
})
it('wires local crash evidence before Electron becomes ready', () => {
const main = readFileSync(new URL('src/main.ts', packageRoot), 'utf8')
const startCrashReporter = main.indexOf('startDesktopCrashReporting(crashReporter')
const beginRun = main.indexOf('beginDesktopRun(')
const childLogging = main.indexOf('installDesktopChildProcessLogging(app')
const exitCoordinator = main.indexOf('createDesktopExitCoordinator(')
const ready = main.indexOf('await app.whenReady()')
const markClean = main.indexOf('desktopRun?.markClean()')
const nativeExit = main.indexOf('app.exit(code)')
expect(startCrashReporter).toBeGreaterThanOrEqual(0)
expect(beginRun).toBeGreaterThan(startCrashReporter)
expect(childLogging).toBeGreaterThan(beginRun)
expect(exitCoordinator).toBeGreaterThan(childLogging)
expect(nativeExit).toBeGreaterThan(exitCoordinator)
expect(markClean).toBeGreaterThan(nativeExit)
expect(ready).toBeGreaterThan(markClean)
})
it('uses the upstream child-environment scrub around login-shell recovery', () => {
const shellEnvironment = readFileSync(new URL('src/shell-environment.ts', packageRoot), 'utf8')