diff options
Diffstat (limited to 'packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts')
-rw-r--r-- | packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts new file mode 100644 index 0000000..06e3fab --- /dev/null +++ b/packages/main/src/infrastructure/impl/FileBasedConfigPersistence.ts | |||
@@ -0,0 +1,138 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2021-2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | import { watch } from 'node:fs'; | ||
21 | import { readFile, stat, writeFile } from 'node:fs/promises'; | ||
22 | import path from 'node:path'; | ||
23 | |||
24 | import JSON5 from 'json5'; | ||
25 | import throttle from 'lodash-es/throttle'; | ||
26 | |||
27 | import type { ConfigSnapshotOut } from '../../stores/Config'; | ||
28 | import type Disposer from '../../utils/Disposer'; | ||
29 | import { getLogger } from '../../utils/log'; | ||
30 | import type ConfigPersistence from '../ConfigPersistence'; | ||
31 | import type { ReadConfigResult } from '../ConfigPersistence'; | ||
32 | |||
33 | const log = getLogger('fileBasedConfigPersistence'); | ||
34 | |||
35 | export default class FileBasedConfigPersistence implements ConfigPersistence { | ||
36 | private readonly configFilePath: string; | ||
37 | |||
38 | private writingConfig = false; | ||
39 | |||
40 | private timeLastWritten: Date | undefined; | ||
41 | |||
42 | constructor( | ||
43 | private readonly userDataDir: string, | ||
44 | private readonly configFileName: string = 'config.json5', | ||
45 | ) { | ||
46 | this.configFileName = configFileName; | ||
47 | this.configFilePath = path.join(this.userDataDir, this.configFileName); | ||
48 | } | ||
49 | |||
50 | async readConfig(): Promise<ReadConfigResult> { | ||
51 | let configStr: string; | ||
52 | try { | ||
53 | configStr = await readFile(this.configFilePath, 'utf8'); | ||
54 | } catch (error) { | ||
55 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { | ||
56 | log.debug('Config file', this.configFilePath, 'was not found'); | ||
57 | return { found: false }; | ||
58 | } | ||
59 | throw error; | ||
60 | } | ||
61 | log.info('Read config file', this.configFilePath); | ||
62 | return { | ||
63 | found: true, | ||
64 | data: JSON5.parse(configStr), | ||
65 | }; | ||
66 | } | ||
67 | |||
68 | async writeConfig(configSnapshot: ConfigSnapshotOut): Promise<void> { | ||
69 | const configJson = JSON5.stringify(configSnapshot, { | ||
70 | space: 2, | ||
71 | }); | ||
72 | this.writingConfig = true; | ||
73 | try { | ||
74 | await writeFile(this.configFilePath, configJson, 'utf8'); | ||
75 | const { mtime } = await stat(this.configFilePath); | ||
76 | log.trace('Config file', this.configFilePath, 'last written at', mtime); | ||
77 | this.timeLastWritten = mtime; | ||
78 | } finally { | ||
79 | this.writingConfig = false; | ||
80 | } | ||
81 | log.info('Wrote config file', this.configFilePath); | ||
82 | } | ||
83 | |||
84 | watchConfig(callback: () => Promise<void>, throttleMs: number): Disposer { | ||
85 | log.debug('Installing watcher for', this.userDataDir); | ||
86 | |||
87 | const configChanged = throttle(async () => { | ||
88 | let mtime: Date; | ||
89 | try { | ||
90 | const stats = await stat(this.configFilePath); | ||
91 | mtime = stats.mtime; | ||
92 | log.trace('Config file last modified at', mtime); | ||
93 | } catch (error) { | ||
94 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') { | ||
95 | log.debug( | ||
96 | 'Config file', | ||
97 | this.configFilePath, | ||
98 | 'was deleted after being changed', | ||
99 | ); | ||
100 | return; | ||
101 | } | ||
102 | throw error; | ||
103 | } | ||
104 | if ( | ||
105 | !this.writingConfig && | ||
106 | (this.timeLastWritten === undefined || mtime > this.timeLastWritten) | ||
107 | ) { | ||
108 | log.debug( | ||
109 | 'Found a config file modified at', | ||
110 | mtime, | ||
111 | 'whish is newer than last written', | ||
112 | this.timeLastWritten, | ||
113 | ); | ||
114 | await callback(); | ||
115 | } | ||
116 | }, throttleMs); | ||
117 | |||
118 | const watcher = watch(this.userDataDir, { | ||
119 | persistent: false, | ||
120 | }); | ||
121 | |||
122 | watcher.on('change', (eventType, filename) => { | ||
123 | if ( | ||
124 | eventType === 'change' && | ||
125 | (filename === this.configFileName || filename === null) | ||
126 | ) { | ||
127 | configChanged()?.catch((err) => { | ||
128 | log.error('Unhandled error while listening for config changes', err); | ||
129 | }); | ||
130 | } | ||
131 | }); | ||
132 | |||
133 | return () => { | ||
134 | log.trace('Removing watcher for', this.configFilePath); | ||
135 | watcher.close(); | ||
136 | }; | ||
137 | } | ||
138 | } | ||