75 lines
3.5 KiB
TypeScript
75 lines
3.5 KiB
TypeScript
/** Opt-in qualification evidence outside the installation directory; no raw diagnostics or request data. */
|
|
import { randomUUID } from 'node:crypto'
|
|
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
import { isAbsolute, join } from 'node:path'
|
|
import type { DesktopUpdateState } from './ipc.ts'
|
|
|
|
/** User and process milestones that connect update states across application restarts. */
|
|
export type DesktopUpdateJournalAction = 'started' | 'workspace-ready' | 'workspace-failed'
|
|
| 'check-requested' | 'download-requested' | 'install-confirmed' | 'quit-requested'
|
|
| 'policy-login-opened' | 'policy-login-returned' | 'policy-login-cancelled' | 'policy-login-failed'
|
|
|
|
const ERROR_CODES = ['ETIMEDOUT', 'ENOSPC', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET',
|
|
'ERR_CONNECTION_CLOSED', 'ERR_NAME_NOT_RESOLVED', 'ERR_UPDATER_INVALID_SIGNATURE', 'ERR_UPDATER_CHECKSUM_MISMATCH'] as const
|
|
|
|
/**
|
|
* Whitelist one update state for disk; neither error text nor unexpected object fields survive.
|
|
* @param state Main-process-owned update state.
|
|
* @returns Only phase, target version, integer progress, operation, and a fixed error classification.
|
|
*/
|
|
export function desktopUpdateJournalState(state: DesktopUpdateState): object {
|
|
return {
|
|
phase: state.phase,
|
|
...(state.version !== undefined ? { targetVersion: state.version } : {}),
|
|
...(state.phase === 'downloading' && state.percent !== undefined ? { percent: Math.floor(state.percent) } : {}),
|
|
...(state.phase === 'error' ? {
|
|
failedOperation: state.failedOperation,
|
|
errorCode: ERROR_CODES.find(code => state.message?.includes(code)) ?? 'UNCLASSIFIED',
|
|
} : {}),
|
|
}
|
|
}
|
|
|
|
/** Process-owned JSONL evidence; each append is flushed before the caller continues. */
|
|
export class DesktopUpdateJournal {
|
|
readonly path: string
|
|
private sequence = 0
|
|
private previousState: string | undefined
|
|
|
|
/**
|
|
* @param directory Absolute evidence directory, retained across installs; creation errors stop qualification.
|
|
* @param version Installed application version, not a version supplied by the feed.
|
|
*/
|
|
constructor(directory: string, private readonly version: string) {
|
|
if (!isAbsolute(directory)) throw new Error('desktop update journal: directory must be absolute')
|
|
mkdirSync(directory, { recursive: true })
|
|
this.path = join(directory, `${Date.now()}-${randomUUID()}.jsonl`)
|
|
writeFileSync(this.path, '', { flag: 'wx', mode: 0o600, flush: true })
|
|
this.action('started')
|
|
}
|
|
|
|
/**
|
|
* Append a fixed action; failures propagate so incomplete qualification is never reported as traced.
|
|
* @param action Update operation or process milestone; no free-text fields are accepted.
|
|
* @returns Nothing after the record has been flushed.
|
|
*/
|
|
action(action: DesktopUpdateJournalAction): void { this.append({ event: action }) }
|
|
|
|
/**
|
|
* Retain state changes and integer progress increments without raw errors, URLs, or request data.
|
|
* @param state Main-process-owned update state.
|
|
* @returns Nothing after the changed state has been flushed; duplicate states add no record.
|
|
*/
|
|
state(state: DesktopUpdateState): void {
|
|
const fields = desktopUpdateJournalState(state)
|
|
const encoded = JSON.stringify(fields)
|
|
if (encoded === this.previousState) return
|
|
this.append({ event: 'state', ...fields })
|
|
this.previousState = encoded
|
|
}
|
|
|
|
private append(fields: object): void {
|
|
appendFileSync(this.path, `${JSON.stringify({ schemaVersion: 1, sequence: this.sequence++,
|
|
time: new Date().toISOString(), pid: process.pid, version: this.version, ...fields })}\n`, { flush: true })
|
|
}
|
|
}
|