From d07e7b834831230b53860d0919a68edc2d36193d Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Sat, 8 Jan 2022 21:36:43 +0100 Subject: build: Eslint fixes for multi-module project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Kristóf Marussy --- packages/main/.eslintrc.cjs | 6 + packages/main/.eslintrc.json | 6 - packages/main/esbuild.config.js | 8 +- packages/main/package.json | 2 +- packages/main/src/compositionRoot.ts | 38 --- .../main/src/controllers/__tests__/config.spec.ts | 185 --------------- .../src/controllers/__tests__/initConfig.spec.ts | 185 +++++++++++++++ .../controllers/__tests__/initNativeTheme.spec.ts | 71 ++++++ .../src/controllers/__tests__/nativeTheme.spec.ts | 71 ------ packages/main/src/controllers/config.ts | 90 ------- packages/main/src/controllers/initConfig.ts | 90 +++++++ packages/main/src/controllers/initNativeTheme.ts | 50 ++++ packages/main/src/controllers/nativeTheme.ts | 50 ---- packages/main/src/devTools.ts | 7 +- packages/main/src/index.ts | 109 +++++---- packages/main/src/init.ts | 38 +++ .../main/src/services/ConfigPersistenceService.ts | 6 +- .../services/impl/ConfigPersistenceServiceImpl.ts | 20 +- packages/main/src/stores/Config.ts | 2 +- packages/main/src/stores/MainStore.ts | 2 +- packages/main/src/stores/SharedStore.ts | 2 +- packages/main/src/utils/Disposer.ts | 25 ++ packages/main/src/utils/disposer.ts | 23 -- packages/main/src/utils/log.ts | 66 ++++++ packages/main/src/utils/logging.ts | 62 ----- packages/main/tsconfig.json | 10 +- packages/preload/.eslintrc.cjs | 6 + packages/preload/esbuild.config.js | 6 +- packages/preload/jest.config.js | 2 +- packages/preload/package.json | 6 +- .../src/contextBridge/SophieRendererImpl.ts | 96 -------- .../__tests__/SophieRendererImpl.spec.ts | 258 --------------------- .../__tests__/createSophieRenderer.spec.ts | 258 +++++++++++++++++++++ .../src/contextBridge/createSophieRenderer.ts | 96 ++++++++ packages/preload/src/index.ts | 2 +- packages/preload/tsconfig.json | 8 +- packages/renderer/.eslinrc.cjs | 11 + packages/renderer/.eslintrc.json | 5 - packages/renderer/package.json | 10 +- packages/renderer/src/components/App.tsx | 6 +- .../src/components/BrowserViewPlaceholder.tsx | 14 +- packages/renderer/src/components/Sidebar.tsx | 4 +- packages/renderer/src/components/StoreProvider.tsx | 2 +- packages/renderer/src/components/ThemeProvider.tsx | 8 +- .../src/components/ToggleDarkModeButton.tsx | 9 +- packages/renderer/src/devTools.ts | 21 +- packages/renderer/src/index.tsx | 13 +- packages/renderer/src/stores/RendererEnv.ts | 4 +- packages/renderer/src/stores/RendererStore.ts | 21 +- packages/renderer/src/utils/log.ts | 50 ++++ packages/renderer/tsconfig.json | 8 +- packages/renderer/vite.config.js | 5 +- packages/service-inject/.eslintrc.cjs | 6 + packages/service-inject/esbuild.config.js | 6 +- packages/service-inject/package.json | 6 +- packages/service-inject/tsconfig.json | 7 +- packages/service-preload/.eslintrc.cjs | 6 + packages/service-preload/esbuild.config.js | 6 +- packages/service-preload/package.json | 10 +- packages/service-preload/src/index.ts | 12 +- packages/service-preload/src/utils/log.ts | 49 ++++ packages/service-preload/tsconfig.json | 8 +- packages/service-preload/types/importMeta.d.ts | 7 + packages/service-shared/.eslintrc.cjs | 7 + packages/service-shared/esbuild.config.js | 6 +- packages/service-shared/package.json | 7 +- packages/service-shared/src/index.ts | 2 +- packages/service-shared/src/ipc.ts | 3 + packages/service-shared/tsconfig.build.json | 12 + packages/service-shared/tsconfig.json | 14 +- packages/shared/.eslintrc.cjs | 7 + packages/shared/esbuild.config.js | 6 +- packages/shared/package.json | 7 +- .../shared/src/contextBridge/SophieRenderer.ts | 7 +- packages/shared/src/index.ts | 5 +- packages/shared/tsconfig.build.json | 12 + packages/shared/tsconfig.json | 14 +- 77 files changed, 1294 insertions(+), 1091 deletions(-) create mode 100644 packages/main/.eslintrc.cjs delete mode 100644 packages/main/.eslintrc.json delete mode 100644 packages/main/src/compositionRoot.ts delete mode 100644 packages/main/src/controllers/__tests__/config.spec.ts create mode 100644 packages/main/src/controllers/__tests__/initConfig.spec.ts create mode 100644 packages/main/src/controllers/__tests__/initNativeTheme.spec.ts delete mode 100644 packages/main/src/controllers/__tests__/nativeTheme.spec.ts delete mode 100644 packages/main/src/controllers/config.ts create mode 100644 packages/main/src/controllers/initConfig.ts create mode 100644 packages/main/src/controllers/initNativeTheme.ts delete mode 100644 packages/main/src/controllers/nativeTheme.ts create mode 100644 packages/main/src/init.ts create mode 100644 packages/main/src/utils/Disposer.ts delete mode 100644 packages/main/src/utils/disposer.ts create mode 100644 packages/main/src/utils/log.ts delete mode 100644 packages/main/src/utils/logging.ts create mode 100644 packages/preload/.eslintrc.cjs delete mode 100644 packages/preload/src/contextBridge/SophieRendererImpl.ts delete mode 100644 packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts create mode 100644 packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts create mode 100644 packages/preload/src/contextBridge/createSophieRenderer.ts create mode 100644 packages/renderer/.eslinrc.cjs delete mode 100644 packages/renderer/.eslintrc.json create mode 100644 packages/renderer/src/utils/log.ts create mode 100644 packages/service-inject/.eslintrc.cjs create mode 100644 packages/service-preload/.eslintrc.cjs create mode 100644 packages/service-preload/src/utils/log.ts create mode 100644 packages/service-preload/types/importMeta.d.ts create mode 100644 packages/service-shared/.eslintrc.cjs create mode 100644 packages/service-shared/tsconfig.build.json create mode 100644 packages/shared/.eslintrc.cjs create mode 100644 packages/shared/tsconfig.build.json (limited to 'packages') diff --git a/packages/main/.eslintrc.cjs b/packages/main/.eslintrc.cjs new file mode 100644 index 0000000..548ea34 --- /dev/null +++ b/packages/main/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: true, + browser: false, + }, +}; diff --git a/packages/main/.eslintrc.json b/packages/main/.eslintrc.json deleted file mode 100644 index 6b736e2..0000000 --- a/packages/main/.eslintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "globals": { - "NodeJS": false, - "require": false - } -} diff --git a/packages/main/esbuild.config.js b/packages/main/esbuild.config.js index c24d6e1..49fba6b 100644 --- a/packages/main/esbuild.config.js +++ b/packages/main/esbuild.config.js @@ -1,8 +1,8 @@ -/* eslint-disable no-process-env */ import getRepoInfo from 'git-repo-info'; + import { node } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; const externalPackages = ['electron']; @@ -12,7 +12,7 @@ if (process.env.MODE !== 'development') { const gitInfo = getRepoInfo(); -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/main/package.json b/packages/main/package.json index e1b3f49..d9abf51 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -5,7 +5,7 @@ "type": "module", "types": "dist-types/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck" }, "dependencies": { "@sophie/service-shared": "workspace:*", diff --git a/packages/main/src/compositionRoot.ts b/packages/main/src/compositionRoot.ts deleted file mode 100644 index 76835a1..0000000 --- a/packages/main/src/compositionRoot.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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 { app } from 'electron'; - -import { initConfig } from './controllers/config'; -import { initNativeTheme } from './controllers/nativeTheme'; -import { ConfigPersistenceServiceImpl } from './services/impl/ConfigPersistenceServiceImpl'; -import { MainStore } from './stores/MainStore'; -import { Disposer } from './utils/disposer'; - -export async function init(store: MainStore): Promise { - const configPersistenceService = new ConfigPersistenceServiceImpl(app.getPath('userData')); - const disposeConfigController = await initConfig(store.config, configPersistenceService); - const disposeNativeThemeController = initNativeTheme(store); - - return () => { - disposeNativeThemeController(); - disposeConfigController(); - }; -} diff --git a/packages/main/src/controllers/__tests__/config.spec.ts b/packages/main/src/controllers/__tests__/config.spec.ts deleted file mode 100644 index eb67df0..0000000 --- a/packages/main/src/controllers/__tests__/config.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* - * 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 { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; -import ms from 'ms'; - -import { initConfig } from '../config'; -import type { ConfigPersistenceService } from '../../services/ConfigPersistenceService'; -import { Config, config as configModel } from '../../stores/Config'; -import { Disposer } from '../../utils/disposer'; -import { silenceLogger } from '../../utils/logging'; - -let config: Config; -let persistenceService: ConfigPersistenceService = { - readConfig: jest.fn(), - writeConfig: jest.fn(), - watchConfig: jest.fn(), -}; -let lessThanThrottleMs = ms('0.1s'); -let throttleMs = ms('1s'); - -beforeAll(() => { - jest.useFakeTimers(); - silenceLogger(); -}); - -beforeEach(() => { - config = configModel.create(); -}); - -describe('when initializing', () => { - describe('when there is no config file', () => { - beforeEach(() => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: false, - }); - }); - - it('should create a new config file', async () => { - await initConfig(config, persistenceService); - expect(persistenceService.writeConfig).toBeCalledTimes(1); - }); - - it('should bail if there is an an error creating the config file', async () => { - mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); - await expect(() => initConfig(config, persistenceService)).rejects.toBeInstanceOf(Error); - }); - }); - - describe('when there is a valid config file', () => { - beforeEach(() => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: 'dark', - }, - }); - }); - - it('should read the existing config file is there is one', async () => { - await initConfig(config, persistenceService); - expect(persistenceService.writeConfig).not.toBeCalled(); - expect(config.themeSource).toBe('dark'); - }); - - it('should bail if it cannot set up a watcher', async () => { - mocked(persistenceService.watchConfig).mockImplementationOnce(() => { - throw new Error('boo'); - }); - await expect(() => initConfig(config, persistenceService)).rejects.toBeInstanceOf(Error); - }); - }); - - it('should not apply an invalid config file', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: -1, - }, - }); - await initConfig(config, persistenceService); - expect(config.themeSource).not.toBe(-1); - }); - - it('should bail if it cannot determine whether there is a config file', async () => { - mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); - await expect(() => initConfig(config, persistenceService)).rejects.toBeInstanceOf(Error); - }); -}); - -describe('when it has loaded the config', () => { - let sutDisposer: Disposer; - let watcherDisposer: Disposer = jest.fn(); - let configChangedCallback: () => Promise; - - beforeEach(async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: {}, - }); - mocked(persistenceService.watchConfig).mockReturnValueOnce(watcherDisposer); - sutDisposer = await initConfig(config, persistenceService, throttleMs); - configChangedCallback = mocked(persistenceService.watchConfig).mock.calls[0][0]; - jest.resetAllMocks(); - }); - - it('should throttle saving changes to the config file', () => { - mocked(persistenceService.writeConfig).mockResolvedValue(undefined); - config.setThemeSource('dark'); - jest.advanceTimersByTime(lessThanThrottleMs); - config.setThemeSource('light'); - jest.advanceTimersByTime(throttleMs); - expect(persistenceService.writeConfig).toBeCalledTimes(1); - }); - - it('should handle config writing errors gracefully', () => { - mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); - config.setThemeSource('dark'); - jest.advanceTimersByTime(throttleMs); - expect(persistenceService.writeConfig).toBeCalledTimes(1); - }); - - it('should read the config file when it has changed', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: 'dark', - }, - }); - await configChangedCallback(); - // Do not write back the changes we have just read. - expect(persistenceService.writeConfig).not.toBeCalled(); - expect(config.themeSource).toBe('dark'); - }); - - it('should not apply an invalid config file when it has changed', async () => { - mocked(persistenceService.readConfig).mockResolvedValueOnce({ - found: true, - data: { - themeSource: -1, - }, - }); - await configChangedCallback(); - expect(config.themeSource).not.toBe(-1); - }); - - it('should handle config writing errors gracefully', async () => { - mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); - await configChangedCallback(); - }); - - describe('when it was disposed', () => { - beforeEach(() => { - sutDisposer(); - }); - - it('should dispose the watcher', () => { - expect(watcherDisposer).toBeCalled(); - }); - - it('should not listen to store changes any more', () => { - config.setThemeSource('dark'); - jest.advanceTimersByTime(2 * throttleMs); - expect(persistenceService.writeConfig).not.toBeCalled(); - }); - }); -}); diff --git a/packages/main/src/controllers/__tests__/initConfig.spec.ts b/packages/main/src/controllers/__tests__/initConfig.spec.ts new file mode 100644 index 0000000..e386a07 --- /dev/null +++ b/packages/main/src/controllers/__tests__/initConfig.spec.ts @@ -0,0 +1,185 @@ +/* + * 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 { jest } from '@jest/globals'; +import { mocked } from 'jest-mock'; +import ms from 'ms'; + +import type ConfigPersistenceService from '../../services/ConfigPersistenceService'; +import { Config, config as configModel } from '../../stores/Config'; +import type Disposer from '../../utils/Disposer'; +import { silenceLogger } from '../../utils/log'; +import initConfig from '../initConfig'; + +let config: Config; +const persistenceService: ConfigPersistenceService = { + readConfig: jest.fn(), + writeConfig: jest.fn(), + watchConfig: jest.fn(), +}; +const lessThanThrottleMs = ms('0.1s'); +const throttleMs = ms('1s'); + +beforeAll(() => { + jest.useFakeTimers(); + silenceLogger(); +}); + +beforeEach(() => { + config = configModel.create(); +}); + +describe('when initializing', () => { + describe('when there is no config file', () => { + beforeEach(() => { + mocked(persistenceService.readConfig).mockResolvedValueOnce({ + found: false, + }); + }); + + it('should create a new config file', async () => { + await initConfig(config, persistenceService); + expect(persistenceService.writeConfig).toBeCalledTimes(1); + }); + + it('should bail if there is an an error creating the config file', async () => { + mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); + await expect(() => initConfig(config, persistenceService)).rejects.toBeInstanceOf(Error); + }); + }); + + describe('when there is a valid config file', () => { + beforeEach(() => { + mocked(persistenceService.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: 'dark', + }, + }); + }); + + it('should read the existing config file is there is one', async () => { + await initConfig(config, persistenceService); + expect(persistenceService.writeConfig).not.toBeCalled(); + expect(config.themeSource).toBe('dark'); + }); + + it('should bail if it cannot set up a watcher', async () => { + mocked(persistenceService.watchConfig).mockImplementationOnce(() => { + throw new Error('boo'); + }); + await expect(() => initConfig(config, persistenceService)).rejects.toBeInstanceOf(Error); + }); + }); + + it('should not apply an invalid config file', async () => { + mocked(persistenceService.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: -1, + }, + }); + await initConfig(config, persistenceService); + expect(config.themeSource).not.toBe(-1); + }); + + it('should bail if it cannot determine whether there is a config file', async () => { + mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); + await expect(() => initConfig(config, persistenceService)).rejects.toBeInstanceOf(Error); + }); +}); + +describe('when it has loaded the config', () => { + let sutDisposer: Disposer; + const watcherDisposer: Disposer = jest.fn(); + let configChangedCallback: () => Promise; + + beforeEach(async () => { + mocked(persistenceService.readConfig).mockResolvedValueOnce({ + found: true, + data: {}, + }); + mocked(persistenceService.watchConfig).mockReturnValueOnce(watcherDisposer); + sutDisposer = await initConfig(config, persistenceService, throttleMs); + [[configChangedCallback]] = mocked(persistenceService.watchConfig).mock.calls; + jest.resetAllMocks(); + }); + + it('should throttle saving changes to the config file', () => { + mocked(persistenceService.writeConfig).mockResolvedValue(undefined); + config.setThemeSource('dark'); + jest.advanceTimersByTime(lessThanThrottleMs); + config.setThemeSource('light'); + jest.advanceTimersByTime(throttleMs); + expect(persistenceService.writeConfig).toBeCalledTimes(1); + }); + + it('should handle config writing errors gracefully', () => { + mocked(persistenceService.writeConfig).mockRejectedValue(new Error('boo')); + config.setThemeSource('dark'); + jest.advanceTimersByTime(throttleMs); + expect(persistenceService.writeConfig).toBeCalledTimes(1); + }); + + it('should read the config file when it has changed', async () => { + mocked(persistenceService.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: 'dark', + }, + }); + await configChangedCallback(); + // Do not write back the changes we have just read. + expect(persistenceService.writeConfig).not.toBeCalled(); + expect(config.themeSource).toBe('dark'); + }); + + it('should not apply an invalid config file when it has changed', async () => { + mocked(persistenceService.readConfig).mockResolvedValueOnce({ + found: true, + data: { + themeSource: -1, + }, + }); + await configChangedCallback(); + expect(config.themeSource).not.toBe(-1); + }); + + it('should handle config writing errors gracefully', async () => { + mocked(persistenceService.readConfig).mockRejectedValue(new Error('boo')); + await configChangedCallback(); + }); + + describe('when it was disposed', () => { + beforeEach(() => { + sutDisposer(); + }); + + it('should dispose the watcher', () => { + expect(watcherDisposer).toBeCalled(); + }); + + it('should not listen to store changes any more', () => { + config.setThemeSource('dark'); + jest.advanceTimersByTime(2 * throttleMs); + expect(persistenceService.writeConfig).not.toBeCalled(); + }); + }); +}); diff --git a/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts b/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts new file mode 100644 index 0000000..bd33f48 --- /dev/null +++ b/packages/main/src/controllers/__tests__/initNativeTheme.spec.ts @@ -0,0 +1,71 @@ +/* + * 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 { jest } from '@jest/globals'; +import { mocked } from 'jest-mock'; + +import { createMainStore, MainStore } from '../../stores/MainStore'; +import type Disposer from '../../utils/Disposer'; + +let shouldUseDarkColors = false; + +jest.unstable_mockModule('electron', () => ({ + nativeTheme: { + themeSource: 'system', + get shouldUseDarkColors() { + return shouldUseDarkColors; + }, + on: jest.fn(), + off: jest.fn(), + }, +})); + +const { nativeTheme } = await import('electron'); +const { default: initNativeTheme } = await import('../initNativeTheme'); + +let store: MainStore; +let disposeSut: Disposer; + +beforeEach(() => { + store = createMainStore(); + disposeSut = initNativeTheme(store); +}); + +it('should register a nativeTheme updated listener', () => { + expect(nativeTheme.on).toBeCalledWith('updated', expect.anything()); +}); + +it('should synchronize themeSource changes to the nativeTheme', () => { + store.config.setThemeSource('dark'); + expect(nativeTheme.themeSource).toBe('dark'); +}); + +it('should synchronize shouldUseDarkColors changes to the store', () => { + const listener = mocked(nativeTheme.on).mock.calls.find(([event]) => event === 'updated')![1]; + shouldUseDarkColors = true; + listener(); + expect(store.shared.shouldUseDarkColors).toBe(true); +}); + +it('should remove the listener on dispose', () => { + const listener = mocked(nativeTheme.on).mock.calls.find(([event]) => event === 'updated')![1]; + disposeSut(); + expect(nativeTheme.off).toBeCalledWith('updated', listener); +}); diff --git a/packages/main/src/controllers/__tests__/nativeTheme.spec.ts b/packages/main/src/controllers/__tests__/nativeTheme.spec.ts deleted file mode 100644 index 85d6dd2..0000000 --- a/packages/main/src/controllers/__tests__/nativeTheme.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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 { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; - -import { createMainStore, MainStore } from '../../stores/MainStore'; -import { Disposer } from '../../utils/disposer'; - -let shouldUseDarkColors = false; - -jest.unstable_mockModule('electron', () => ({ - nativeTheme: { - themeSource: 'system', - get shouldUseDarkColors() { - return shouldUseDarkColors; - }, - on: jest.fn(), - off: jest.fn(), - }, -})); - -const { nativeTheme } = await import('electron'); -const { initNativeTheme } = await import('../nativeTheme'); - -let store: MainStore; -let disposeSut: Disposer; - -beforeEach(() => { - store = createMainStore(); - disposeSut = initNativeTheme(store); -}); - -it('should register a nativeTheme updated listener', () => { - expect(nativeTheme.on).toBeCalledWith('updated', expect.anything()); -}); - -it('should synchronize themeSource changes to the nativeTheme', () => { - store.config.setThemeSource('dark'); - expect(nativeTheme.themeSource).toBe('dark'); -}); - -it('should synchronize shouldUseDarkColors changes to the store', () => { - const listener = mocked(nativeTheme.on).mock.calls.find(([event]) => event === 'updated')![1]; - shouldUseDarkColors = true; - listener(); - expect(store.shared.shouldUseDarkColors).toBe(true); -}); - -it('should remove the listener on dispose', () => { - const listener = mocked(nativeTheme.on).mock.calls.find(([event]) => event === 'updated')![1]; - disposeSut(); - expect(nativeTheme.off).toBeCalledWith('updated', listener); -}); diff --git a/packages/main/src/controllers/config.ts b/packages/main/src/controllers/config.ts deleted file mode 100644 index deaeac2..0000000 --- a/packages/main/src/controllers/config.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * 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 { debounce } from 'lodash-es'; -import ms from 'ms'; -import { applySnapshot, getSnapshot, onSnapshot } from 'mobx-state-tree'; - -import type { ConfigPersistenceService } from '../services/ConfigPersistenceService.js'; -import type { Config, ConfigSnapshotOut } from '../stores/Config.js'; -import { Disposer } from '../utils/disposer'; -import { getLogger } from '../utils/logging'; - -const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s'); - -const log = getLogger('config'); - -export async function initConfig( - config: Config, - persistenceService: ConfigPersistenceService, - debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, -): Promise { - log.trace('Initializing config controller'); - - let lastSnapshotOnDisk: ConfigSnapshotOut | null = null; - - async function readConfig(): Promise { - const result = await persistenceService.readConfig(); - if (result.found) { - try { - applySnapshot(config, result.data); - lastSnapshotOnDisk = getSnapshot(config); - } catch (err) { - log.error('Failed to apply config snapshot', result.data, err); - } - } - return result.found; - } - - async function writeConfig(): Promise { - const snapshot = getSnapshot(config); - await persistenceService.writeConfig(snapshot); - lastSnapshotOnDisk = snapshot; - } - - if (!await readConfig()) { - log.info('Config file was not found'); - await writeConfig(); - log.info('Created config file'); - } - - const disposeOnSnapshot = onSnapshot(config, debounce((snapshot) => { - // We can compare snapshots by reference, since it is only recreated on store changes. - if (lastSnapshotOnDisk !== snapshot) { - writeConfig().catch((err) => { - log.error('Failed to write config on config change', err); - }); - } - }, debounceTime)); - - const disposeWatcher = persistenceService.watchConfig(async () => { - try { - await readConfig(); - } catch (err) { - log.error('Failed to read config', err); - } - }, debounceTime); - - return () => { - log.trace('Disposing config controller'); - disposeWatcher(); - disposeOnSnapshot(); - }; -} diff --git a/packages/main/src/controllers/initConfig.ts b/packages/main/src/controllers/initConfig.ts new file mode 100644 index 0000000..1d40762 --- /dev/null +++ b/packages/main/src/controllers/initConfig.ts @@ -0,0 +1,90 @@ +/* + * 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 { debounce } from 'lodash-es'; +import { applySnapshot, getSnapshot, onSnapshot } from 'mobx-state-tree'; +import ms from 'ms'; + +import type ConfigPersistenceService from '../services/ConfigPersistenceService'; +import type { Config, ConfigSnapshotOut } from '../stores/Config'; +import type Disposer from '../utils/Disposer'; +import { getLogger } from '../utils/log'; + +const DEFAULT_CONFIG_DEBOUNCE_TIME = ms('1s'); + +const log = getLogger('config'); + +export default async function initConfig( + config: Config, + persistenceService: ConfigPersistenceService, + debounceTime: number = DEFAULT_CONFIG_DEBOUNCE_TIME, +): Promise { + log.trace('Initializing config controller'); + + let lastSnapshotOnDisk: ConfigSnapshotOut | null = null; + + async function readConfig(): Promise { + const result = await persistenceService.readConfig(); + if (result.found) { + try { + applySnapshot(config, result.data); + lastSnapshotOnDisk = getSnapshot(config); + } catch (err) { + log.error('Failed to apply config snapshot', result.data, err); + } + } + return result.found; + } + + async function writeConfig(): Promise { + const snapshot = getSnapshot(config); + await persistenceService.writeConfig(snapshot); + lastSnapshotOnDisk = snapshot; + } + + if (!await readConfig()) { + log.info('Config file was not found'); + await writeConfig(); + log.info('Created config file'); + } + + const disposeOnSnapshot = onSnapshot(config, debounce((snapshot) => { + // We can compare snapshots by reference, since it is only recreated on store changes. + if (lastSnapshotOnDisk !== snapshot) { + writeConfig().catch((err) => { + log.error('Failed to write config on config change', err); + }); + } + }, debounceTime)); + + const disposeWatcher = persistenceService.watchConfig(async () => { + try { + await readConfig(); + } catch (err) { + log.error('Failed to read config', err); + } + }, debounceTime); + + return () => { + log.trace('Disposing config controller'); + disposeWatcher(); + disposeOnSnapshot(); + }; +} diff --git a/packages/main/src/controllers/initNativeTheme.ts b/packages/main/src/controllers/initNativeTheme.ts new file mode 100644 index 0000000..d2074ab --- /dev/null +++ b/packages/main/src/controllers/initNativeTheme.ts @@ -0,0 +1,50 @@ +/* + * 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 { nativeTheme } from 'electron'; +import { autorun } from 'mobx'; + +import type { MainStore } from '../stores/MainStore'; +import type Disposer from '../utils/Disposer'; +import { getLogger } from '../utils/log'; + +const log = getLogger('nativeTheme'); + +export default function initNativeTheme(store: MainStore): Disposer { + log.trace('Initializing nativeTheme controller'); + + const disposeThemeSourceReaction = autorun(() => { + nativeTheme.themeSource = store.config.themeSource; + log.debug('Set theme source:', store.config.themeSource); + }); + + store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); + const shouldUseDarkColorsListener = () => { + store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); + log.debug('Set should use dark colors:', nativeTheme.shouldUseDarkColors); + }; + nativeTheme.on('updated', shouldUseDarkColorsListener); + + return () => { + log.trace('Disposing nativeTheme controller'); + nativeTheme.off('updated', shouldUseDarkColorsListener); + disposeThemeSourceReaction(); + }; +} diff --git a/packages/main/src/controllers/nativeTheme.ts b/packages/main/src/controllers/nativeTheme.ts deleted file mode 100644 index ccd12d8..0000000 --- a/packages/main/src/controllers/nativeTheme.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 { nativeTheme } from 'electron'; -import { autorun } from 'mobx'; - -import type { MainStore } from '../stores/MainStore.js'; -import { Disposer } from '../utils/disposer'; -import { getLogger } from '../utils/logging'; - -const log = getLogger('nativeTheme'); - -export function initNativeTheme(store: MainStore): Disposer { - log.trace('Initializing nativeTheme controller'); - - const disposeThemeSourceReaction = autorun(() => { - nativeTheme.themeSource = store.config.themeSource; - log.debug('Set theme source:', store.config.themeSource); - }); - - store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); - const shouldUseDarkColorsListener = () => { - store.setShouldUseDarkColors(nativeTheme.shouldUseDarkColors); - log.debug('Set should use dark colors:', nativeTheme.shouldUseDarkColors); - }; - nativeTheme.on('updated', shouldUseDarkColorsListener); - - return () => { - log.trace('Disposing nativeTheme controller'); - nativeTheme.off('updated', shouldUseDarkColorsListener); - disposeThemeSourceReaction(); - }; -} diff --git a/packages/main/src/devTools.ts b/packages/main/src/devTools.ts index 398904c..0486c36 100644 --- a/packages/main/src/devTools.ts +++ b/packages/main/src/devTools.ts @@ -46,7 +46,12 @@ export async function installDevToolsExtensions(): Promise { default: installExtension, REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS, - } = require('electron-devtools-installer'); + /* eslint-disable-next-line + import/no-extraneous-dependencies, + global-require, + @typescript-eslint/no-var-requires + */ + } = require('electron-devtools-installer') as typeof import('electron-devtools-installer'); await installExtension( [ REACT_DEVELOPER_TOOLS, diff --git a/packages/main/src/index.ts b/packages/main/src/index.ts index d0191b7..bc10b4c 100644 --- a/packages/main/src/index.ts +++ b/packages/main/src/index.ts @@ -19,18 +19,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { - app, - BrowserView, - BrowserWindow, - ipcMain, -} from 'electron'; import { arch } from 'os'; -import osName from 'os-name'; -import { ensureDirSync, readFile, readFileSync } from 'fs-extra'; -import { autorun } from 'mobx'; -import { getSnapshot, onPatch } from 'mobx-state-tree'; import { join } from 'path'; +import { URL } from 'url'; + import { ServiceToMainIpcMessage, unreadCount, @@ -41,18 +33,30 @@ import { MainToRendererIpcMessage, RendererToMainIpcMessage, } from '@sophie/shared'; -import { URL } from 'url'; +import { + app, + BrowserView, + BrowserWindow, + ipcMain, +} from 'electron'; +import { ensureDirSync, readFile, readFileSync } from 'fs-extra'; +import { autorun } from 'mobx'; +import { getSnapshot, onPatch } from 'mobx-state-tree'; +import osName from 'os-name'; -import { init } from './compositionRoot'; import { DEVMODE_ALLOWED_URL_PREFIXES, installDevToolsExtensions, openDevToolsWhenReady, } from './devTools'; +import init from './init'; import { createMainStore } from './stores/MainStore'; +import { getLogger } from './utils/log'; const isDevelopment = import.meta.env.MODE === 'development'; +const log = getLogger('index'); + // Always enable sandboxing. app.enableSandbox(); @@ -93,7 +97,7 @@ app.setAboutPanelOptions({ `Node.js: ${process.versions.node}`, `Platform: ${osName()}`, `Arch: ${arch()}`, - `Build date: ${new Date(Number(import.meta.env.BUILD_DATE))}`, + `Build date: ${new Date(Number(import.meta.env.BUILD_DATE)).toLocaleString()}`, `Git SHA: ${import.meta.env.GIT_SHA}`, `Git branch: ${import.meta.env.GIT_BRANCH}`, ].join('\n'), @@ -109,9 +113,9 @@ function getResourceUrl(relativePath: string): string { return new URL(relativePath, baseUrl).toString(); } -let serviceInjectRelativePath = '../../service-inject/dist/index.js'; -let serviceInjectPath = getResourcePath(serviceInjectRelativePath); -let serviceInject: WebSource = { +const serviceInjectRelativePath = '../../service-inject/dist/index.js'; +const serviceInjectPath = getResourcePath(serviceInjectRelativePath); +const serviceInject: WebSource = { code: readFileSync(serviceInjectPath, 'utf8'), url: getResourceUrl(serviceInjectRelativePath), }; @@ -122,7 +126,7 @@ const store = createMainStore(); init(store).then((disposeCompositionRoot) => { app.on('will-quit', disposeCompositionRoot); }).catch((err) => { - console.log('Failed to initialize application', err); + log.log('Failed to initialize application', err); }); const rendererBaseUrl = getResourceUrl('../renderer/'); @@ -211,7 +215,7 @@ async function createWindow(): Promise { ipcMain.handle(RendererToMainIpcMessage.GetSharedStoreSnapshot, (event) => { if (event.sender.id !== webContents.id) { - console.warn( + log.warn( 'Unexpected', RendererToMainIpcMessage.GetSharedStoreSnapshot, 'from webContents', @@ -224,7 +228,7 @@ async function createWindow(): Promise { ipcMain.on(RendererToMainIpcMessage.DispatchAction, (event, rawAction) => { if (event.sender.id !== webContents.id) { - console.warn( + log.warn( 'Unexpected', RendererToMainIpcMessage.DispatchAction, 'from webContents', @@ -242,17 +246,26 @@ async function createWindow(): Promise { store.config.setThemeSource(actionToDispatch.themeSource); break; case 'reload-all-services': - readFile(serviceInjectPath, 'utf8').then((data) => { - serviceInject.code = data; - }).catch((err) => { - console.error('Error while reloading', serviceInjectPath, err); - }).then(() => { - browserView.webContents.reload(); - }); + readFile(serviceInjectPath, 'utf8') + .then((data) => { + serviceInject.code = data; + }) + .catch((err) => { + log.error('Error while reloading', serviceInjectPath, err); + }) + .then(() => { + browserView.webContents.reload(); + }) + .catch((err) => { + log.error('Failed to reload browserView', err); + }); + break; + default: + log.error('Unexpected action from UI renderer:', actionToDispatch); break; } } catch (err) { - console.error('Error while dispatching renderer action', rawAction, err); + log.error('Error while dispatching renderer action', rawAction, err); } }); @@ -260,11 +273,10 @@ async function createWindow(): Promise { webContents.send(MainToRendererIpcMessage.SharedStorePatch, patch); }); - ipcMain.handle(ServiceToMainIpcMessage.ApiExposedInMainWorld, (event) => { - return event.sender.id === browserView.webContents.id - ? serviceInject - : null; - }); + ipcMain.handle( + ServiceToMainIpcMessage.ApiExposedInMainWorld, + (event) => (event.sender.id === browserView.webContents.id ? serviceInject : null), + ); browserView.webContents.on('ipc-message', (_event, channel, ...args) => { try { @@ -274,14 +286,14 @@ async function createWindow(): Promise { // otherwise electron emits a no handler registered warning. break; case ServiceToMainIpcMessage.SetUnreadCount: - console.log('Unread count:', unreadCount.parse(args[0])); + log.log('Unread count:', unreadCount.parse(args[0])); break; default: - console.error('Unknown IPC message:', channel, args); + log.error('Unknown IPC message:', channel, args); break; } } catch (err) { - console.error('Error while processing IPC message:', channel, args, err); + log.error('Error while processing IPC message:', channel, args, err); } }); @@ -291,17 +303,22 @@ async function createWindow(): Promise { }, ); - browserView.webContents.session.webRequest.onBeforeSendHeaders(({ url, requestHeaders }, callback) => { - if (url.match(/^[^:]+:\/\/accounts\.google\.[^.\/]+\//)) { - requestHeaders['User-Agent'] = chromelessUserAgent; - } else { - requestHeaders['User-Agent'] = userAgent; - } - callback({ requestHeaders }); - }); + browserView.webContents.session.webRequest.onBeforeSendHeaders( + ({ url, requestHeaders }, callback) => { + const requestUserAgent = url.match(/^[^:]+:\/\/accounts\.google\.[^./]+\//) + ? chromelessUserAgent + : userAgent; + callback({ + requestHeaders: { + ...requestHeaders, + 'User-Agent': requestUserAgent, + }, + }); + }, + ); browserView.webContents.loadURL('https://gitlab.com/say-hi-to-sophie/sophie').catch((err) => { - console.error('Failed to load browser', err); + log.error('Failed to load browser', err); }); return mainWindow.loadURL(pageUrl); @@ -330,12 +347,12 @@ app.whenReady().then(async () => { try { await installDevToolsExtensions(); } catch (err) { - console.error('Failed to install devtools extensions', err); + log.error('Failed to install devtools extensions', err); } } return createWindow(); }).catch((err) => { - console.error('Failed to create window', err); + log.error('Failed to create window', err); process.exit(1); }); diff --git a/packages/main/src/init.ts b/packages/main/src/init.ts new file mode 100644 index 0000000..4487cc4 --- /dev/null +++ b/packages/main/src/init.ts @@ -0,0 +1,38 @@ +/* + * 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 { app } from 'electron'; + +import initConfig from './controllers/initConfig'; +import initNativeTheme from './controllers/initNativeTheme'; +import ConfigPersistenceServiceImpl from './services/impl/ConfigPersistenceServiceImpl'; +import { MainStore } from './stores/MainStore'; +import type Disposer from './utils/Disposer'; + +export default async function init(store: MainStore): Promise { + const configPersistenceService = new ConfigPersistenceServiceImpl(app.getPath('userData')); + const disposeConfigController = await initConfig(store.config, configPersistenceService); + const disposeNativeThemeController = initNativeTheme(store); + + return () => { + disposeNativeThemeController(); + disposeConfigController(); + }; +} diff --git a/packages/main/src/services/ConfigPersistenceService.ts b/packages/main/src/services/ConfigPersistenceService.ts index aed0ba3..7d508c5 100644 --- a/packages/main/src/services/ConfigPersistenceService.ts +++ b/packages/main/src/services/ConfigPersistenceService.ts @@ -18,12 +18,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ConfigSnapshotOut } from '../stores/Config'; -import { Disposer } from '../utils/disposer'; +import type { ConfigSnapshotOut } from '../stores/Config'; +import type Disposer from '../utils/Disposer'; export type ReadConfigResult = { found: true; data: unknown; } | { found: false; }; -export interface ConfigPersistenceService { +export default interface ConfigPersistenceService { readConfig(): Promise; writeConfig(configSnapshot: ConfigSnapshotOut): Promise; diff --git a/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts b/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts index 2d19632..df8c807 100644 --- a/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts +++ b/packages/main/src/services/impl/ConfigPersistenceServiceImpl.ts @@ -19,18 +19,20 @@ */ import { watch } from 'fs'; import { readFile, stat, writeFile } from 'fs/promises'; -import JSON5 from 'json5'; -import { throttle } from 'lodash-es'; import { join } from 'path'; -import type { ConfigPersistenceService, ReadConfigResult } from '../ConfigPersistenceService.js'; -import type { ConfigSnapshotOut } from '../../stores/Config.js'; -import { Disposer } from '../../utils/disposer'; -import { getLogger } from '../../utils/logging'; +import JSON5 from 'json5'; +import throttle from 'lodash-es/throttle'; + +import type { ConfigSnapshotOut } from '../../stores/Config'; +import type Disposer from '../../utils/Disposer'; +import { getLogger } from '../../utils/log'; +import type ConfigPersistenceService from '../ConfigPersistenceService'; +import type { ReadConfigResult } from '../ConfigPersistenceService'; const log = getLogger('configPersistence'); -export class ConfigPersistenceServiceImpl implements ConfigPersistenceService { +export default class ConfigPersistenceServiceImpl implements ConfigPersistenceService { private readonly configFilePath: string; private writingConfig = false; @@ -103,7 +105,7 @@ export class ConfigPersistenceServiceImpl implements ConfigPersistenceService { 'whish is newer than last written', this.timeLastWritten, ); - return callback(); + await callback(); } }, throttleMs); @@ -115,7 +117,7 @@ export class ConfigPersistenceServiceImpl implements ConfigPersistenceService { if (eventType === 'change' && (filename === this.configFileName || filename === null)) { configChanged()?.catch((err) => { - console.log('Unhandled error while listening for config changes', err); + log.error('Unhandled error while listening for config changes', err); }); } }); diff --git a/packages/main/src/stores/Config.ts b/packages/main/src/stores/Config.ts index 7d1168f..06dbdeb 100644 --- a/packages/main/src/stores/Config.ts +++ b/packages/main/src/stores/Config.ts @@ -18,13 +18,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Instance } from 'mobx-state-tree'; import { config as originalConfig, ConfigSnapshotIn, ConfigSnapshotOut, ThemeSource, } from '@sophie/shared'; +import { Instance } from 'mobx-state-tree'; export const config = originalConfig.actions((self) => ({ setThemeSource(mode: ThemeSource) { diff --git a/packages/main/src/stores/MainStore.ts b/packages/main/src/stores/MainStore.ts index f8a09d6..7b26c52 100644 --- a/packages/main/src/stores/MainStore.ts +++ b/packages/main/src/stores/MainStore.ts @@ -18,8 +18,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { applySnapshot, Instance, types } from 'mobx-state-tree'; import { BrowserViewBounds } from '@sophie/shared'; +import { applySnapshot, Instance, types } from 'mobx-state-tree'; import type { Config } from './Config.js'; import { sharedStore } from './SharedStore'; diff --git a/packages/main/src/stores/SharedStore.ts b/packages/main/src/stores/SharedStore.ts index e20150d..c023fc7 100644 --- a/packages/main/src/stores/SharedStore.ts +++ b/packages/main/src/stores/SharedStore.ts @@ -18,8 +18,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Instance, types } from 'mobx-state-tree'; import { sharedStore as originalSharedStore } from '@sophie/shared'; +import { Instance, types } from 'mobx-state-tree'; import { config } from './Config'; diff --git a/packages/main/src/utils/Disposer.ts b/packages/main/src/utils/Disposer.ts new file mode 100644 index 0000000..2e0ca25 --- /dev/null +++ b/packages/main/src/utils/Disposer.ts @@ -0,0 +1,25 @@ +/* + * 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 { IDisposer } from 'mobx-state-tree'; + +type Disposer = IDisposer; + +export default Disposer; diff --git a/packages/main/src/utils/disposer.ts b/packages/main/src/utils/disposer.ts deleted file mode 100644 index 0d469dd..0000000 --- a/packages/main/src/utils/disposer.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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 { IDisposer } from 'mobx-state-tree'; - -export type Disposer = IDisposer; diff --git a/packages/main/src/utils/log.ts b/packages/main/src/utils/log.ts new file mode 100644 index 0000000..c704797 --- /dev/null +++ b/packages/main/src/utils/log.ts @@ -0,0 +1,66 @@ +/* + * 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 chalk, { ChalkInstance } from 'chalk'; +import loglevel, { Logger } from 'loglevel'; +import prefix from 'loglevel-plugin-prefix'; + +if (import.meta.env?.DEV) { + loglevel.setLevel('debug'); +} else { + loglevel.setLevel('info'); +} + +const COLORS: Partial> = { + TRACE: chalk.magenta, + DEBUG: chalk.cyan, + INFO: chalk.blue, + WARN: chalk.yellow, + ERROR: chalk.red, + CRITICAL: chalk.red, +}; + +function getColor(level: string): ChalkInstance { + return COLORS[level] ?? chalk.gray; +} + +prefix.reg(loglevel); +prefix.apply(loglevel, { + format(level, name, timestamp) { + const levelColor = getColor(level); + const timeStr = timestamp.toString(); + const nameStr = typeof name === 'undefined' + ? levelColor(':') + : ` ${chalk.green(`${name}:`)}`; + return `${chalk.gray(`[${timeStr}]`)} ${levelColor(level)}${nameStr}`; + }, +}); + +export function getLogger(loggerName: string): Logger { + return loglevel.getLogger(loggerName); +} + +export function silenceLogger(): void { + loglevel.disableAll(); + const loggers = loglevel.getLoggers(); + Object.keys(loggers).forEach((loggerName) => { + loggers[loggerName].disableAll(); + }); +} diff --git a/packages/main/src/utils/logging.ts b/packages/main/src/utils/logging.ts deleted file mode 100644 index f703749..0000000 --- a/packages/main/src/utils/logging.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * 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 chalk, { ChalkInstance } from 'chalk'; -import loglevel, { Logger } from 'loglevel'; -import prefix from 'loglevel-plugin-prefix'; - -if (import.meta.env?.DEV) { - loglevel.setLevel('debug'); -} else { - loglevel.setLevel('info'); -} - -const COLORS: Partial> = { - TRACE: chalk.magenta, - DEBUG: chalk.cyan, - INFO: chalk.blue, - WARN: chalk.yellow, - ERROR: chalk.red, - CRITICAL: chalk.red, -}; - -function getColor(level: string): ChalkInstance { - return COLORS[level] ?? chalk.gray; -} - -prefix.reg(loglevel); -prefix.apply(loglevel, { - format(level, name, timestamp) { - const levelColor = getColor(level); - return `${chalk.gray(`[${timestamp}]`)} ${levelColor(level)} ${chalk.green(`${name}:`)}`; - }, -}); - -export function getLogger(loggerName: string): Logger { - return loglevel.getLogger(loggerName); -} - -export function silenceLogger(): void { - loglevel.disableAll(); - const loggers = loglevel.getLoggers(); - for (const loggerName of Object.keys(loggers)) { - loggers[loggerName].disableAll(); - } -} diff --git a/packages/main/tsconfig.json b/packages/main/tsconfig.json index 1401445..00a1985 100644 --- a/packages/main/tsconfig.json +++ b/packages/main/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../config/tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": [ @@ -9,14 +9,16 @@ }, "references": [ { - "path": "../service-shared" + "path": "../service-shared/tsconfig.build.json" }, { - "path": "../shared" + "path": "../shared/tsconfig.build.json" } ], "include": [ "src/**/*.ts", - "types/**/*.d.ts" + "types/**/*.d.ts", + "esbuild.config.js", + "jest.config.js" ] } diff --git a/packages/preload/.eslintrc.cjs b/packages/preload/.eslintrc.cjs new file mode 100644 index 0000000..02fab21 --- /dev/null +++ b/packages/preload/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: true, + browser: true, + }, +}; diff --git a/packages/preload/esbuild.config.js b/packages/preload/esbuild.config.js index b73a071..66f5e84 100644 --- a/packages/preload/esbuild.config.js +++ b/packages/preload/esbuild.config.js @@ -1,8 +1,8 @@ import { chrome } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/preload/jest.config.js b/packages/preload/jest.config.js index e474c4c..27af475 100644 --- a/packages/preload/jest.config.js +++ b/packages/preload/jest.config.js @@ -1,6 +1,6 @@ import rootConfig from '../../config/jest.config.base.js'; -/** @type {import('ts-jest').InitialOptionsTsJest} */ +/** @type {import('@jest/types').Config.InitialOptions} */ export default { ...rootConfig, testEnvironment: 'jsdom', diff --git a/packages/preload/package.json b/packages/preload/package.json index 0957aaf..a03d7d9 100644 --- a/packages/preload/package.json +++ b/packages/preload/package.json @@ -6,7 +6,7 @@ "type": "module", "types": "dist-types/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck" }, "dependencies": { "@sophie/shared": "workspace:*", @@ -20,8 +20,6 @@ "@types/jest": "^27.4.0", "jest": "^27.4.7", "jest-mock": "^27.4.6", - "jsdom": "^19.0.0", - "rimraf": "^3.0.2", - "typescript": "^4.5.4" + "jsdom": "^19.0.0" } } diff --git a/packages/preload/src/contextBridge/SophieRendererImpl.ts b/packages/preload/src/contextBridge/SophieRendererImpl.ts deleted file mode 100644 index f3c07c5..0000000 --- a/packages/preload/src/contextBridge/SophieRendererImpl.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { ipcRenderer } from 'electron'; -import log from 'loglevel'; -import type { IJsonPatch } from 'mobx-state-tree'; -import { - Action, - action, - MainToRendererIpcMessage, - RendererToMainIpcMessage, - sharedStore, - SharedStoreListener, - SophieRenderer, -} from '@sophie/shared'; - -class SophieRendererImpl implements SophieRenderer { - private onSharedStoreChangeCalled: boolean = false; - - private listener: SharedStoreListener | null = null; - - constructor(private readonly allowReplaceListener: boolean) { - ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { - try { - // `mobx-state-tree` will validate the patch, so we can safely cast here. - this.listener?.onPatch(patch as IJsonPatch); - } catch (err) { - log.error('Shared store listener onPatch failed', err); - this.listener = null; - } - }); - } - - async onSharedStoreChange(listener: SharedStoreListener): Promise { - if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { - throw new Error('Shared store change listener was already set'); - } - this.onSharedStoreChangeCalled = true; - let success = false; - let snapshot: unknown | null = null; - try { - snapshot = await ipcRenderer.invoke(RendererToMainIpcMessage.GetSharedStoreSnapshot); - success = true; - } catch (err) { - log.error('Failed to get initial shared store snapshot', err); - } - if (success) { - if (sharedStore.is(snapshot)) { - listener.onSnapshot(snapshot); - this.listener = listener; - return; - } - log.error('Got invalid initial shared store snapshot', snapshot); - } - throw new Error('Failed to connect to shared store'); - } - - dispatchAction(actionToDispatch: Action): void { - // Let the full zod parse error bubble up to the main world, - // since all data it may contain was provided from the main world. - const parsedAction = action.parse(actionToDispatch); - try { - ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); - } catch (err) { - // Do not leak IPC failure details into the main world. - const message = 'Failed to dispatch action'; - log.error(message, actionToDispatch, err); - throw new Error(message); - } - } -} - -export function createSophieRenderer(allowReplaceListener: boolean): SophieRenderer { - const impl = new SophieRendererImpl(allowReplaceListener); - return { - onSharedStoreChange: impl.onSharedStoreChange.bind(impl), - dispatchAction: impl.dispatchAction.bind(impl), - }; -} diff --git a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts b/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts deleted file mode 100644 index ff77a63..0000000 --- a/packages/preload/src/contextBridge/__tests__/SophieRendererImpl.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -/* - * 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 { jest } from '@jest/globals'; -import { mocked } from 'jest-mock'; -import log from 'loglevel'; -import type { IJsonPatch } from 'mobx-state-tree'; -import { - Action, - MainToRendererIpcMessage, - RendererToMainIpcMessage, - SharedStoreSnapshotIn, - SophieRenderer, -} from '@sophie/shared'; - -jest.unstable_mockModule('electron', () => ({ - ipcRenderer: { - invoke: jest.fn(), - on: jest.fn(), - send: jest.fn(), - }, -})); - -const { ipcRenderer } = await import('electron'); - -const { createSophieRenderer } = await import('../SophieRendererImpl'); - -const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; - -const snapshot: SharedStoreSnapshotIn = { - shouldUseDarkColors: true, -}; - -const invalidSnapshot = { - shouldUseDarkColors: -1, -} as unknown as SharedStoreSnapshotIn; - -const patch: IJsonPatch = { - op: 'replace', - path: 'foo', - value: 'bar', -}; - -const action: Action = { - action: 'set-theme-source', - themeSource: 'dark', -}; - -const invalidAction = { - action: 'not-a-valid-action', -} as unknown as Action; - -beforeAll(() => { - log.disableAll(); -}); - -describe('createSophieRenderer', () => { - it('registers a shared store patch listener', () => { - createSophieRenderer(false); - expect(ipcRenderer.on).toHaveBeenCalledWith( - MainToRendererIpcMessage.SharedStorePatch, - expect.anything(), - ); - }); -}); - -describe('SophieRendererImpl', () => { - let sut: SophieRenderer; - let onSharedStorePatch: (event1: Electron.IpcRendererEvent, patch1: unknown) => void; - let listener = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onPatch: jest.fn((_patch: IJsonPatch) => {}), - }; - - beforeEach(() => { - sut = createSophieRenderer(false); - onSharedStorePatch = mocked(ipcRenderer.on).mock.calls.find(([channel]) => { - return channel === MainToRendererIpcMessage.SharedStorePatch; - })?.[1]!; - }); - - describe('onSharedStoreChange', () => { - it('should request a snapshot from the main process', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - await sut.onSharedStoreChange(listener); - expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); - expect(listener.onSnapshot).toBeCalledWith(snapshot); - }); - - it('should catch IPC errors without exposing them', async () => { - mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); - await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( - 'message', - expect.stringMatching(/s3cr3t/), - ); - expect(listener.onSnapshot).not.toBeCalled(); - }); - - it('should not pass on invalid snapshots', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); - await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); - expect(listener.onSnapshot).not.toBeCalled(); - }); - }); - - describe('dispatchAction', () => { - it('should dispatch valid actions', () => { - sut.dispatchAction(action); - expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); - }); - - it('should not dispatch invalid actions', () => { - expect(() => sut.dispatchAction(invalidAction)).toThrowError(); - expect(ipcRenderer.send).not.toBeCalled(); - }); - }); - - describe('when no listener is registered', () => { - it('should discard the received patch without any error', () => { - onSharedStorePatch(event, patch); - }); - }); - - function itRefusesToRegisterAnotherListener() { - it('should refuse to register another listener', async () => { - await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); - }); - } - - function itDoesNotPassPatchesToTheListener( - name: string = 'should not pass patches to the listener', - ) { - it(name, () => { - onSharedStorePatch(event, patch); - expect(listener.onPatch).not.toBeCalled(); - }); - } - - describe('when a listener registered successfully', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - await sut.onSharedStoreChange(listener); - }); - - it('should pass patches to the listener', () => { - onSharedStorePatch(event, patch); - expect(listener.onPatch).toBeCalledWith(patch); - }); - - it('should catch listener errors', () => { - mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); - onSharedStorePatch(event, patch); - }); - - itRefusesToRegisterAnotherListener(); - - describe('after the listener threw in onPatch', () => { - beforeEach(() => { - mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); - onSharedStorePatch(event, patch); - listener.onPatch.mockRestore(); - }); - - itDoesNotPassPatchesToTheListener('should not pass on patches any more'); - }); - }); - - describe('when a listener failed to register due to IPC error', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockRejectedValue(new Error()); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when a listener failed to register due to an invalid snapshot', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when a listener failed to register due to listener error', () => { - beforeEach(async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); - mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); - try { - await sut.onSharedStoreChange(listener); - } catch { - // Ignore error. - } - }); - - itRefusesToRegisterAnotherListener(); - - itDoesNotPassPatchesToTheListener(); - }); - - describe('when it is allowed to replace listeners', () => { - const snapshot2 = { - shouldUseDarkColors: false, - }; - const listener2 = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - onPatch: jest.fn((_patch: IJsonPatch) => { }), - }; - - it('should fetch a second snapshot', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); - await sut.onSharedStoreChange(listener2); - expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); - expect(listener2.onSnapshot).toBeCalledWith(snapshot2); - }); - - it('should pass the second snapshot to the new listener', async () => { - mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); - await sut.onSharedStoreChange(listener2); - onSharedStorePatch(event, patch); - expect(listener2.onPatch).toBeCalledWith(patch); - }); - }); -}); diff --git a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts new file mode 100644 index 0000000..a38dbac --- /dev/null +++ b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts @@ -0,0 +1,258 @@ +/* + * 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 { jest } from '@jest/globals'; +import { + Action, + MainToRendererIpcMessage, + RendererToMainIpcMessage, + SharedStoreSnapshotIn, + SophieRenderer, +} from '@sophie/shared'; +import { mocked } from 'jest-mock'; +import log from 'loglevel'; +import type { IJsonPatch } from 'mobx-state-tree'; + +jest.unstable_mockModule('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }, +})); + +const { ipcRenderer } = await import('electron'); + +const { default: createSophieRenderer } = await import('../createSophieRenderer'); + +const event: Electron.IpcRendererEvent = null as unknown as Electron.IpcRendererEvent; + +const snapshot: SharedStoreSnapshotIn = { + shouldUseDarkColors: true, +}; + +const invalidSnapshot = { + shouldUseDarkColors: -1, +} as unknown as SharedStoreSnapshotIn; + +const patch: IJsonPatch = { + op: 'replace', + path: 'foo', + value: 'bar', +}; + +const action: Action = { + action: 'set-theme-source', + themeSource: 'dark', +}; + +const invalidAction = { + action: 'not-a-valid-action', +} as unknown as Action; + +beforeAll(() => { + log.disableAll(); +}); + +describe('createSophieRenderer', () => { + it('registers a shared store patch listener', () => { + createSophieRenderer(false); + expect(ipcRenderer.on).toHaveBeenCalledWith( + MainToRendererIpcMessage.SharedStorePatch, + expect.anything(), + ); + }); +}); + +describe('SharedStoreConnector', () => { + let sut: SophieRenderer; + let onSharedStorePatch: (eventArg: Electron.IpcRendererEvent, patchArg: unknown) => void; + const listener = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => {}), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onPatch: jest.fn((_patch: IJsonPatch) => {}), + }; + + beforeEach(() => { + sut = createSophieRenderer(false); + [, onSharedStorePatch] = mocked(ipcRenderer.on).mock.calls.find( + ([channel]) => channel === MainToRendererIpcMessage.SharedStorePatch, + )!; + }); + + describe('onSharedStoreChange', () => { + it('should request a snapshot from the main process', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); + expect(listener.onSnapshot).toBeCalledWith(snapshot); + }); + + it('should catch IPC errors without exposing them', async () => { + mocked(ipcRenderer.invoke).mockRejectedValue(new Error('s3cr3t')); + await expect(sut.onSharedStoreChange(listener)).rejects.not.toHaveProperty( + 'message', + expect.stringMatching(/s3cr3t/), + ); + expect(listener.onSnapshot).not.toBeCalled(); + }); + + it('should not pass on invalid snapshots', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + expect(listener.onSnapshot).not.toBeCalled(); + }); + }); + + describe('dispatchAction', () => { + it('should dispatch valid actions', () => { + sut.dispatchAction(action); + expect(ipcRenderer.send).toBeCalledWith(RendererToMainIpcMessage.DispatchAction, action); + }); + + it('should not dispatch invalid actions', () => { + expect(() => sut.dispatchAction(invalidAction)).toThrowError(); + expect(ipcRenderer.send).not.toBeCalled(); + }); + }); + + describe('when no listener is registered', () => { + it('should discard the received patch without any error', () => { + onSharedStorePatch(event, patch); + }); + }); + + function itRefusesToRegisterAnotherListener(): void { + it('should refuse to register another listener', async () => { + await expect(sut.onSharedStoreChange(listener)).rejects.toBeInstanceOf(Error); + }); + } + + function itDoesNotPassPatchesToTheListener( + name = 'should not pass patches to the listener', + ): void { + it(name, () => { + onSharedStorePatch(event, patch); + expect(listener.onPatch).not.toBeCalled(); + }); + } + + describe('when a listener registered successfully', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + await sut.onSharedStoreChange(listener); + }); + + it('should pass patches to the listener', () => { + onSharedStorePatch(event, patch); + expect(listener.onPatch).toBeCalledWith(patch); + }); + + it('should catch listener errors', () => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(event, patch); + }); + + itRefusesToRegisterAnotherListener(); + + describe('after the listener threw in onPatch', () => { + beforeEach(() => { + mocked(listener.onPatch).mockImplementation(() => { throw new Error(); }); + onSharedStorePatch(event, patch); + listener.onPatch.mockRestore(); + }); + + itDoesNotPassPatchesToTheListener('should not pass on patches any more'); + }); + }); + + describe('when a listener failed to register due to IPC error', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockRejectedValue(new Error()); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to an invalid snapshot', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(invalidSnapshot); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when a listener failed to register due to listener error', () => { + beforeEach(async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot); + mocked(listener.onSnapshot).mockImplementation(() => { throw new Error(); }); + try { + await sut.onSharedStoreChange(listener); + } catch { + // Ignore error. + } + }); + + itRefusesToRegisterAnotherListener(); + + itDoesNotPassPatchesToTheListener(); + }); + + describe('when it is allowed to replace listeners', () => { + const snapshot2 = { + shouldUseDarkColors: false, + }; + const listener2 = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onSnapshot: jest.fn((_snapshot: SharedStoreSnapshotIn) => { }), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + onPatch: jest.fn((_patch: IJsonPatch) => { }), + }; + + it('should fetch a second snapshot', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); + await sut.onSharedStoreChange(listener2); + expect(ipcRenderer.invoke).toBeCalledWith(RendererToMainIpcMessage.GetSharedStoreSnapshot); + expect(listener2.onSnapshot).toBeCalledWith(snapshot2); + }); + + it('should pass the second snapshot to the new listener', async () => { + mocked(ipcRenderer.invoke).mockResolvedValueOnce(snapshot2); + await sut.onSharedStoreChange(listener2); + onSharedStorePatch(event, patch); + expect(listener2.onPatch).toBeCalledWith(patch); + }); + }); +}); diff --git a/packages/preload/src/contextBridge/createSophieRenderer.ts b/packages/preload/src/contextBridge/createSophieRenderer.ts new file mode 100644 index 0000000..2055080 --- /dev/null +++ b/packages/preload/src/contextBridge/createSophieRenderer.ts @@ -0,0 +1,96 @@ +/* + * 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 { + Action, + action, + MainToRendererIpcMessage, + RendererToMainIpcMessage, + sharedStore, + SharedStoreListener, + SophieRenderer, +} from '@sophie/shared'; +import { ipcRenderer } from 'electron'; +import log from 'loglevel'; +import type { IJsonPatch } from 'mobx-state-tree'; + +class SharedStoreConnector { + private onSharedStoreChangeCalled = false; + + private listener: SharedStoreListener | null = null; + + constructor(private readonly allowReplaceListener: boolean) { + ipcRenderer.on(MainToRendererIpcMessage.SharedStorePatch, (_event, patch) => { + try { + // `mobx-state-tree` will validate the patch, so we can safely cast here. + this.listener?.onPatch(patch as IJsonPatch); + } catch (err) { + log.error('Shared store listener onPatch failed', err); + this.listener = null; + } + }); + } + + async onSharedStoreChange(listener: SharedStoreListener): Promise { + if (this.onSharedStoreChangeCalled && !this.allowReplaceListener) { + throw new Error('Shared store change listener was already set'); + } + this.onSharedStoreChangeCalled = true; + let success = false; + let snapshot: unknown | null = null; + try { + snapshot = await ipcRenderer.invoke(RendererToMainIpcMessage.GetSharedStoreSnapshot); + success = true; + } catch (err) { + log.error('Failed to get initial shared store snapshot', err); + } + if (success) { + if (sharedStore.is(snapshot)) { + listener.onSnapshot(snapshot); + this.listener = listener; + return; + } + log.error('Got invalid initial shared store snapshot', snapshot); + } + throw new Error('Failed to connect to shared store'); + } +} + +function dispatchAction(actionToDispatch: Action): void { + // Let the full zod parse error bubble up to the main world, + // since all data it may contain was provided from the main world. + const parsedAction = action.parse(actionToDispatch); + try { + ipcRenderer.send(RendererToMainIpcMessage.DispatchAction, parsedAction); + } catch (err) { + // Do not leak IPC failure details into the main world. + const message = 'Failed to dispatch action'; + log.error(message, actionToDispatch, err); + throw new Error(message); + } +} + +export default function createSophieRenderer(allowReplaceListener: boolean): SophieRenderer { + const connector = new SharedStoreConnector(allowReplaceListener); + return { + onSharedStoreChange: connector.onSharedStoreChange.bind(connector), + dispatchAction, + }; +} diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index de91742..f13220c 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -20,7 +20,7 @@ import { contextBridge } from 'electron'; -import { createSophieRenderer } from './contextBridge/SophieRendererImpl'; +import createSophieRenderer from './contextBridge/createSophieRenderer'; const isDevelopment = import.meta.env.MODE === 'development'; diff --git a/packages/preload/tsconfig.json b/packages/preload/tsconfig.json index 741d435..ff49538 100644 --- a/packages/preload/tsconfig.json +++ b/packages/preload/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../config/tsconfig.base.json", "compilerOptions": { "noEmit": true, "lib": [ @@ -13,11 +13,13 @@ }, "references": [ { - "path": "../shared" + "path": "../shared/tsconfig.build.json" } ], "include": [ "src/**/*.ts", - "types/**/*.d.ts" + "types/**/*.d.ts", + "esbuild.config.js", + "jest.config.js" ] } diff --git a/packages/renderer/.eslinrc.cjs b/packages/renderer/.eslinrc.cjs new file mode 100644 index 0000000..3385ac5 --- /dev/null +++ b/packages/renderer/.eslinrc.cjs @@ -0,0 +1,11 @@ +module.exports = { + extends: [ + 'airbnb', + 'airbnb/hooks', + 'airbnb-typescript', + ], + env: { + node: false, + browser: true, + }, +}; diff --git a/packages/renderer/.eslintrc.json b/packages/renderer/.eslintrc.json deleted file mode 100644 index a28aec9..0000000 --- a/packages/renderer/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "globals": { - "JSX": false - } -} diff --git a/packages/renderer/package.json b/packages/renderer/package.json index df15abb..fde4c28 100644 --- a/packages/renderer/package.json +++ b/packages/renderer/package.json @@ -5,7 +5,7 @@ "type": "module", "types": "dist-types/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck" }, "dependencies": { "@emotion/react": "^11.7.1", @@ -14,7 +14,9 @@ "@mui/icons-material": "^5.2.5", "@mui/material": "^5.2.7", "@sophie/shared": "workspace:*", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "loglevel": "^1.8.0", + "loglevel-plugin-prefix": "^0.8.4", "mobx": "^6.3.12", "mobx-react-lite": "^3.2.3", "mobx-state-tree": "^5.1.0", @@ -22,14 +24,12 @@ "react-dom": "^17.0.2" }, "devDependencies": { - "@types/lodash": "^4.14.178", + "@types/lodash-es": "^4.14.178", "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", "@vitejs/plugin-react": "^1.1.4", "mst-middlewares": "^5.1.0", "remotedev": "^0.2.9", - "rimraf": "^3.0.2", - "typescript": "^4.5.4", "vite": "^2.7.10" } } diff --git a/packages/renderer/src/components/App.tsx b/packages/renderer/src/components/App.tsx index 8bd3dd8..1174bbb 100644 --- a/packages/renderer/src/components/App.tsx +++ b/packages/renderer/src/components/App.tsx @@ -21,10 +21,10 @@ import Box from '@mui/material/Box'; import React from 'react'; -import { BrowserViewPlaceholder } from './BrowserViewPlaceholder'; -import { Sidebar } from './Sidebar'; +import BrowserViewPlaceholder from './BrowserViewPlaceholder'; +import Sidebar from './Sidebar'; -export function App(): JSX.Element { +export default function App(): JSX.Element { return ( { + const store = useStore(); const onResize = useCallback(throttle(([entry]: ResizeObserverEntry[]) => { if (entry) { @@ -38,14 +36,14 @@ export const BrowserViewPlaceholder = observer(function BrowserViewPlaceholder() width, height, } = entry.target.getBoundingClientRect(); - setBrowserViewBounds({ + store.setBrowserViewBounds({ x, y, width, height, }); } - }, 100), [setBrowserViewBounds]); + }, 100), [store]); const resizeObserverRef = useRef(null); diff --git a/packages/renderer/src/components/Sidebar.tsx b/packages/renderer/src/components/Sidebar.tsx index 6c79932..44a47b0 100644 --- a/packages/renderer/src/components/Sidebar.tsx +++ b/packages/renderer/src/components/Sidebar.tsx @@ -21,9 +21,9 @@ import Box from '@mui/material/Box'; import React from 'react'; -import { ToggleDarkModeButton } from './ToggleDarkModeButton'; +import ToggleDarkModeButton from './ToggleDarkModeButton'; -export function Sidebar(): JSX.Element { +export default function Sidebar(): JSX.Element { return ( ({ diff --git a/packages/renderer/src/components/StoreProvider.tsx b/packages/renderer/src/components/StoreProvider.tsx index da1e699..cde6a31 100644 --- a/packages/renderer/src/components/StoreProvider.tsx +++ b/packages/renderer/src/components/StoreProvider.tsx @@ -32,7 +32,7 @@ export function useStore(): RendererStore { return store; } -export function StoreProvider({ children, store }: { +export default function StoreProvider({ children, store }: { children: JSX.Element | JSX.Element[], store: RendererStore, }): JSX.Element { diff --git a/packages/renderer/src/components/ThemeProvider.tsx b/packages/renderer/src/components/ThemeProvider.tsx index 9215f5c..eacaa52 100644 --- a/packages/renderer/src/components/ThemeProvider.tsx +++ b/packages/renderer/src/components/ThemeProvider.tsx @@ -18,18 +18,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { observer } from 'mobx-react-lite'; import { unstable_createMuiStrictModeTheme as createTheme, ThemeProvider as MuiThemeProvider, } from '@mui/material/styles'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { useStore } from './StoreProvider'; -export const ThemeProvider = observer(function ThemeProvider({ children }: { - children: JSX.Element | JSX.Element[], -}) { +export default observer(({ children }: { + children: JSX.Element | JSX.Element[]; +}) => { const { shared: { shouldUseDarkColors } } = useStore(); const theme = createTheme({ diff --git a/packages/renderer/src/components/ToggleDarkModeButton.tsx b/packages/renderer/src/components/ToggleDarkModeButton.tsx index 1b6757e..c8ffdf0 100644 --- a/packages/renderer/src/components/ToggleDarkModeButton.tsx +++ b/packages/renderer/src/components/ToggleDarkModeButton.tsx @@ -18,21 +18,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { observer } from 'mobx-react-lite'; import DarkModeIcon from '@mui/icons-material/DarkMode'; import LightModeIcon from '@mui/icons-material/LightMode'; import IconButton from '@mui/material/IconButton'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { useStore } from './StoreProvider'; -export const ToggleDarkModeButton = observer(function ToggleDarkModeButton() { - const { shared: { shouldUseDarkColors }, toggleDarkMode } = useStore(); +export default observer(() => { + const store = useStore(); + const { shared: { shouldUseDarkColors } } = store; return ( toggleDarkMode()} + onClick={() => store.toggleDarkMode()} > {shouldUseDarkColors ? : } diff --git a/packages/renderer/src/devTools.ts b/packages/renderer/src/devTools.ts index 3ec66aa..3d3ba99 100644 --- a/packages/renderer/src/devTools.ts +++ b/packages/renderer/src/devTools.ts @@ -32,30 +32,23 @@ import type { IAnyStateTreeNode } from 'mobx-state-tree'; * However, we don't bundle `remotedev` in production, so the call would fail anyways. * * @param model The store to connect to the redux devtools. + * @return A promise that resolves when the store was exposed to the devtools. * @see https://github.com/SocketCluster/socketcluster-client/issues/118#issuecomment-469064682 */ -async function exposeToReduxDevtoolsAsync(model: IAnyStateTreeNode): Promise { +export async function exposeToReduxDevtools(model: IAnyStateTreeNode): Promise { (window as { global?: unknown }).global = window; + // Hack to load dev dependencies on demand. const [remotedev, { connectReduxDevtools }] = await Promise.all([ - // @ts-ignore - import('remotedev'), + // @ts-expect-error `remotedev` has no typings. + // eslint-disable-next-line import/no-extraneous-dependencies + import('remotedev') as unknown, + // eslint-disable-next-line import/no-extraneous-dependencies import('mst-middlewares'), ]); connectReduxDevtools(remotedev, model); } -/** - * Connects the `model` to the redux devtools extension. - * - * @param model The store to connect to the redux devtools. - */ -export function exposeToReduxDevtools(model: IAnyStateTreeNode): void { - exposeToReduxDevtoolsAsync(model).catch((err) => { - console.error('Could not connect to Redux devtools', err); - }); -} - /** * Sends a message to the main process to reload all services when * `build/watch.js` sends a reload event on bundle write. diff --git a/packages/renderer/src/index.tsx b/packages/renderer/src/index.tsx index 1626bef..d900e50 100644 --- a/packages/renderer/src/index.tsx +++ b/packages/renderer/src/index.tsx @@ -26,14 +26,17 @@ import CssBaseline from '@mui/material/CssBaseline'; import React from 'react'; import { render } from 'react-dom'; -import { App } from './components/App'; -import { StoreProvider } from './components/StoreProvider'; -import { ThemeProvider } from './components/ThemeProvider'; +import App from './components/App'; +import StoreProvider from './components/StoreProvider'; +import ThemeProvider from './components/ThemeProvider'; import { exposeToReduxDevtools, hotReloadServices } from './devTools'; import { createAndConnectRendererStore } from './stores/RendererStore'; +import { getLogger } from './utils/log'; const isDevelopment = import.meta.env.MODE === 'development'; +const log = getLogger('index'); + if (isDevelopment) { hotReloadServices(); document.title = `[dev] ${document.title}`; @@ -42,7 +45,9 @@ if (isDevelopment) { const store = createAndConnectRendererStore(window.sophieRenderer); if (isDevelopment) { - exposeToReduxDevtools(store); + exposeToReduxDevtools(store).catch((err) => { + log.error('Cannot initialize redux devtools', err); + }); } function Root(): JSX.Element { diff --git a/packages/renderer/src/stores/RendererEnv.ts b/packages/renderer/src/stores/RendererEnv.ts index d687738..f0a5a51 100644 --- a/packages/renderer/src/stores/RendererEnv.ts +++ b/packages/renderer/src/stores/RendererEnv.ts @@ -18,10 +18,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { getEnv as getAnyEnv, IAnyStateTreeNode } from 'mobx-state-tree'; import type { Action } from '@sophie/shared'; +import { getEnv as getAnyEnv, IAnyStateTreeNode } from 'mobx-state-tree'; -export interface RendererEnv { +export default interface RendererEnv { dispatchMainAction(action: Action): void; } diff --git a/packages/renderer/src/stores/RendererStore.ts b/packages/renderer/src/stores/RendererStore.ts index 037b212..e684759 100644 --- a/packages/renderer/src/stores/RendererStore.ts +++ b/packages/renderer/src/stores/RendererStore.ts @@ -18,20 +18,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { - applySnapshot, - applyPatch, - Instance, - types, -} from 'mobx-state-tree'; import { BrowserViewBounds, sharedStore, SophieRenderer, ThemeSource, } from '@sophie/shared'; +import { + applySnapshot, + applyPatch, + Instance, + types, +} from 'mobx-state-tree'; + +import { getLogger } from '../utils/log'; + +import type RendererEnv from './RendererEnv'; +import { getEnv } from './RendererEnv'; -import { getEnv, RendererEnv } from './RendererEnv'; +const log = getLogger('RendererStore'); export const rendererStore = types.model('RendererStore', { shared: types.optional(sharedStore, {}), @@ -81,7 +86,7 @@ export function createAndConnectRendererStore(ipc: SophieRenderer): RendererStor applyPatch(store.shared, patch); }, }).catch((err) => { - console.error('Failed to connect to shared store', err); + log.error('Failed to connect to shared store', err); }); return store; diff --git a/packages/renderer/src/utils/log.ts b/packages/renderer/src/utils/log.ts new file mode 100644 index 0000000..c17fc2a --- /dev/null +++ b/packages/renderer/src/utils/log.ts @@ -0,0 +1,50 @@ +/* + * Copyright (C) 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 loglevel, { Logger } from 'loglevel'; +import prefix from 'loglevel-plugin-prefix'; + +if (import.meta.env?.DEV) { + loglevel.setLevel('debug'); +} else { + // No devtools in production, so there's not point to log anything. + loglevel.disableAll(); +} + +prefix.reg(loglevel); +prefix.apply(loglevel, { + format(level, name, timestamp) { + const timeStr = timestamp.toString(); + const nameStr = typeof name === 'undefined' ? '' : ` ${name}`; + return `[${timeStr}] ${level}${nameStr}:`; + }, +}); + +export function getLogger(loggerName: string): Logger { + return loglevel.getLogger(loggerName); +} + +export function silenceLogger(): void { + loglevel.disableAll(); + const loggers = loglevel.getLoggers(); + Object.keys(loggers).forEach((loggerName) => { + loggers[loggerName].disableAll(); + }); +} diff --git a/packages/renderer/tsconfig.json b/packages/renderer/tsconfig.json index 8746462..14c3e0c 100644 --- a/packages/renderer/tsconfig.json +++ b/packages/renderer/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../config/tsconfig.base.json", "compilerOptions": { "noEmit": true, "jsx": "react", @@ -14,12 +14,14 @@ }, "references": [ { - "path": "../shared" + "path": "../shared/tsconfig.build.json" } ], "include": [ "src/**/*.ts", "src/**/*.tsx", - "types/**/*.d.ts" + "types/**/*.d.ts", + ".eslintrc.cjs", + "vite.config.js" ] } diff --git a/packages/renderer/vite.config.js b/packages/renderer/vite.config.js index bcd1975..6440ead 100644 --- a/packages/renderer/vite.config.js +++ b/packages/renderer/vite.config.js @@ -3,10 +3,11 @@ import { builtinModules } from 'module'; import { join } from 'path'; + import react from '@vitejs/plugin-react'; import { banner, chrome } from '../../config/buildConstants.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; const thisDir = fileURLToDirname(import.meta.url); @@ -45,7 +46,7 @@ export default { preserveSymlinks: true, }, optimizeDeps: { - link: [ + exclude: [ '@sophie/shared', ], }, diff --git a/packages/service-inject/.eslintrc.cjs b/packages/service-inject/.eslintrc.cjs new file mode 100644 index 0000000..6ae3faf --- /dev/null +++ b/packages/service-inject/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: false, + browser: true, + }, +}; diff --git a/packages/service-inject/esbuild.config.js b/packages/service-inject/esbuild.config.js index 2169c8e..d0b04bb 100644 --- a/packages/service-inject/esbuild.config.js +++ b/packages/service-inject/esbuild.config.js @@ -1,8 +1,8 @@ import { chrome } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/service-inject/package.json b/packages/service-inject/package.json index 7c496fd..c045500 100644 --- a/packages/service-inject/package.json +++ b/packages/service-inject/package.json @@ -6,13 +6,9 @@ "type": "module", "types": "dist-types/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck" }, "dependencies": { "@sophie/service-shared": "workspace:*" - }, - "devDependencies": { - "rimraf": "^3.0.2", - "typescript": "^4.5.4" } } diff --git a/packages/service-inject/tsconfig.json b/packages/service-inject/tsconfig.json index 638690b..cc61d63 100644 --- a/packages/service-inject/tsconfig.json +++ b/packages/service-inject/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../config/tsconfig.base.json", "compilerOptions": { "noEmit": true, "lib": [ @@ -10,10 +10,11 @@ }, "references": [ { - "path": "../service-shared" + "path": "../service-shared/tsconfig.build.json" } ], "include": [ - "src/**/*.ts" + "src/**/*.ts", + "esbuild.config.js" ] } diff --git a/packages/service-preload/.eslintrc.cjs b/packages/service-preload/.eslintrc.cjs new file mode 100644 index 0000000..02fab21 --- /dev/null +++ b/packages/service-preload/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + env: { + node: true, + browser: true, + }, +}; diff --git a/packages/service-preload/esbuild.config.js b/packages/service-preload/esbuild.config.js index b73a071..66f5e84 100644 --- a/packages/service-preload/esbuild.config.js +++ b/packages/service-preload/esbuild.config.js @@ -1,8 +1,8 @@ import { chrome } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/service-preload/package.json b/packages/service-preload/package.json index 26215a3..14717f8 100644 --- a/packages/service-preload/package.json +++ b/packages/service-preload/package.json @@ -5,14 +5,12 @@ "type": "module", "types": "dist-types/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck" }, "dependencies": { "@sophie/service-shared": "workspace:*", - "electron": "16.0.6" - }, - "devDependencies": { - "rimraf": "^3.0.2", - "typescript": "^4.5.4" + "electron": "16.0.6", + "loglevel": "^1.8.0", + "loglevel-plugin-prefix": "^0.8.4" } } diff --git a/packages/service-preload/src/index.ts b/packages/service-preload/src/index.ts index d1ea13c..2bbfefd 100644 --- a/packages/service-preload/src/index.ts +++ b/packages/service-preload/src/index.ts @@ -18,8 +18,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { ipcRenderer, webFrame } from 'electron'; import { ServiceToMainIpcMessage, webSource } from '@sophie/service-shared'; +import { ipcRenderer, webFrame } from 'electron'; + +import { getLogger } from './utils/log'; + +const log = getLogger('index'); if (webFrame.parent === null) { // Inject CSS to simulate `browserView.setBackgroundColor`. @@ -49,14 +53,14 @@ if (webFrame.parent === null) { * @see https://www.electronjs.org/docs/latest/api/web-contents#contentsexecutejavascriptinisolatedworldworldid-scripts-usergesture */ async function fetchAndExecuteInjectScript(): Promise { - const apiExposedResponse = await ipcRenderer.invoke( + const apiExposedResponse: unknown = await ipcRenderer.invoke( ServiceToMainIpcMessage.ApiExposedInMainWorld, ); const injectSource = webSource.parse(apiExposedResponse); // Isolated world 0 is the main world. - return webFrame.executeJavaScriptInIsolatedWorld(0, [injectSource]); + await webFrame.executeJavaScriptInIsolatedWorld(0, [injectSource]); } fetchAndExecuteInjectScript().catch((err) => { - console.log('Failed to fetch inject source:', err); + log.error('Failed to fetch inject source:', err); }); diff --git a/packages/service-preload/src/utils/log.ts b/packages/service-preload/src/utils/log.ts new file mode 100644 index 0000000..0c35319 --- /dev/null +++ b/packages/service-preload/src/utils/log.ts @@ -0,0 +1,49 @@ +/* + * Copyright (C) 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 loglevel, { Logger } from 'loglevel'; +import prefix from 'loglevel-plugin-prefix'; + +if (import.meta.env?.DEV) { + loglevel.setLevel('debug'); +} else { + loglevel.setLevel('info'); +} + +prefix.reg(loglevel); +prefix.apply(loglevel, { + format(level, name, timestamp) { + const timeStr = timestamp.toString(); + const nameStr = typeof name === 'undefined' ? '' : ` ${name}`; + return `[${timeStr}] ${level}${nameStr}:`; + }, +}); + +export function getLogger(loggerName: string): Logger { + return loglevel.getLogger(loggerName); +} + +export function silenceLogger(): void { + loglevel.disableAll(); + const loggers = loglevel.getLoggers(); + Object.keys(loggers).forEach((loggerName) => { + loggers[loggerName].disableAll(); + }); +} diff --git a/packages/service-preload/tsconfig.json b/packages/service-preload/tsconfig.json index 638690b..0372dde 100644 --- a/packages/service-preload/tsconfig.json +++ b/packages/service-preload/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + "extends": "../../config/tsconfig.base.json", "compilerOptions": { "noEmit": true, "lib": [ @@ -10,10 +10,12 @@ }, "references": [ { - "path": "../service-shared" + "path": "../service-shared/tsconfig.build.json" } ], "include": [ - "src/**/*.ts" + "src/**/*.ts", + "types/**/*.ts", + "esbuild.config.js" ] } diff --git a/packages/service-preload/types/importMeta.d.ts b/packages/service-preload/types/importMeta.d.ts new file mode 100644 index 0000000..9b73170 --- /dev/null +++ b/packages/service-preload/types/importMeta.d.ts @@ -0,0 +1,7 @@ +interface ImportMeta { + env: { + DEV: boolean; + MODE: string; + PROD: boolean; + } +} diff --git a/packages/service-shared/.eslintrc.cjs b/packages/service-shared/.eslintrc.cjs new file mode 100644 index 0000000..71d6ec4 --- /dev/null +++ b/packages/service-shared/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + env: { + // We must run in both node and browser, so we can't depend on either of them. + node: false, + browser: false, + }, +}; diff --git a/packages/service-shared/esbuild.config.js b/packages/service-shared/esbuild.config.js index 08941a4..ccee72c 100644 --- a/packages/service-shared/esbuild.config.js +++ b/packages/service-shared/esbuild.config.js @@ -1,8 +1,8 @@ import { chrome, node } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/service-shared/package.json b/packages/service-shared/package.json index 9d75fc8..5338c8c 100644 --- a/packages/service-shared/package.json +++ b/packages/service-shared/package.json @@ -7,13 +7,10 @@ "exports": "./dist/index.mjs", "types": "dist/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck", + "types": "yarn g:types" }, "dependencies": { "zod": "^3.11.6" - }, - "devDependencies": { - "rimraf": "^3.0.2", - "typescript": "^4.5.4" } } diff --git a/packages/service-shared/src/index.ts b/packages/service-shared/src/index.ts index 564ebe8..e111347 100644 --- a/packages/service-shared/src/index.ts +++ b/packages/service-shared/src/index.ts @@ -18,7 +18,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export { ServiceToMainIpcMessage } from './ipc'; +export { MainToServiceIpcMessage, ServiceToMainIpcMessage } from './ipc'; export type { UnreadCount, diff --git a/packages/service-shared/src/ipc.ts b/packages/service-shared/src/ipc.ts index 4f991c5..c0dab11 100644 --- a/packages/service-shared/src/ipc.ts +++ b/packages/service-shared/src/ipc.ts @@ -18,6 +18,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +export enum MainToServiceIpcMessage { +} + export enum ServiceToMainIpcMessage { ApiExposedInMainWorld = 'sophie-service-to-main:api-exposed-in-main-world', SetUnreadCount = 'sophie-service-to-main:set-unread-count', diff --git a/packages/service-shared/tsconfig.build.json b/packages/service-shared/tsconfig.build.json new file mode 100644 index 0000000..9a0c835 --- /dev/null +++ b/packages/service-shared/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declarationDir": "dist", + "emitDeclarationOnly": true, + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/service-shared/tsconfig.json b/packages/service-shared/tsconfig.json index ff5a29b..79889d2 100644 --- a/packages/service-shared/tsconfig.json +++ b/packages/service-shared/tsconfig.json @@ -1,12 +1,14 @@ { - "extends": "../../tsconfig.json", + "extends": "./tsconfig.build.json", "compilerOptions": { - "composite": true, - "declarationDir": "dist", - "emitDeclarationOnly": true, - "rootDir": "src" + "composite": false, + "emitDeclarationOnly": false, + "declarationDir": null, + "noEmit": true, + "rootDir": null }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "esbuild.config.js" ] } diff --git a/packages/shared/.eslintrc.cjs b/packages/shared/.eslintrc.cjs new file mode 100644 index 0000000..71d6ec4 --- /dev/null +++ b/packages/shared/.eslintrc.cjs @@ -0,0 +1,7 @@ +module.exports = { + env: { + // We must run in both node and browser, so we can't depend on either of them. + node: false, + browser: false, + }, +}; diff --git a/packages/shared/esbuild.config.js b/packages/shared/esbuild.config.js index 66d6658..78249ab 100644 --- a/packages/shared/esbuild.config.js +++ b/packages/shared/esbuild.config.js @@ -1,8 +1,8 @@ import { chrome, node } from '../../config/buildConstants.js'; -import { getConfig } from '../../config/esbuildConfig.js'; -import { fileURLToDirname } from '../../config/utils.js'; +import fileURLToDirname from '../../config/fileURLToDirname.js'; +import getEsbuildConfig from '../../config/getEsbuildConfig.js'; -export default getConfig({ +export default getEsbuildConfig({ absWorkingDir: fileURLToDirname(import.meta.url), entryPoints: [ 'src/index.ts', diff --git a/packages/shared/package.json b/packages/shared/package.json index 0c06643..d77261d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,15 +7,12 @@ "exports": "./dist/index.mjs", "types": "dist/index.d.ts", "scripts": { - "typecheck": "tsc" + "typecheck:workspace": "yarn g:typecheck", + "types": "yarn g:types" }, "dependencies": { "mobx": "^6.3.12", "mobx-state-tree": "^5.1.0", "zod": "^3.11.6" - }, - "devDependencies": { - "rimraf": "^3.0.2", - "typescript": "^4.5.4" } } diff --git a/packages/shared/src/contextBridge/SophieRenderer.ts b/packages/shared/src/contextBridge/SophieRenderer.ts index fc43b6e..9858aa9 100644 --- a/packages/shared/src/contextBridge/SophieRenderer.ts +++ b/packages/shared/src/contextBridge/SophieRenderer.ts @@ -18,12 +18,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { SharedStoreListener } from '../stores/SharedStore'; - import { Action } from '../schemas'; +import { SharedStoreListener } from '../stores/SharedStore'; export interface SophieRenderer { - onSharedStoreChange(listener: SharedStoreListener): Promise; + onSharedStoreChange(this: void, listener: SharedStoreListener): Promise; - dispatchAction(action: Action): void; + dispatchAction(this: void, action: Action): void; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2f7146c..9828ec4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,10 +20,7 @@ export type { SophieRenderer } from './contextBridge/SophieRenderer'; -export { - MainToRendererIpcMessage, - RendererToMainIpcMessage, -} from './ipc'; +export { MainToRendererIpcMessage, RendererToMainIpcMessage } from './ipc'; export type { Action, diff --git a/packages/shared/tsconfig.build.json b/packages/shared/tsconfig.build.json new file mode 100644 index 0000000..9a0c835 --- /dev/null +++ b/packages/shared/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declarationDir": "dist", + "emitDeclarationOnly": true, + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json index ff5a29b..79889d2 100644 --- a/packages/shared/tsconfig.json +++ b/packages/shared/tsconfig.json @@ -1,12 +1,14 @@ { - "extends": "../../tsconfig.json", + "extends": "./tsconfig.build.json", "compilerOptions": { - "composite": true, - "declarationDir": "dist", - "emitDeclarationOnly": true, - "rootDir": "src" + "composite": false, + "emitDeclarationOnly": false, + "declarationDir": null, + "noEmit": true, + "rootDir": null }, "include": [ - "src/**/*.ts" + "src/**/*.ts", + "esbuild.config.js" ] } -- cgit v1.2.3-54-g00ecf