| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374 |
- /** 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 })
- }
- }
|