aboutsummaryrefslogtreecommitdiffstats
path: root/packages/main/src/infrastructure/config/impl/ConfigFile.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/main/src/infrastructure/config/impl/ConfigFile.ts')
-rw-r--r--packages/main/src/infrastructure/config/impl/ConfigFile.ts144
1 files changed, 80 insertions, 64 deletions
diff --git a/packages/main/src/infrastructure/config/impl/ConfigFile.ts b/packages/main/src/infrastructure/config/impl/ConfigFile.ts
index e8237b4..8f0cc3f 100644
--- a/packages/main/src/infrastructure/config/impl/ConfigFile.ts
+++ b/packages/main/src/infrastructure/config/impl/ConfigFile.ts
@@ -22,120 +22,136 @@ import { watch } from 'node:fs';
22import { readFile, stat, writeFile } from 'node:fs/promises'; 22import { readFile, stat, writeFile } from 'node:fs/promises';
23import path from 'node:path'; 23import path from 'node:path';
24 24
25import JSON5 from 'json5'; 25import { debounce } from 'lodash-es';
26import { throttle } from 'lodash-es';
27 26
28import type Config from '../../../stores/config/Config'; 27import type Disposer from '../../../utils/Disposer.js';
29import type Disposer from '../../../utils/Disposer'; 28import getLogger from '../../../utils/getLogger.js';
30import { getLogger } from '../../../utils/log'; 29import isErrno from '../../../utils/isErrno.js';
31import type ConfigRepository from '../ConfigRepository'; 30import type ConfigRepository from '../ConfigRepository.js';
32import type { ReadConfigResult } from '../ConfigRepository'; 31import type { ReadConfigResult } from '../ConfigRepository.js';
33 32
34const log = getLogger('ConfigFile'); 33const log = getLogger('ConfigFile');
35 34
36export default class ConfigFile implements ConfigRepository { 35export const CONFIG_FILE_NAME = 'settings.json';
37 readonly #userDataDir: string; 36export const DEFAULT_CONFIG_CHANGE_DEBOUNCE_MS = 10;
38
39 readonly #configFileName: string;
40 37
41 readonly #configFilePath: string; 38export default class ConfigFile implements ConfigRepository {
39 private readonly configFilePath: string;
42 40
43 #writingConfig = false; 41 private writingConfig = false;
44 42
45 #timeLastWritten: Date | undefined; 43 private timeLastWritten: Date | undefined;
46 44
47 constructor(userDataDir: string, configFileName = 'config.json5') { 45 constructor(
48 this.#userDataDir = userDataDir; 46 private readonly userDataDir: string,
49 this.#configFileName = configFileName; 47 private readonly configFileName = CONFIG_FILE_NAME,
50 this.#configFilePath = path.join(userDataDir, configFileName); 48 private readonly debounceTime = DEFAULT_CONFIG_CHANGE_DEBOUNCE_MS,
49 ) {
50 this.configFilePath = path.join(userDataDir, configFileName);
51 } 51 }
52 52
53 async readConfig(): Promise<ReadConfigResult> { 53 async readConfig(): Promise<ReadConfigResult> {
54 let configStr: string; 54 let contents: string;
55 try { 55 try {
56 configStr = await readFile(this.#configFilePath, 'utf8'); 56 contents = await readFile(this.configFilePath, 'utf8');
57 } catch (error) { 57 } catch (error) {
58 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 58 if (isErrno(error, 'ENOENT')) {
59 log.debug('Config file', this.#configFilePath, 'was not found'); 59 log.debug('Config file', this.configFilePath, 'was not found');
60 return { found: false }; 60 return { found: false };
61 } 61 }
62 throw error; 62 throw error;
63 } 63 }
64 log.info('Read config file', this.#configFilePath); 64 log.debug('Read config file', this.configFilePath);
65 return { 65 return {
66 found: true, 66 found: true,
67 data: JSON5.parse(configStr), 67 contents,
68 }; 68 };
69 } 69 }
70 70
71 async writeConfig(configSnapshot: Config): Promise<void> { 71 async writeConfig(contents: string): Promise<void> {
72 const configJson = JSON5.stringify(configSnapshot, { 72 if (this.writingConfig) {
73 space: 2, 73 throw new Error('writeConfig cannot be called reentrantly');
74 }); 74 }
75 this.#writingConfig = true; 75 this.writingConfig = true;
76 try { 76 try {
77 await writeFile(this.#configFilePath, configJson, 'utf8'); 77 await writeFile(this.configFilePath, contents, 'utf8');
78 const { mtime } = await stat(this.#configFilePath); 78 const { mtime } = await stat(this.configFilePath);
79 log.trace('Config file', this.#configFilePath, 'last written at', mtime); 79 log.trace('Config file', this.configFilePath, 'last written at', mtime);
80 this.#timeLastWritten = mtime; 80 this.timeLastWritten = mtime;
81 } finally { 81 } finally {
82 this.#writingConfig = false; 82 this.writingConfig = false;
83 } 83 }
84 log.debug('Wrote config file', this.#configFilePath); 84 log.debug('Wrote config file', this.configFilePath);
85 } 85 }
86 86
87 watchConfig(callback: () => Promise<void>, throttleMs: number): Disposer { 87 watchConfig(callback: () => Promise<void>): Disposer {
88 log.debug('Installing watcher for', this.#userDataDir); 88 log.debug('Installing watcher for', this.userDataDir);
89 89
90 const configChanged = throttle(async () => { 90 const configChanged = debounce(async () => {
91 let mtime: Date; 91 let mtime: Date;
92 try { 92 try {
93 const stats = await stat(this.#configFilePath); 93 const stats = await stat(this.configFilePath);
94 mtime = stats.mtime; 94 mtime = stats.mtime;
95 log.trace('Config file last modified at', mtime); 95 log.trace('Config file last modified at', mtime);
96 } catch (error) { 96 } catch (error) {
97 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 97 if (isErrno(error, 'ENOENT')) {
98 log.debug( 98 log.debug(
99 'Config file', 99 'Config file',
100 this.#configFilePath, 100 this.configFilePath,
101 'was deleted after being changed', 101 'was deleted after being changed',
102 ); 102 );
103 return; 103 return;
104 } 104 }
105 throw error; 105 log.error(
106 'Unexpected error while listening for config file changes',
107 error,
108 );
109 return;
106 } 110 }
107 if ( 111 if (
108 !this.#writingConfig && 112 !this.writingConfig &&
109 (this.#timeLastWritten === undefined || mtime > this.#timeLastWritten) 113 (this.timeLastWritten === undefined || mtime > this.timeLastWritten)
110 ) { 114 ) {
111 log.debug( 115 log.debug(
112 'Found a config file modified at', 116 'Found a config file modified at',
113 mtime, 117 mtime,
114 'whish is newer than last written', 118 'which is newer than last written',
115 this.#timeLastWritten, 119 this.timeLastWritten,
116 ); 120 );
117 await callback(); 121 try {
118 } 122 await callback();
119 }, throttleMs); 123 } catch (error) {
120 124 log.error('Callback error while listening for config changes', error);
121 const watcher = watch(this.#userDataDir, { 125 }
122 persistent: false,
123 });
124
125 watcher.on('change', (eventType, filename) => {
126 if (
127 eventType === 'change' &&
128 (filename === this.#configFileName || filename === null)
129 ) {
130 configChanged()?.catch((err) => {
131 log.error('Unhandled error while listening for config changes', err);
132 });
133 } 126 }
134 }); 127 }, this.debounceTime);
128
129 const watcher = watch(
130 this.userDataDir,
131 {
132 persistent: false,
133 recursive: false,
134 },
135 (_eventType, filename) => {
136 // We handle both `rename` and `change` events for maximum portability.
137 // This may result in multiple calls to `configChanged` for a single config change,
138 // so we debounce it with a short (imperceptible) delay.
139 if (filename === this.configFileName || filename === null) {
140 configChanged()?.catch((err) => {
141 // This should never happen, because `configChanged` handles all exceptions.
142 log.error(
143 'Unhandled error while listening for config changes',
144 err,
145 );
146 });
147 }
148 },
149 );
135 150
136 return () => { 151 return () => {
137 log.trace('Removing watcher for', this.#configFilePath); 152 log.trace('Removing watcher for', this.configFilePath);
138 watcher.close(); 153 watcher.close();
154 configChanged.cancel();
139 }; 155 };
140 } 156 }
141} 157}