/* * Copyright (C) 2021-2022 Kristóf Marussy * * This file is part of Sophie. * * Sophie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * SPDX-License-Identifier: AGPL-3.0-only */ import { FSWatcher, watch } from 'fs'; import { readFile, stat, writeFile } from 'fs/promises'; import JSON5 from 'json5'; import { throttle } from 'lodash'; import { join } from 'path'; import type { ConfigSnapshotOut } from '../stores/Config'; export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; export class ConfigPersistenceService { static inject = ['userDataDir', 'configFileName'] as const; private readonly configFilePath: string; private timeLastWritten: Date | null = null; private watcher: FSWatcher | null = null; constructor( private readonly userDataDir: string, private readonly configFileName: string, ) { this.configFileName = configFileName; this.configFilePath = join(this.userDataDir, this.configFileName); } async readConfig(): Promise { let configStr; try { configStr = await readFile(this.configFilePath, 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return { found: false }; } throw err; } return { found: true, data: JSON5.parse(configStr), }; } async writeConfig(configSnapshot: ConfigSnapshotOut): Promise { const configJson = JSON5.stringify(configSnapshot, { space: 2, }); await writeFile(this.configFilePath, configJson, 'utf8'); const stats = await stat(this.configFilePath); this.timeLastWritten = stats.mtime; } watchConfig(callback: () => Promise, throttleMs: number): void { if (this.watcher !== null) { throw new Error('watchConfig was already called'); } const configChanged = throttle(async () => { let mtime: Date; try { const stats = await stat(this.configFilePath); mtime = stats.mtime; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return; } throw err; } if (this.timeLastWritten === null || mtime > this.timeLastWritten) { return callback(); } }, throttleMs); this.watcher = watch(this.userDataDir, { persistent: false, }); this.watcher.on('change', (eventType, filename) => { if (eventType === 'change' && (filename === this.configFileName || filename === null)) { configChanged()?.catch((err) => { console.log('Unhandled error while listening for config changes', err); }); } }); } dispose(): void { this.watcher?.close(); } }