/* * 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 { watch } from 'fs'; import { readFile, stat, writeFile } from 'fs/promises'; import { join } from 'path'; import JSON5 from 'json5'; import throttle from 'lodash-es/throttle'; import type { ConfigSnapshotOut } from '../../stores/Config'; import type Disposer from '../../utils/Disposer'; import { getLogger } from '../../utils/log'; import type ConfigPersistenceService from '../ConfigPersistenceService'; import type { ReadConfigResult } from '../ConfigPersistenceService'; const log = getLogger('configPersistence'); export default class ConfigPersistenceServiceImpl implements ConfigPersistenceService { private readonly configFilePath: string; private writingConfig = false; private timeLastWritten: Date | null = null; constructor( private readonly userDataDir: string, private readonly configFileName: string = 'config.json5', ) { 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') { log.debug('Config file', this.configFilePath, 'was not found'); return { found: false }; } throw err; } log.info('Read config file', this.configFilePath); return { found: true, data: JSON5.parse(configStr), }; } async writeConfig(configSnapshot: ConfigSnapshotOut): Promise { const configJson = JSON5.stringify(configSnapshot, { space: 2, }); this.writingConfig = true; try { await writeFile(this.configFilePath, configJson, 'utf8'); const { mtime } = await stat(this.configFilePath); log.trace('Config file', this.configFilePath, 'last written at', mtime); this.timeLastWritten = mtime; } finally { this.writingConfig = false; } log.info('Wrote config file', this.configFilePath); } watchConfig(callback: () => Promise, throttleMs: number): Disposer { log.debug('Installing watcher for', this.userDataDir); const configChanged = throttle(async () => { let mtime: Date; try { const stats = await stat(this.configFilePath); mtime = stats.mtime; log.trace('Config file last modified at', mtime); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { log.debug('Config file', this.configFilePath, 'was deleted after being changed'); return; } throw err; } if (!this.writingConfig && (this.timeLastWritten === null || mtime > this.timeLastWritten)) { log.debug( 'Found a config file modified at', mtime, 'whish is newer than last written', this.timeLastWritten, ); await callback(); } }, throttleMs); const watcher = watch(this.userDataDir, { persistent: false, }); watcher.on('change', (eventType, filename) => { if (eventType === 'change' && (filename === this.configFileName || filename === null)) { configChanged()?.catch((err) => { log.error('Unhandled error while listening for config changes', err); }); } }); return () => { log.trace('Removing watcher for', this.configFilePath); watcher.close(); }; } }