mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-10 18:36:56 +08:00
Merge remote-tracking branch 'origin/master' into codex/desktop-zoom-shortcuts
This commit is contained in:
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user