From 3b7d52abb0e7de00bdf92ee3482a4cae1f6b7d64 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Mon, 3 Jan 2022 01:02:00 +0100 Subject: feat: Add Profile and Service stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the main process, it is optional to specify the ID of a Profile or a Service. The missing ID will be filled in with a randomly generated one. Moreover, services without a profile will get a profile generated with the same name. Signed-off-by: Kristóf Marussy --- packages/main/package.json | 5 +- packages/main/src/controllers/initConfig.ts | 12 +- packages/main/src/stores/Config.ts | 32 ++++- packages/main/src/stores/Profile.ts | 51 +++++++ packages/main/src/stores/Service.ts | 63 +++++++++ packages/main/src/stores/__tests__/Config.spec.ts | 156 +++++++++++++++++++++ packages/main/src/utils/generateId.ts | 27 ++++ .../__tests__/createSophieRenderer.spec.ts | 27 ---- .../src/contextBridge/createSophieRenderer.ts | 15 +- packages/shared/src/index.ts | 14 ++ packages/shared/src/stores/Config.ts | 5 + packages/shared/src/stores/Profile.ts | 32 +++++ packages/shared/src/stores/Service.ts | 37 +++++ yarn.lock | 17 +++ 14 files changed, 448 insertions(+), 45 deletions(-) create mode 100644 packages/main/src/stores/Profile.ts create mode 100644 packages/main/src/stores/Service.ts create mode 100644 packages/main/src/stores/__tests__/Config.spec.ts create mode 100644 packages/main/src/utils/generateId.ts create mode 100644 packages/shared/src/stores/Profile.ts create mode 100644 packages/shared/src/stores/Service.ts diff --git a/packages/main/package.json b/packages/main/package.json index 862b83a..80a93b9 100644 --- a/packages/main/package.json +++ b/packages/main/package.json @@ -20,7 +20,9 @@ "mobx": "^6.3.13", "mobx-state-tree": "^5.1.0", "ms": "^2.1.3", - "os-name": "^5.0.1" + "nanoid": "^3.1.30", + "os-name": "^5.0.1", + "slug": "^5.2.0" }, "devDependencies": { "@jest/globals": "^27.4.6", @@ -28,6 +30,7 @@ "@types/lodash-es": "^4.17.5", "@types/ms": "^0.7.31", "@types/node": "^17.0.12", + "@types/slug": "^5", "electron-devtools-installer": "^3.2.0", "esbuild": "^0.14.14", "git-repo-info": "^2.1.1", diff --git a/packages/main/src/controllers/initConfig.ts b/packages/main/src/controllers/initConfig.ts index 915f451..93be978 100644 --- a/packages/main/src/controllers/initConfig.ts +++ b/packages/main/src/controllers/initConfig.ts @@ -19,11 +19,11 @@ */ import { debounce } from 'lodash-es'; -import { applySnapshot, getSnapshot, onSnapshot } from 'mobx-state-tree'; +import { 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 ConfigPersistenceService from '../services/ConfigPersistenceService.js'; +import { Config, ConfigFileIn, ConfigSnapshotOut } from '../stores/Config.js'; import type Disposer from '../utils/Disposer'; import { getLogger } from '../utils/log'; @@ -44,12 +44,14 @@ export default async function initConfig( const result = await persistenceService.readConfig(); if (result.found) { try { - applySnapshot(config, result.data); - lastSnapshotOnDisk = getSnapshot(config); + // This cast is unsound if the config file is invalid, + // but we'll throw an error in the end anyways. + config.loadFromConfigFile(result.data as ConfigFileIn); } catch (error) { log.error('Failed to apply config snapshot', result.data, error); } } + lastSnapshotOnDisk = getSnapshot(config); return result.found; } diff --git a/packages/main/src/stores/Config.ts b/packages/main/src/stores/Config.ts index ca90c0c..e7fc360 100644 --- a/packages/main/src/stores/Config.ts +++ b/packages/main/src/stores/Config.ts @@ -19,14 +19,40 @@ */ import { config as originalConfig, ThemeSource } from '@sophie/shared'; -import { Instance } from 'mobx-state-tree'; +import { applySnapshot, Instance, SnapshotIn } from 'mobx-state-tree'; + +import { addMissingProfileIds, PartialProfileSnapshotIn } from './Profile'; +import { + addMissingServiceIdsAndProfiles, + PartialServiceSnapshotIn, +} from './Service'; export const config = originalConfig.actions((self) => ({ - setThemeSource(mode: ThemeSource) { + loadFromConfigFile(snapshot: ConfigFileIn): void { + const profiles = addMissingProfileIds(snapshot.profiles); + const services = addMissingServiceIdsAndProfiles( + snapshot.services, + profiles, + ); + applySnapshot(self, { + ...snapshot, + profiles, + services, + }); + }, + setThemeSource(mode: ThemeSource): void { self.themeSource = mode; }, })); export interface Config extends Instance {} -export type { ConfigSnapshotIn, ConfigSnapshotOut } from '@sophie/shared'; +export interface ConfigSnapshotIn extends SnapshotIn {} + +export interface ConfigFileIn + extends Omit { + profiles?: PartialProfileSnapshotIn[] | undefined; + services?: PartialServiceSnapshotIn[] | undefined; +} + +export type { ConfigSnapshotOut } from '@sophie/shared'; diff --git a/packages/main/src/stores/Profile.ts b/packages/main/src/stores/Profile.ts new file mode 100644 index 0000000..4705862 --- /dev/null +++ b/packages/main/src/stores/Profile.ts @@ -0,0 +1,51 @@ +/* + * 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 type { ProfileSnapshotIn } from '@sophie/shared'; + +import generateId from '../utils/generateId'; + +export interface PartialProfileSnapshotIn + extends Omit { + id?: string | undefined; +} + +export function addMissingProfileIds( + partialProfiles: PartialProfileSnapshotIn[] | undefined, +): ProfileSnapshotIn[] { + return (partialProfiles ?? []).map((profile) => { + const { name } = profile; + let { id } = profile; + if (typeof id === 'undefined') { + id = generateId(name); + } + return { + ...profile, + id, + }; + }); +} + +export type { + Profile, + ProfileSnapshotOut, + ProfileSnapshotIn, +} from '@sophie/shared'; +export { profile } from '@sophie/shared'; diff --git a/packages/main/src/stores/Service.ts b/packages/main/src/stores/Service.ts new file mode 100644 index 0000000..9bc6a43 --- /dev/null +++ b/packages/main/src/stores/Service.ts @@ -0,0 +1,63 @@ +/* + * 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 type { ServiceSnapshotIn } from '@sophie/shared'; + +import generateId from '../utils/generateId'; + +import type { ProfileSnapshotIn } from './Profile'; + +export interface PartialServiceSnapshotIn + extends Omit { + id?: string | undefined; + profile?: string | undefined; +} + +export function addMissingServiceIdsAndProfiles( + partialServices: PartialServiceSnapshotIn[] | undefined, + profiles: ProfileSnapshotIn[], +): ServiceSnapshotIn[] { + return (partialServices ?? []).map((service) => { + const { name } = service; + let { id, profile } = service; + if (typeof id === 'undefined') { + id = generateId(name); + } + if (typeof profile === 'undefined') { + profile = generateId(name); + profiles.push({ + id: profile, + name: service.name, + }); + } + return { + ...service, + id, + profile, + }; + }); +} + +export type { + Service, + ServiceSnapshotOut, + ServiceSnapshotIn, +} from '@sophie/shared'; +export { service } from '@sophie/shared'; diff --git a/packages/main/src/stores/__tests__/Config.spec.ts b/packages/main/src/stores/__tests__/Config.spec.ts new file mode 100644 index 0000000..22ccbc7 --- /dev/null +++ b/packages/main/src/stores/__tests__/Config.spec.ts @@ -0,0 +1,156 @@ +/* + * 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 { config, Config, ConfigFileIn } from '../Config'; +import type { PartialProfileSnapshotIn } from '../Profile'; +import type { PartialServiceSnapshotIn } from '../Service'; + +const profileProps: PartialProfileSnapshotIn = { + name: 'Test profile', +}; + +const serviceProps: PartialServiceSnapshotIn = { + name: 'Test service', + url: 'https://example.com', +}; + +let sut: Config; + +beforeEach(() => { + sut = config.create(); +}); + +describe('preprocessConfigFile', () => { + it('should load profiles with an ID', () => { + sut.loadFromConfigFile({ + profiles: [ + { + id: 'someId', + ...profileProps, + }, + ], + }); + expect(sut.profiles[0].id).toBe('someId'); + }); + + it('should generate an ID for profiles without and ID', () => { + sut.loadFromConfigFile({ + profiles: [profileProps], + }); + expect(sut.profiles[0].id).toBeDefined(); + }); + + it('should load services with an ID and a profile', () => { + sut.loadFromConfigFile({ + profiles: [ + { + id: 'someProfileId', + ...profileProps, + }, + ], + services: [ + { + id: 'someServiceId', + profile: 'someProfileId', + ...serviceProps, + }, + ], + }); + expect(sut.services[0].id).toBe('someServiceId'); + expect(sut.services[0].profile).toBe(sut.profiles[0]); + }); + + it('should refuse to load a profile without a name', () => { + expect(() => { + sut.loadFromConfigFile({ + profiles: [ + { + id: 'someProfileId', + ...profileProps, + name: undefined, + }, + ], + } as unknown as ConfigFileIn); + }).toThrow(); + expect(sut.profiles).toHaveLength(0); + }); + + it('should load services without an ID but with a profile', () => { + sut.loadFromConfigFile({ + profiles: [ + { + id: 'someProfileId', + ...profileProps, + }, + ], + services: [ + { + profile: 'someProfileId', + ...serviceProps, + }, + ], + }); + expect(sut.services[0].id).toBeDefined(); + expect(sut.services[0].profile).toBe(sut.profiles[0]); + }); + + it('should create a profile for a service with an ID but no profile', () => { + sut.loadFromConfigFile({ + services: [ + { + id: 'someServiceId', + ...serviceProps, + }, + ], + }); + expect(sut.services[0].id).toBe('someServiceId'); + expect(sut.services[0].profile).toBeDefined(); + expect(sut.services[0].profile.name).toBe(serviceProps.name); + }); + + it('should create a profile for a service without an ID or profile', () => { + sut.loadFromConfigFile({ + services: [ + { + ...serviceProps, + }, + ], + }); + expect(sut.services[0].id).toBeDefined(); + expect(sut.services[0].profile).toBeDefined(); + expect(sut.services[0].profile.name).toBe(serviceProps.name); + }); + + it('should refuse to load a service without a name', () => { + expect(() => { + sut.loadFromConfigFile({ + services: [ + { + id: 'someServiceId', + ...serviceProps, + name: undefined, + }, + ], + } as unknown as ConfigFileIn); + }).toThrow(); + expect(sut.profiles).toHaveLength(0); + expect(sut.services).toHaveLength(0); + }); +}); diff --git a/packages/main/src/utils/generateId.ts b/packages/main/src/utils/generateId.ts new file mode 100644 index 0000000..8a87e5a --- /dev/null +++ b/packages/main/src/utils/generateId.ts @@ -0,0 +1,27 @@ +/* + * 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 { nanoid } from 'nanoid'; +import slug from 'slug'; + +export default function generateId(name?: string | undefined) { + const nameSlug = typeof name === 'undefined' ? '' : slug(name); + return `${nameSlug}_${nanoid()}`; +} diff --git a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts index 88b0077..2652c4e 100644 --- a/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts +++ b/packages/preload/src/contextBridge/__tests__/createSophieRenderer.spec.ts @@ -51,10 +51,6 @@ const snapshot: SharedStoreSnapshotIn = { shouldUseDarkColors: true, }; -const invalidSnapshot = { - shouldUseDarkColors: -1, -} as unknown as SharedStoreSnapshotIn; - const patch: IJsonPatch = { op: 'replace', path: 'foo', @@ -121,14 +117,6 @@ describe('SharedStoreConnector', () => { ).rejects.not.toHaveProperty('message', expect.stringMatching(/s3cr3t/)); expect(listener.onSnapshot).not.toHaveBeenCalled(); }); - - 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.toHaveBeenCalled(); - }); }); describe('dispatchAction', () => { @@ -220,21 +208,6 @@ describe('SharedStoreConnector', () => { 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); diff --git a/packages/preload/src/contextBridge/createSophieRenderer.ts b/packages/preload/src/contextBridge/createSophieRenderer.ts index 3174fed..41accfd 100644 --- a/packages/preload/src/contextBridge/createSophieRenderer.ts +++ b/packages/preload/src/contextBridge/createSophieRenderer.ts @@ -23,8 +23,8 @@ import { action, MainToRendererIpcMessage, RendererToMainIpcMessage, - sharedStore, SharedStoreListener, + SharedStoreSnapshotIn, SophieRenderer, } from '@sophie/shared'; import { ipcRenderer } from 'electron'; @@ -66,15 +66,12 @@ class SharedStoreConnector { } catch (error) { log.error('Failed to get initial shared store snapshot', error); } - if (success) { - if (sharedStore.is(snapshot)) { - listener.onSnapshot(snapshot); - this.listener = listener; - return; - } - log.error('Got invalid initial shared store snapshot', snapshot); + if (!success) { + throw new Error('Failed to connect to shared store'); } - throw new Error('Failed to connect to shared store'); + // `mobx-state-tree` will validate the snapshot, so we can safely cast here. + listener.onSnapshot(snapshot as SharedStoreSnapshotIn); + this.listener = listener; } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6383f63..9f4e9b3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -32,6 +32,20 @@ export type { } from './stores/Config'; export { config } from './stores/Config'; +export type { + Profile, + ProfileSnapshotIn, + ProfileSnapshotOut, +} from './stores/Profile'; +export { profile } from './stores/Profile'; + +export type { + Service, + ServiceSnapshotIn, + ServiceSnapshotOut, +} from './stores/Service'; +export { service } from './stores/Service'; + export type { SharedStore, SharedStoreListener, diff --git a/packages/shared/src/stores/Config.ts b/packages/shared/src/stores/Config.ts index 1d98a33..1d97e7d 100644 --- a/packages/shared/src/stores/Config.ts +++ b/packages/shared/src/stores/Config.ts @@ -22,7 +22,12 @@ import { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; import { themeSource } from '../schemas'; +import { profile } from './Profile'; +import { service } from './Service'; + export const config = types.model('Config', { + profiles: types.array(profile), + services: types.array(service), themeSource: types.optional(types.enumeration(themeSource.options), 'system'), }); diff --git a/packages/shared/src/stores/Profile.ts b/packages/shared/src/stores/Profile.ts new file mode 100644 index 0000000..88a0f4d --- /dev/null +++ b/packages/shared/src/stores/Profile.ts @@ -0,0 +1,32 @@ +/* + * 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 { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; + +export const profile = types.model('Profile', { + id: types.identifier, + name: types.string, +}); + +export interface Profile extends Instance {} + +export interface ProfileSnapshotIn extends SnapshotIn {} + +export interface ProfileSnapshotOut extends SnapshotOut {} diff --git a/packages/shared/src/stores/Service.ts b/packages/shared/src/stores/Service.ts new file mode 100644 index 0000000..ed2cd9a --- /dev/null +++ b/packages/shared/src/stores/Service.ts @@ -0,0 +1,37 @@ +/* + * 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 { Instance, types, SnapshotIn, SnapshotOut } from 'mobx-state-tree'; + +import { profile } from './Profile'; + +export const service = types.model('Service', { + id: types.identifier, + name: types.string, + profile: types.reference(profile), + // TODO: Remove this once recipes are added. + url: types.string, +}); + +export interface Service extends Instance {} + +export interface ServiceSnapshotIn extends SnapshotIn {} + +export interface ServiceSnapshotOut extends SnapshotOut {} diff --git a/yarn.lock b/yarn.lock index c131687..eba34a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1241,6 +1241,7 @@ __metadata: "@types/lodash-es": ^4.17.5 "@types/ms": ^0.7.31 "@types/node": ^17.0.12 + "@types/slug": ^5 chalk: ^5.0.0 electron: 17.0.0 electron-devtools-installer: ^3.2.0 @@ -1256,7 +1257,9 @@ __metadata: mobx: ^6.3.13 mobx-state-tree: ^5.1.0 ms: ^2.1.3 + nanoid: ^3.1.30 os-name: ^5.0.1 + slug: ^5.2.0 languageName: unknown linkType: soft @@ -1653,6 +1656,13 @@ __metadata: languageName: node linkType: hard +"@types/slug@npm:^5": + version: 5.0.3 + resolution: "@types/slug@npm:5.0.3" + checksum: 17b90c7ebc57f7aeeb8d49ef17445f1edf7f704440c23e4d326169faeef0e76191548ae2bcb6ba0667da710c5e2ad7e8b96fe52d5adc531b891b37327564a967 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -8141,6 +8151,13 @@ __metadata: languageName: node linkType: hard +"slug@npm:^5.2.0": + version: 5.2.0 + resolution: "slug@npm:5.2.0" + checksum: ffb75ad26199445b564c9e076df09e887fdc047c945eb050ac6bf7f4f45234fefcb030bb0fa2668884df07f809db78e5226e647a956afbd5eb6aca877ca8e276 + languageName: node + linkType: hard + "smart-buffer@npm:^4.0.2, smart-buffer@npm:^4.1.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" -- cgit v1.2.3-54-g00ecf