import * as fs from 'fs/promises' import { randomBytes } from 'node:crypto' import { ApiError } from '../middleware/errorHandler.js' type RecoverableJsonFileOptions = { filePath: string label: string defaultValue: T normalize: (value: unknown) => T | null } function cloneDefault(value: T): T { if (value && typeof value === 'object') { return JSON.parse(JSON.stringify(value)) as T } return value } function errnoCode(error: unknown): string | undefined { return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : undefined } async function quarantineInvalidJsonFile( filePath: string, label: string, reason: string, ): Promise { const backupPath = `${filePath}.invalid-${Date.now()}-${randomBytes(3).toString('hex')}` try { await fs.rename(filePath, backupPath) console.warn(`[desktop] Recovered invalid ${label}; moved ${filePath} to ${backupPath}: ${reason}`) } catch (error) { console.warn( `[desktop] Recovered invalid ${label} from ${filePath}, but failed to quarantine it: ${ error instanceof Error ? error.message : String(error) }`, ) } } export function normalizeJsonObject(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null } return value as Record } export async function readRecoverableJsonFile({ filePath, label, defaultValue, normalize, }: RecoverableJsonFileOptions): Promise { let raw: string try { raw = await fs.readFile(filePath, 'utf-8') } catch (error) { if (errnoCode(error) === 'ENOENT') { return cloneDefault(defaultValue) } throw ApiError.internal(`Failed to read ${label} from ${filePath}: ${error}`) } if (raw.trim() === '') { return cloneDefault(defaultValue) } let parsed: unknown try { parsed = JSON.parse(raw) } catch (error) { await quarantineInvalidJsonFile( filePath, label, error instanceof Error ? error.message : String(error), ) return cloneDefault(defaultValue) } const normalized = normalize(parsed) if (normalized === null) { await quarantineInvalidJsonFile(filePath, label, 'unexpected JSON shape') return cloneDefault(defaultValue) } return normalized }