/* * 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 { debounce } from 'lodash-es'; import type Disposer from '../../../utils/Disposer.js'; import getLogger from '../../../utils/getLogger.js'; import isErrno from '../../../utils/isErrno.js'; import type ConfigRepository from '../ConfigRepository.js'; import type { ReadConfigResult } from '../ConfigRepository.js'; const log = getLogger('ConfigFile'); export const CONFIG_FILE_NAME = 'settings.json'; export const DEFAULT_CONFIG_CHANGE_DEBOUNCE_MS = 10; export default class ConfigFile implements ConfigRepository { private readonly configFilePath: string; private writingConfig = false; private timeLastWritten: Date | undefined; constructor( private readonly userDataDir: string, private readonly configFileName = CONFIG_FILE_NAME, private readonly debounceTime = DEFAULT_CONFIG_CHANGE_DEBOUNCE_MS, ) { this.configFilePath = path.join(userDataDir, configFileName); } async readConfig(): Promise { let contents: string; try { contents = await readFile(this.configFilePath, 'utf8'); } catch (error) { if (isErrno(error, 'ENOENT')) { log.debug('Config file', this.configFilePath, 'was not found'); return { found: false }; } throw error; } log.debug('Read config file', this.configFilePath); return { found: true, contents, }; } async writeConfig(contents: string): Promise { if (this.writingConfig) { throw new Error('writeConfig cannot be called reentrantly'); } this.writingConfig = true; try { await writeFile(this.configFilePath, contents, '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.debug('Wrote config file', this.configFilePath); } watchConfig(callback: () => Promise): Disposer { log.debug('Installing watcher for', this.userDataDir); const configChanged = debounce(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 (isErrno(error, 'ENOENT')) { log.debug( 'Config file', this.configFilePath, 'was deleted after being changed', ); return; } log.error( 'Unexpected error while listening for config file changes', error, ); return; } if ( !this.writingConfig && (this.timeLastWritten === undefined || mtime > this.timeLastWritten) ) { log.debug( 'Found a config file modified at', mtime, 'which is newer than last written', this.timeLastWritten, ); try { await callback(); } catch (error) { log.error('Callback error while listening for config changes', error); } } }, this.debounceTime); const watcher = watch( this.userDataDir, { persistent: false, recursive: false, }, (_eventType, filename) => { // We handle both `rename` and `change` events for maximum portability. // This may result in multiple calls to `configChanged` for a single config change, // so we debounce it with a short (imperceptible) delay. if (filename === this.configFileName || filename === null) { configChanged()?.catch((err) => { // This should never happen, because `configChanged` handles all exceptions. log.error( 'Unhandled error while listening for config changes', err, ); }); } }, ); return () => { log.trace('Removing watcher for', this.configFilePath); watcher.close(); configChanged.cancel(); }; } }