import { chmod, readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises'; import path from 'node:path'; import type { BridgePreference } from './types'; function parseStoredPreferences(value: unknown): Record { if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}; const parsed: Record = {}; for (const [key, entries] of Object.entries(value)) { if (!Array.isArray(entries)) continue; parsed[key] = entries.filter( (entry): entry is BridgePreference => entry !== null && typeof entry === 'object' && !Array.isArray(entry) && typeof (entry as Record).key === 'string', ); } return parsed; } /** * Persists each source's preference array verbatim, keyed by extension package * and bridge source id. * * Extensions keep credentials in here (the Jellyfin source stores a password), * so the file is written with owner-only permissions. */ export class PreferenceStore { private readonly file: string; private cache: Record | null = null; /** * Mutations run one at a time. Two concurrent load-modify-persist cycles * starting on a cold cache would each read their own object, and the later * write would drop the earlier one's edit. */ private queue: Promise = Promise.resolve(); constructor(file: string) { this.file = file; } private enqueue(operation: () => Promise): Promise { const result = this.queue.then(operation, operation); // Keep the chain alive after a rejection so one failure cannot wedge it. this.queue = result.catch(() => undefined); return result; } private async load(): Promise> { if (this.cache !== null) return this.cache; try { const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown; this.cache = parseStoredPreferences(parsed); } catch { // Missing or corrupt file starts empty rather than blocking the browser. this.cache = {}; } return this.cache; } async get(pkg: string, sourceId: string): Promise { return this.enqueue(async () => { const all = await this.load(); const key = `${pkg}:${sourceId}`; if (all[key]) return all[key]; // Bare source IDs predate package scoping and have no trustworthy owner. // Never assign their credentials to whichever package happens to ask first. const legacy = all[sourceId]; if (legacy) { delete all[sourceId]; await this.persist(all); } return []; }); } async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise { await this.enqueue(async () => { const all = await this.load(); all[`${pkg}:${sourceId}`] = preferences; await this.persist(all); }); } /** * Drop every saved value whose key starts with `prefix`. * * Removing an extension should not leave its credentials on disk, and a * source id is not knowable once the APK is gone — so callers pass the * package name and this clears anything recorded under it. */ async clear(prefix: string): Promise { await this.enqueue(async () => { const all = await this.load(); let changed = false; for (const key of Object.keys(all)) { if (key === prefix || key.startsWith(`${prefix}:`)) { delete all[key]; changed = true; } } if (changed) await this.persist(all); }); } /** * Write through a temporary file and rename into place. * * A write interrupted partway would otherwise leave truncated JSON, and * `load()` treats unparseable content as empty — which would quietly discard * every saved credential. */ private async persist(all: Record): Promise { await mkdir(path.dirname(this.file), { recursive: true }); const temporary = `${this.file}.tmp`; try { await chmod(temporary, 0o600).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'ENOENT') throw error; }); await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 }); await chmod(temporary, 0o600); await rename(temporary, this.file); } catch (error) { await rm(temporary, { force: true }).catch(() => undefined); throw error; } } }