update-journal.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. /** Opt-in qualification evidence outside the installation directory; no raw diagnostics or request data. */
  2. import { randomUUID } from 'node:crypto'
  3. import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
  4. import { isAbsolute, join } from 'node:path'
  5. import type { DesktopUpdateState } from './ipc.ts'
  6. /** User and process milestones that connect update states across application restarts. */
  7. export type DesktopUpdateJournalAction = 'started' | 'workspace-ready' | 'workspace-failed'
  8. | 'check-requested' | 'download-requested' | 'install-confirmed' | 'quit-requested'
  9. | 'policy-login-opened' | 'policy-login-returned' | 'policy-login-cancelled' | 'policy-login-failed'
  10. const ERROR_CODES = ['ETIMEDOUT', 'ENOSPC', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_RESET',
  11. 'ERR_CONNECTION_CLOSED', 'ERR_NAME_NOT_RESOLVED', 'ERR_UPDATER_INVALID_SIGNATURE', 'ERR_UPDATER_CHECKSUM_MISMATCH'] as const
  12. /**
  13. * Whitelist one update state for disk; neither error text nor unexpected object fields survive.
  14. * @param state Main-process-owned update state.
  15. * @returns Only phase, target version, integer progress, operation, and a fixed error classification.
  16. */
  17. export function desktopUpdateJournalState(state: DesktopUpdateState): object {
  18. return {
  19. phase: state.phase,
  20. ...(state.version !== undefined ? { targetVersion: state.version } : {}),
  21. ...(state.phase === 'downloading' && state.percent !== undefined ? { percent: Math.floor(state.percent) } : {}),
  22. ...(state.phase === 'error' ? {
  23. failedOperation: state.failedOperation,
  24. errorCode: ERROR_CODES.find(code => state.message?.includes(code)) ?? 'UNCLASSIFIED',
  25. } : {}),
  26. }
  27. }
  28. /** Process-owned JSONL evidence; each append is flushed before the caller continues. */
  29. export class DesktopUpdateJournal {
  30. readonly path: string
  31. private sequence = 0
  32. private previousState: string | undefined
  33. /**
  34. * @param directory Absolute evidence directory, retained across installs; creation errors stop qualification.
  35. * @param version Installed application version, not a version supplied by the feed.
  36. */
  37. constructor(directory: string, private readonly version: string) {
  38. if (!isAbsolute(directory)) throw new Error('desktop update journal: directory must be absolute')
  39. mkdirSync(directory, { recursive: true })
  40. this.path = join(directory, `${Date.now()}-${randomUUID()}.jsonl`)
  41. writeFileSync(this.path, '', { flag: 'wx', mode: 0o600, flush: true })
  42. this.action('started')
  43. }
  44. /**
  45. * Append a fixed action; failures propagate so incomplete qualification is never reported as traced.
  46. * @param action Update operation or process milestone; no free-text fields are accepted.
  47. * @returns Nothing after the record has been flushed.
  48. */
  49. action(action: DesktopUpdateJournalAction): void { this.append({ event: action }) }
  50. /**
  51. * Retain state changes and integer progress increments without raw errors, URLs, or request data.
  52. * @param state Main-process-owned update state.
  53. * @returns Nothing after the changed state has been flushed; duplicate states add no record.
  54. */
  55. state(state: DesktopUpdateState): void {
  56. const fields = desktopUpdateJournalState(state)
  57. const encoded = JSON.stringify(fields)
  58. if (encoded === this.previousState) return
  59. this.append({ event: 'state', ...fields })
  60. this.previousState = encoded
  61. }
  62. private append(fields: object): void {
  63. appendFileSync(this.path, `${JSON.stringify({ schemaVersion: 1, sequence: this.sequence++,
  64. time: new Date().toISOString(), pid: process.pid, version: this.version, ...fields })}\n`, { flush: true })
  65. }
  66. }