diff options
Diffstat (limited to 'packages/main/src/infrastructure/config/impl/ConfigFile.ts')
-rw-r--r-- | packages/main/src/infrastructure/config/impl/ConfigFile.ts | 144 |
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'; | |||
22 | import { readFile, stat, writeFile } from 'node:fs/promises'; | 22 | import { readFile, stat, writeFile } from 'node:fs/promises'; |
23 | import path from 'node:path'; | 23 | import path from 'node:path'; |
24 | 24 | ||
25 | import JSON5 from 'json5'; | 25 | import { debounce } from 'lodash-es'; |
26 | import { throttle } from 'lodash-es'; | ||
27 | 26 | ||
28 | import type Config from '../../../stores/config/Config'; | 27 | import type Disposer from '../../../utils/Disposer.js'; |
29 | import type Disposer from '../../../utils/Disposer'; | 28 | import getLogger from '../../../utils/getLogger.js'; |
30 | import { getLogger } from '../../../utils/log'; | 29 | import isErrno from '../../../utils/isErrno.js'; |
31 | import type ConfigRepository from '../ConfigRepository'; | 30 | import type ConfigRepository from '../ConfigRepository.js'; |
32 | import type { ReadConfigResult } from '../ConfigRepository'; | 31 | import type { ReadConfigResult } from '../ConfigRepository.js'; |
33 | 32 | ||
34 | const log = getLogger('ConfigFile'); | 33 | const log = getLogger('ConfigFile'); |
35 | 34 | ||
36 | export default class ConfigFile implements ConfigRepository { | 35 | export const CONFIG_FILE_NAME = 'settings.json'; |
37 | readonly #userDataDir: string; | 36 | export const DEFAULT_CONFIG_CHANGE_DEBOUNCE_MS = 10; |
38 | |||
39 | readonly #configFileName: string; | ||
40 | 37 | ||
41 | readonly #configFilePath: string; | 38 | export 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 | } |