aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/main/package.json1
-rw-r--r--packages/main/src/i18n/I18nStore.ts104
-rw-r--r--packages/main/src/i18n/LocalizationRepository.ts39
-rw-r--r--packages/main/src/i18n/RepositoryBasedI18nBackend.ts224
-rw-r--r--packages/main/src/i18n/i18nLog.ts41
-rw-r--r--packages/main/src/i18n/impl/LocaltizationFiles.ts93
-rw-r--r--packages/main/src/i18n/loadLocalization.ts50
-rw-r--r--packages/main/src/infrastructure/config/impl/ConfigFile.ts5
-rw-r--r--packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts29
-rw-r--r--packages/main/src/infrastructure/resources/Resources.ts2
-rw-r--r--packages/main/src/infrastructure/resources/impl/getDistResources.ts4
-rw-r--r--packages/main/src/initReactions.ts8
-rw-r--r--packages/main/src/stores/MainStore.ts9
-rw-r--r--packages/main/src/utils/isErrno.ts28
14 files changed, 624 insertions, 13 deletions
diff --git a/packages/main/package.json b/packages/main/package.json
index 4ba6d06..85f9b12 100644
--- a/packages/main/package.json
+++ b/packages/main/package.json
@@ -14,6 +14,7 @@
14 "deep-equal": "^2.0.5", 14 "deep-equal": "^2.0.5",
15 "electron": "17.1.0", 15 "electron": "17.1.0",
16 "fs-extra": "^10.0.1", 16 "fs-extra": "^10.0.1",
17 "i18next": "^21.6.14",
17 "json5": "^2.2.0", 18 "json5": "^2.2.0",
18 "lodash-es": "^4.17.21", 19 "lodash-es": "^4.17.21",
19 "loglevel": "^1.8.0", 20 "loglevel": "^1.8.0",
diff --git a/packages/main/src/i18n/I18nStore.ts b/packages/main/src/i18n/I18nStore.ts
new file mode 100644
index 0000000..d833e8a
--- /dev/null
+++ b/packages/main/src/i18n/I18nStore.ts
@@ -0,0 +1,104 @@
1/*
2 * Copyright (C) 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
21import type { i18n, TFunction } from 'i18next';
22import { IAtom, createAtom } from 'mobx';
23
24import { getLogger } from '../utils/log';
25
26const log = getLogger('I18nStore');
27
28export type UseTranslationResult =
29 | { ready: true; i18n: i18n; t: TFunction }
30 | { ready: false };
31
32export default class I18nStore {
33 private readonly languageChangedAtom: IAtom;
34
35 private readonly namespaceLoadedAtoms: Map<string, IAtom> = new Map();
36
37 private readonly notifyLanguageChange = () =>
38 this.languageChangedAtom.reportObserved();
39
40 constructor(private readonly i18next: i18n) {
41 this.languageChangedAtom = createAtom(
42 'i18next',
43 () => i18next.on('languageChanged', this.notifyLanguageChange),
44 () => i18next.off('languageChanged', this.notifyLanguageChange),
45 );
46 }
47
48 useTranslation(ns?: string): UseTranslationResult {
49 const observed = this.languageChangedAtom.reportObserved();
50 const namespaceToLoad =
51 ns ?? this.i18next.options.defaultNS ?? 'translation';
52 if (
53 this.i18next.isInitialized &&
54 this.i18next.hasLoadedNamespace(namespaceToLoad)
55 ) {
56 return {
57 ready: true,
58 i18n: this.i18next,
59 // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`.
60 t: this.i18next.getFixedT(null, namespaceToLoad),
61 };
62 }
63 if (observed) {
64 this.loadNamespace(namespaceToLoad);
65 }
66 return { ready: false };
67 }
68
69 private loadNamespace(ns: string): void {
70 const existingAtom = this.namespaceLoadedAtoms.get(ns);
71 if (existingAtom !== undefined) {
72 existingAtom.reportObserved();
73 return;
74 }
75 const atom = createAtom(`i18next-${ns}`);
76 this.namespaceLoadedAtoms.set(ns, atom);
77 atom.reportObserved();
78
79 const loaded = () => {
80 this.namespaceLoadedAtoms.delete(ns);
81 atom.reportChanged();
82 };
83
84 const loadAsync = async () => {
85 await this.i18next.loadNamespaces([ns]);
86 if (this.i18next.isInitialized) {
87 setImmediate(loaded);
88 return;
89 }
90 const initialized = () => {
91 setImmediate(() => {
92 this.i18next.off('initialized', initialized);
93 loaded();
94 });
95 };
96 this.i18next.on('initialized', initialized);
97 };
98
99 loadAsync().catch((error) => {
100 log.error('Failed to load translations for namespace', ns, error);
101 setImmediate(loaded);
102 });
103 }
104}
diff --git a/packages/main/src/i18n/LocalizationRepository.ts b/packages/main/src/i18n/LocalizationRepository.ts
new file mode 100644
index 0000000..041449d
--- /dev/null
+++ b/packages/main/src/i18n/LocalizationRepository.ts
@@ -0,0 +1,39 @@
1/*
2 * Copyright (C) 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
21import type { ResourceKey } from 'i18next';
22
23export default interface LocatlizationRepository {
24 getResourceContents(
25 language: string,
26 namespace: string,
27 ): Promise<ResourceKey>;
28
29 getMissingResourceContents(
30 language: string,
31 namespace: string,
32 ): Promise<ResourceKey>;
33
34 setMissingResourceContents(
35 language: string,
36 namespace: string,
37 data: ResourceKey,
38 ): Promise<void>;
39}
diff --git a/packages/main/src/i18n/RepositoryBasedI18nBackend.ts b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts
new file mode 100644
index 0000000..4d87ce9
--- /dev/null
+++ b/packages/main/src/i18n/RepositoryBasedI18nBackend.ts
@@ -0,0 +1,224 @@
1/*
2 * Copyright (C) 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
21import type {
22 BackendModule,
23 InitOptions,
24 ReadCallback,
25 Services,
26} from 'i18next';
27import { debounce } from 'lodash-es';
28import ms from 'ms';
29
30import { getLogger } from '../utils/log';
31
32import type LocatlizationRepository from './LocalizationRepository';
33
34const MISSING_ENTRIES_DEBOUNCE_TIME = ms('1s');
35
36const log = getLogger('RepositoryBasedI18nBackend');
37
38/**
39 * I18next backend for loading translations from a file.
40 *
41 * Loosely based on [i18next-fs-backend](https://github.com/i18next/i18next-fs-backend),
42 * but we shrink bundle size by omitting JSON5 or YAML translation handling.
43 * Direct file IO is replaced by the `LocalizationRepository`,
44 * which handles platform-specific concerns.
45 */
46export default class RepositoryBasedI18nBackend
47 implements BackendModule<unknown>
48{
49 type = 'backend' as const;
50
51 private writeQueue: Map<string, Map<string, Map<string, string>>> = new Map();
52
53 private keySeparator: string | false = '.';
54
55 private readonly flushWriteQueueImmediately = () => {
56 const savedWriteQueue = this.writeQueue;
57 this.writeQueue = new Map();
58 savedWriteQueue.forEach((languagesMap, namespace) => {
59 languagesMap.forEach((keysMap, language) => {
60 this.flushChanges(language, namespace, keysMap).catch((error) => {
61 log.error(
62 'Cannot write missing',
63 language,
64 'translations for namespace',
65 namespace,
66 error,
67 );
68 });
69 });
70 });
71 };
72
73 private readonly flushWriteQueue = debounce(
74 this.flushWriteQueueImmediately,
75 MISSING_ENTRIES_DEBOUNCE_TIME,
76 );
77
78 constructor(
79 private readonly repository: LocatlizationRepository,
80 private readonly devMode = false,
81 ) {}
82
83 init(
84 _services: Services,
85 _backendOptions: unknown,
86 { keySeparator }: InitOptions,
87 ) {
88 if (keySeparator !== undefined) {
89 this.keySeparator = keySeparator;
90 }
91 }
92
93 read(language: string, namespace: string, callback: ReadCallback): void {
94 const readAsync = async () => {
95 const translations = await this.repository.getResourceContents(
96 language,
97 namespace,
98 );
99 // eslint-disable-next-line unicorn/no-null -- `i18next` API requires `null`.
100 setImmediate(() => callback(null, translations));
101 };
102
103 readAsync().catch((error) => {
104 log.error(
105 'Error while loading',
106 language,
107 'translations for namespace',
108 namespace,
109 error,
110 );
111 const callbackError =
112 error instanceof Error
113 ? error
114 : new Error(`Unknown error: ${JSON.stringify(error)}`);
115 /*
116 eslint-disable-next-line promise/no-callback-in-promise, unicorn/no-null --
117 Converting from promise based API to a callback. `i18next` API requires `null`.
118 */
119 setImmediate(() => callback(callbackError, null));
120 });
121 }
122
123 create(
124 languages: string[],
125 namespace: string,
126 key: string,
127 fallbackValue: string,
128 ): void {
129 if (!this.devMode) {
130 throw new Error(
131 'Refusing to write missing translations in production mode',
132 );
133 }
134 let languagesMapOrUndefined = this.writeQueue.get(namespace);
135 if (languagesMapOrUndefined === undefined) {
136 languagesMapOrUndefined = new Map();
137 this.writeQueue.set(namespace, languagesMapOrUndefined);
138 }
139 const languagesMap = languagesMapOrUndefined;
140 languages.forEach((language) => {
141 let keysMap = languagesMap.get(language);
142 if (keysMap === undefined) {
143 keysMap = new Map();
144 languagesMap.set(language, keysMap);
145 }
146 keysMap.set(key, fallbackValue);
147 });
148 this.flushWriteQueue();
149 }
150
151 /**
152 * Flushes new translations to the missing translations file.
153 *
154 * Will always fails if `devMode` is `false`.
155 * If no changes are made to the translations, the file won't be rewritten.
156 *
157 * @param language The language to add translations for.
158 * @param namespace The namespace to add translations for.
159 * @param entries The translations to add.
160 * @returns A promise that resolves when changes were flushed successfully.
161 */
162 private async flushChanges(
163 language: string,
164 namespace: string,
165 entries: Map<string, string>,
166 ): Promise<void> {
167 if (!this.devMode) {
168 throw new Error(
169 'Refusing to write missing translations in production mode',
170 );
171 }
172 const resourceContents = await this.repository.getMissingResourceContents(
173 language,
174 namespace,
175 );
176 const translations =
177 typeof resourceContents === 'object' ? resourceContents : {};
178 let changed = false;
179 entries.forEach((value, key) => {
180 const splitKey = this.keySeparator ? key.split(this.keySeparator) : [key];
181 let obj = translations;
182 /*
183 eslint-disable @typescript-eslint/no-unsafe-assignment,
184 security/detect-object-injection --
185 We need to modify raw objects, because this is the storage format for i18next.
186 To mitigate the prototype injection security risk,
187 we make sure to only ever run this in development mode.
188 */
189 for (let i = 0; i < splitKey.length - 1; i += 1) {
190 let nextObj = obj[splitKey[i]];
191 if (typeof nextObj !== 'object') {
192 nextObj = {};
193 obj[splitKey[i]] = nextObj;
194 }
195 obj = nextObj;
196 }
197 const lastKey = splitKey[splitKey.length - 1];
198 if (obj[lastKey] !== value) {
199 obj[lastKey] = value;
200 changed = true;
201 }
202 /*
203 eslint-enable @typescript-eslint/no-unsafe-assignment,
204 security/detect-object-injection
205 */
206 });
207 if (!changed) {
208 return;
209 }
210 await this.repository.setMissingResourceContents(
211 language,
212 namespace,
213 translations,
214 );
215 log.debug(
216 'Wrote',
217 entries.size,
218 'missing',
219 language,
220 'translations for namespace',
221 namespace,
222 );
223 }
224}
diff --git a/packages/main/src/i18n/i18nLog.ts b/packages/main/src/i18n/i18nLog.ts
new file mode 100644
index 0000000..33a8c4e
--- /dev/null
+++ b/packages/main/src/i18n/i18nLog.ts
@@ -0,0 +1,41 @@
1/*
2 * Copyright (C) 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
21import type { LoggerModule } from 'i18next';
22
23import { getLogger } from '../utils/log';
24
25const log = getLogger('i18nLog');
26
27/*
28 eslint-disable-next-line @typescript-eslint/unbound-method --
29 loglevel log methods don't rely on `this`, so this is safe,
30 and also keeps stacktraces smaller.
31*/
32const { debug, warn, error } = log;
33
34const i18nLog: LoggerModule = {
35 type: 'logger',
36 log: debug,
37 warn,
38 error,
39};
40
41export default i18nLog;
diff --git a/packages/main/src/i18n/impl/LocaltizationFiles.ts b/packages/main/src/i18n/impl/LocaltizationFiles.ts
new file mode 100644
index 0000000..73a769e
--- /dev/null
+++ b/packages/main/src/i18n/impl/LocaltizationFiles.ts
@@ -0,0 +1,93 @@
1/*
2 * Copyright (C) 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
21import { readFile, writeFile } from 'node:fs/promises';
22
23import type { ResourceKey } from 'i18next';
24
25import type Resources from '../../infrastructure/resources/Resources';
26import isErrno from '../../utils/isErrno';
27import { getLogger } from '../../utils/log';
28import type LocatlizationRepository from '../LocalizationRepository';
29
30const log = getLogger('LocalizationFiles');
31
32export default class LocalizationFiles implements LocatlizationRepository {
33 constructor(private readonly resources: Resources) {}
34
35 async getResourceContents(
36 language: string,
37 namespace: string,
38 ): Promise<ResourceKey> {
39 const fileName = this.resources.getLocalizationPath(
40 language,
41 `${namespace}.json`,
42 );
43 const contents = await readFile(fileName, 'utf8');
44 log.info('Read localization file', fileName);
45 // The contents of the file should come from a signed archive during production,
46 // so we don't need to (and can't) validate them.
47 return JSON.parse(contents) as ResourceKey;
48 }
49
50 private getMissingLocalizationPath(
51 language: string,
52 namespace: string,
53 ): string {
54 return this.resources.getLocalizationPath(
55 language,
56 `${namespace}.missing.json`,
57 );
58 }
59
60 async getMissingResourceContents(
61 language: string,
62 namespace: string,
63 ): Promise<ResourceKey> {
64 const fileName = this.getMissingLocalizationPath(language, namespace);
65 try {
66 const contents = await readFile(fileName, 'utf8');
67 // The contents of the file are only used during development,
68 // so we don't need to (and can't) validate them.
69 return JSON.parse(contents) as ResourceKey;
70 } catch (error) {
71 if (isErrno(error, 'ENOENT')) {
72 log.debug(
73 'No missing translations file',
74 language,
75 'for namespace',
76 namespace,
77 );
78 return {};
79 }
80 throw error;
81 }
82 }
83
84 setMissingResourceContents(
85 language: string,
86 namespace: string,
87 data: ResourceKey,
88 ): Promise<void> {
89 const fileName = this.getMissingLocalizationPath(language, namespace);
90 const contents = JSON.stringify(data, undefined, 2);
91 return writeFile(fileName, contents, 'utf8');
92 }
93}
diff --git a/packages/main/src/i18n/loadLocalization.ts b/packages/main/src/i18n/loadLocalization.ts
new file mode 100644
index 0000000..1408a30
--- /dev/null
+++ b/packages/main/src/i18n/loadLocalization.ts
@@ -0,0 +1,50 @@
1/*
2 * Copyright (C) 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
21import i18next from 'i18next';
22
23import type Resources from '../infrastructure/resources/Resources';
24import type MainStore from '../stores/MainStore';
25
26import I18nStore from './I18nStore';
27import RepositoryBasedI18nBackend from './RepositoryBasedI18nBackend';
28import i18nLog from './i18nLog';
29import LocalizationFiles from './impl/LocaltizationFiles';
30
31export default async function loadLocalization(
32 store: MainStore,
33 resources: Resources,
34 devMode: boolean,
35): Promise<void> {
36 const repository = new LocalizationFiles(resources);
37 const backend = new RepositoryBasedI18nBackend(repository, devMode);
38 const i18n = i18next
39 .createInstance({
40 lng: 'en',
41 fallbackLng: ['en'],
42 debug: devMode,
43 saveMissing: devMode,
44 })
45 .use(backend)
46 .use(i18nLog);
47 await i18n.init();
48 const i18nStore = new I18nStore(i18n);
49 store.setI18n(i18nStore);
50}
diff --git a/packages/main/src/infrastructure/config/impl/ConfigFile.ts b/packages/main/src/infrastructure/config/impl/ConfigFile.ts
index e8237b4..4ad0fcc 100644
--- a/packages/main/src/infrastructure/config/impl/ConfigFile.ts
+++ b/packages/main/src/infrastructure/config/impl/ConfigFile.ts
@@ -27,6 +27,7 @@ import { throttle } from 'lodash-es';
27 27
28import type Config from '../../../stores/config/Config'; 28import type Config from '../../../stores/config/Config';
29import type Disposer from '../../../utils/Disposer'; 29import type Disposer from '../../../utils/Disposer';
30import isErrno from '../../../utils/isErrno';
30import { getLogger } from '../../../utils/log'; 31import { getLogger } from '../../../utils/log';
31import type ConfigRepository from '../ConfigRepository'; 32import type ConfigRepository from '../ConfigRepository';
32import type { ReadConfigResult } from '../ConfigRepository'; 33import type { ReadConfigResult } from '../ConfigRepository';
@@ -55,7 +56,7 @@ export default class ConfigFile implements ConfigRepository {
55 try { 56 try {
56 configStr = await readFile(this.#configFilePath, 'utf8'); 57 configStr = await readFile(this.#configFilePath, 'utf8');
57 } catch (error) { 58 } catch (error) {
58 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 59 if (isErrno(error, 'ENOENT')) {
59 log.debug('Config file', this.#configFilePath, 'was not found'); 60 log.debug('Config file', this.#configFilePath, 'was not found');
60 return { found: false }; 61 return { found: false };
61 } 62 }
@@ -94,7 +95,7 @@ export default class ConfigFile implements ConfigRepository {
94 mtime = stats.mtime; 95 mtime = stats.mtime;
95 log.trace('Config file last modified at', mtime); 96 log.trace('Config file last modified at', mtime);
96 } catch (error) { 97 } catch (error) {
97 if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 98 if (isErrno(error, 'ENOENT')) {
98 log.debug( 99 log.debug(
99 'Config file', 100 'Config file',
100 this.#configFilePath, 101 this.#configFilePath,
diff --git a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
index 5166719..49bfbfd 100644
--- a/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
+++ b/packages/main/src/infrastructure/electron/impl/setApplicationMenu.ts
@@ -30,6 +30,12 @@ export default function setApplicationMenu(
30 isMac: boolean, 30 isMac: boolean,
31): void { 31): void {
32 const dispose = autorun(() => { 32 const dispose = autorun(() => {
33 const translation = store.useTranslation();
34 if (!translation.ready) {
35 return;
36 }
37 const { t } = translation;
38
33 const { settings, shared, visibleService } = store; 39 const { settings, shared, visibleService } = store;
34 const { showLocationBar, selectedService } = settings; 40 const { showLocationBar, selectedService } = settings;
35 const { canSwitchServices, services } = shared; 41 const { canSwitchServices, services } = shared;
@@ -42,7 +48,7 @@ export default function setApplicationMenu(
42 role: 'viewMenu', 48 role: 'viewMenu',
43 submenu: [ 49 submenu: [
44 { 50 {
45 label: 'Show Location Bar', 51 label: t<string>('menu.view.showLocationBar'),
46 accelerator: 'CommandOrControl+Shift+L', 52 accelerator: 'CommandOrControl+Shift+L',
47 type: 'checkbox', 53 type: 'checkbox',
48 checked: showLocationBar, 54 checked: showLocationBar,
@@ -52,7 +58,7 @@ export default function setApplicationMenu(
52 }, 58 },
53 { type: 'separator' }, 59 { type: 'separator' },
54 { 60 {
55 label: 'Reload', 61 label: t<string>('menu.view.reload'),
56 accelerator: 'CommandOrControl+R', 62 accelerator: 'CommandOrControl+R',
57 enabled: selectedService !== undefined, 63 enabled: selectedService !== undefined,
58 click() { 64 click() {
@@ -60,7 +66,7 @@ export default function setApplicationMenu(
60 }, 66 },
61 }, 67 },
62 { 68 {
63 label: 'Force Reload', 69 label: t<string>('menu.view.forceReload'),
64 accelerator: 'CommandOrControl+Shift+R', 70 accelerator: 'CommandOrControl+Shift+R',
65 enabled: selectedService !== undefined, 71 enabled: selectedService !== undefined,
66 click() { 72 click() {
@@ -68,7 +74,7 @@ export default function setApplicationMenu(
68 }, 74 },
69 }, 75 },
70 { 76 {
71 label: 'Toggle Developer Tools', 77 label: t<string>('menu.view.toggleDeveloperTools'),
72 accelerator: 'CommandOrControl+Shift+I', 78 accelerator: 'CommandOrControl+Shift+I',
73 enabled: visibleService !== undefined, 79 enabled: visibleService !== undefined,
74 click() { 80 click() {
@@ -80,12 +86,15 @@ export default function setApplicationMenu(
80 ? ([ 86 ? ([
81 { 87 {
82 role: 'forceReload', 88 role: 'forceReload',
83 label: 'Reload Sophie', 89 label: t<string>('menu.view.reloadSophie'),
84 accelerator: 'CommandOrControl+Shift+Alt+R', 90 accelerator: 'CommandOrControl+Shift+Alt+R',
85 }, 91 },
86 { 92 {
87 role: 'toggleDevTools', 93 role: 'toggleDevTools',
88 label: 'Toggle Sophie Developer Tools', 94 label: t<string>(
95 'menu.view.toggleSophieDeveloperTools',
96 'Toggle Sophie Developer Tools',
97 ),
89 accelerator: 'CommandOrControl+Shift+Alt+I', 98 accelerator: 'CommandOrControl+Shift+Alt+I',
90 }, 99 },
91 { type: 'separator' }, 100 { type: 'separator' },
@@ -95,10 +104,10 @@ export default function setApplicationMenu(
95 ], 104 ],
96 }, 105 },
97 { 106 {
98 label: 'Services', 107 label: t<string>('menu.servicesMenu'),
99 submenu: [ 108 submenu: [
100 { 109 {
101 label: 'Next Service', 110 label: t<string>('menu.services.nextService'),
102 accelerator: 'CommandOrControl+Tab', 111 accelerator: 'CommandOrControl+Tab',
103 enabled: canSwitchServices, 112 enabled: canSwitchServices,
104 click() { 113 click() {
@@ -106,7 +115,7 @@ export default function setApplicationMenu(
106 }, 115 },
107 }, 116 },
108 { 117 {
109 label: 'Previous Service', 118 label: t<string>('menu.services.previousService'),
110 accelerator: 'CommandOrControl+Shift+Tab', 119 accelerator: 'CommandOrControl+Shift+Tab',
111 enabled: canSwitchServices, 120 enabled: canSwitchServices,
112 click() { 121 click() {
@@ -140,7 +149,7 @@ export default function setApplicationMenu(
140 role: 'help', 149 role: 'help',
141 submenu: [ 150 submenu: [
142 { 151 {
143 label: 'Gitlab', 152 label: t<string>('menu.help.gitlab'),
144 click() { 153 click() {
145 store.openWebpageInBrowser(); 154 store.openWebpageInBrowser();
146 }, 155 },
diff --git a/packages/main/src/infrastructure/resources/Resources.ts b/packages/main/src/infrastructure/resources/Resources.ts
index 269c838..0e90c6b 100644
--- a/packages/main/src/infrastructure/resources/Resources.ts
+++ b/packages/main/src/infrastructure/resources/Resources.ts
@@ -21,6 +21,8 @@
21export default interface Resources { 21export default interface Resources {
22 getPath(packageName: string, relativePathInPackage: string): string; 22 getPath(packageName: string, relativePathInPackage: string): string;
23 23
24 getLocalizationPath(language: string, fileName: string): string;
25
24 getFileURL(packageName: string, relativePathInPackage: string): string; 26 getFileURL(packageName: string, relativePathInPackage: string): string;
25 27
26 getRendererURL(relativePathInRendererPackage: string): string; 28 getRendererURL(relativePathInRendererPackage: string): string;
diff --git a/packages/main/src/infrastructure/resources/impl/getDistResources.ts b/packages/main/src/infrastructure/resources/impl/getDistResources.ts
index f3c3f7b..3c1ffb3 100644
--- a/packages/main/src/infrastructure/resources/impl/getDistResources.ts
+++ b/packages/main/src/infrastructure/resources/impl/getDistResources.ts
@@ -33,6 +33,7 @@ export default function getDistResources(
33 devServerURL = import.meta.env?.VITE_DEV_SERVER_URL, 33 devServerURL = import.meta.env?.VITE_DEV_SERVER_URL,
34): Resources { 34): Resources {
35 const packagesRoot = path.join(thisDir, '..', '..'); 35 const packagesRoot = path.join(thisDir, '..', '..');
36 const localizationRoot = path.join(packagesRoot, '..', 'locales');
36 37
37 function getPath(packageName: string, relativePathInPackage: string): string { 38 function getPath(packageName: string, relativePathInPackage: string): string {
38 return path.join(packagesRoot, packageName, 'dist', relativePathInPackage); 39 return path.join(packagesRoot, packageName, 'dist', relativePathInPackage);
@@ -48,6 +49,9 @@ export default function getDistResources(
48 49
49 return { 50 return {
50 getPath, 51 getPath,
52 getLocalizationPath(language, fileName) {
53 return path.join(localizationRoot, language, fileName);
54 },
51 getFileURL, 55 getFileURL,
52 getRendererURL: 56 getRendererURL:
53 devMode && devServerURL !== undefined 57 devMode && devServerURL !== undefined
diff --git a/packages/main/src/initReactions.ts b/packages/main/src/initReactions.ts
index 9c49fc5..cdff551 100644
--- a/packages/main/src/initReactions.ts
+++ b/packages/main/src/initReactions.ts
@@ -20,6 +20,7 @@
20 20
21import { app, session } from 'electron'; 21import { app, session } from 'electron';
22 22
23import loadLocalization from './i18n/loadLocalization';
23import ConfigFile from './infrastructure/config/impl/ConfigFile'; 24import ConfigFile from './infrastructure/config/impl/ConfigFile';
24import UserAgents from './infrastructure/electron/UserAgents'; 25import UserAgents from './infrastructure/electron/UserAgents';
25import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory'; 26import ElectronViewFactory from './infrastructure/electron/impl/ElectronViewFactory';
@@ -52,7 +53,11 @@ export default async function initReactions(
52 } 53 }
53 const userAgents = new UserAgents(app.userAgentFallback); 54 const userAgents = new UserAgents(app.userAgentFallback);
54 app.userAgentFallback = userAgents.fallbackUserAgent(devMode); 55 app.userAgentFallback = userAgents.fallbackUserAgent(devMode);
55 setApplicationMenu(store, devMode, isMac); 56 const localizeInterface = async () => {
57 await loadLocalization(store, resources, devMode);
58 setApplicationMenu(store, devMode, isMac);
59 };
60 const localization = localizeInterface();
56 const viewFactory = new ElectronViewFactory(userAgents, resources, devMode); 61 const viewFactory = new ElectronViewFactory(userAgents, resources, devMode);
57 const [mainWindow] = await Promise.all([ 62 const [mainWindow] = await Promise.all([
58 viewFactory.createMainWindow(store), 63 viewFactory.createMainWindow(store),
@@ -60,6 +65,7 @@ export default async function initReactions(
60 ]); 65 ]);
61 store.setMainWindow(mainWindow); 66 store.setMainWindow(mainWindow);
62 loadServices(store, viewFactory); 67 loadServices(store, viewFactory);
68 await localization;
63 return () => { 69 return () => {
64 disposeNativeThemeController(); 70 disposeNativeThemeController();
65 disposeConfigController(); 71 disposeConfigController();
diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts
index 86fadcb..873f998 100644
--- a/packages/main/src/stores/MainStore.ts
+++ b/packages/main/src/stores/MainStore.ts
@@ -21,6 +21,8 @@
21import type { Action, BrowserViewBounds } from '@sophie/shared'; 21import type { Action, BrowserViewBounds } from '@sophie/shared';
22import { applySnapshot, Instance, types } from 'mobx-state-tree'; 22import { applySnapshot, Instance, types } from 'mobx-state-tree';
23 23
24import type I18nStore from '../i18n/I18nStore';
25import type { UseTranslationResult } from '../i18n/I18nStore';
24import type { MainWindow } from '../infrastructure/electron/types'; 26import type { MainWindow } from '../infrastructure/electron/types';
25import { getLogger } from '../utils/log'; 27import { getLogger } from '../utils/log';
26 28
@@ -44,6 +46,7 @@ const MainStore = types
44 {}, 46 {},
45 ), 47 ),
46 shared: types.optional(SharedStore, {}), 48 shared: types.optional(SharedStore, {}),
49 i18n: types.frozen<I18nStore | undefined>(),
47 }) 50 })
48 .views((self) => ({ 51 .views((self) => ({
49 get settings(): GlobalSettings { 52 get settings(): GlobalSettings {
@@ -61,6 +64,9 @@ const MainStore = types
61 ? selectedService 64 ? selectedService
62 : undefined; 65 : undefined;
63 }, 66 },
67 useTranslation(ns?: string): UseTranslationResult {
68 return self.i18n?.useTranslation(ns) ?? { ready: false };
69 },
64 })) 70 }))
65 .volatile( 71 .volatile(
66 (): { 72 (): {
@@ -124,6 +130,9 @@ const MainStore = types
124 beforeDestroy(): void { 130 beforeDestroy(): void {
125 self.mainWindow?.dispose(); 131 self.mainWindow?.dispose();
126 }, 132 },
133 setI18n(i18n: I18nStore): void {
134 self.i18n = i18n;
135 },
127 })); 136 }));
128 137
129/* 138/*
diff --git a/packages/main/src/utils/isErrno.ts b/packages/main/src/utils/isErrno.ts
new file mode 100644
index 0000000..8adbc33
--- /dev/null
+++ b/packages/main/src/utils/isErrno.ts
@@ -0,0 +1,28 @@
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
21export default function isErrno(error: unknown, code: string): boolean {
22 return (
23 typeof error === 'object' &&
24 error !== null &&
25 'code' in error &&
26 (error as NodeJS.ErrnoException).code === code
27 );
28}