/* * 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 'node:fs'; import { readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import JSON5 from 'json5'; import { throttle } from 'lodash-es'; import type Config from '../../../stores/config/Config'; import type Disposer from '../../../utils/Disposer'; import { getLogger } from '../../../utils/log'; import type ConfigRepository from '../ConfigRepository'; import type ReadConfigResult from '../ReadConfigResult'; const log = getLogger('ConfigFile'); export default class ConfigFile implements ConfigRepository { readonly #userDataDir: string; readonly #configFileName: string; readonly #configFilePath: string; #writingConfig = false; #timeLastWritten: Date | undefined; constructor(userDataDir: string, configFileName = 'config.json5') { this.#userDataDir = userDataDir; this.#configFileName = configFileName; this.#configFilePath = path.join(userDataDir, configFileName); } async readConfig(): Promise { let configStr: string; try { configStr = await readFile(this.#configFilePath, 'utf8'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { log.debug('Config file', this.#configFilePath, 'was not found'); return { found: false }; } throw error; } log.info('Read config file', this.#configFilePath); return { found: true, data: JSON5.parse(configStr), }; } async writeConfig(configSnapshot: Config): 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 (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { log.debug( 'Config file', this.#configFilePath, 'was deleted after being changed', ); return; } throw error; } if ( !this.#writingConfig && (this.#timeLastWritten === undefined || 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(); }; } }