From 99671496a02b7b2bc9bfadcbe80c79207c9b17db Mon Sep 17 00:00:00 2001 From: Grzegorz Zdunek Date: Tue, 7 Mar 2023 15:39:49 +0100 Subject: [PATCH] Connect: Generate json schema for app config (#22538) * Rename `putAllSync` to `writeSync` * Extend `FileStorage` with functions to replace an entire state and return file path used to create it * Add `updateJsonSchema` function * Generate schema for the app config * Always return a string from `createMockFileStorage().getFilePath()` * Add missing license headers * Rename 'teleport_connect_config_schema.json' to 'schema_app_config.json' * Rename `getKeyboardShortcutDescription` to `getShortcutDesc` * Rename `keyboardShortcutSchema` to `shortcutSchema` * Update a valid shortcut message * Rename `configJsonSchemaFile` to `jsonSchema` * Simplify `updateJsonSchema` * Set `$refStrategy` to none * Add missing description for `usageReporting.enabled` * Bump zod to the latest (improves TS performance) * Move `configService` implementation to `configStore` * Rename `configStore` to `configService` * Move `updateJsonSchema` to `createConfigService` * Move `validateStoredConfig` outside `createConfigService` * Rename `createAppConfigSchema.ts` to `appConfigSchema.ts`, `getKeyboardShortcutSchema.ts` to `keyboardShortcutSchema.ts` * Export `createKeyboardShortcutSchema` * Add license header --- web/packages/teleterm/package.json | 3 +- web/packages/teleterm/src/main.ts | 18 +- .../src/mainProcess/fixtures/mocks.ts | 9 +- .../teleterm/src/mainProcess/types.ts | 4 +- .../src/services/config/appConfigSchema.ts | 206 +++++++++++++ .../src/services/config/configService.test.ts | 105 +++++++ .../src/services/config/configService.ts | 271 ++++++++---------- .../src/services/config/configStore.test.ts | 82 ------ .../src/services/config/configStore.ts | 92 ------ .../teleterm/src/services/config/index.ts | 1 + ...test.ts => keyboardShortcutSchema.test.ts} | 6 +- ...cutSchema.ts => keyboardShortcutSchema.ts} | 2 +- .../src/services/fileStorage/fileStorage.ts | 48 +++- .../services/fileStorage/fileStorageClient.ts | 34 ++- .../services/fileStorage/fixtures/mocks.ts | 18 +- yarn.lock | 13 +- 16 files changed, 536 insertions(+), 376 deletions(-) create mode 100644 web/packages/teleterm/src/services/config/appConfigSchema.ts create mode 100644 web/packages/teleterm/src/services/config/configService.test.ts delete mode 100644 web/packages/teleterm/src/services/config/configStore.test.ts delete mode 100644 web/packages/teleterm/src/services/config/configStore.ts rename web/packages/teleterm/src/services/config/{getKeyboardShortcutSchema.test.ts => keyboardShortcutSchema.test.ts} (96%) rename web/packages/teleterm/src/services/config/{getKeyboardShortcutSchema.ts => keyboardShortcutSchema.ts} (98%) diff --git a/web/packages/teleterm/package.json b/web/packages/teleterm/package.json index e1b0f58bc4509..ea053321eb070 100644 --- a/web/packages/teleterm/package.json +++ b/web/packages/teleterm/package.json @@ -56,7 +56,8 @@ "winston": "^3.3.3", "xterm": "^5.0.0", "xterm-addon-fit": "^0.7.0", - "zod": "^3.20.0" + "zod": "^3.21.2", + "zod-to-json-schema": "^3.20.4" }, "productName": "Teleport Connect" } diff --git a/web/packages/teleterm/src/main.ts b/web/packages/teleterm/src/main.ts index 7805585bc4174..e780a92ae86e0 100644 --- a/web/packages/teleterm/src/main.ts +++ b/web/packages/teleterm/src/main.ts @@ -47,14 +47,20 @@ function initializeApp(): void { filePath: path.join(settings.userDataDir, 'app_state.json'), debounceWrites: true, }); - const appConfigFileStorage = createFileStorage({ + const configFileStorage = createFileStorage({ filePath: path.join(settings.userDataDir, 'app_config.json'), debounceWrites: false, }); - const configService = createConfigService( - appConfigFileStorage, - settings.platform - ); + const configJsonSchemaFileStorage = createFileStorage({ + filePath: path.join(settings.userDataDir, 'schema_app_config.json'), + debounceWrites: false, + }); + + const configService = createConfigService({ + configFile: configFileStorage, + jsonSchemaFile: configJsonSchemaFileStorage, + platform: settings.platform, + }); const windowsManager = new WindowsManager(appStateFileStorage, settings); process.on('uncaughtException', error => { @@ -92,7 +98,7 @@ function initializeApp(): void { app.on('will-quit', async event => { event.preventDefault(); - appStateFileStorage.putAllSync(); + appStateFileStorage.writeSync(); globalShortcut.unregisterAll(); try { await mainProcess.dispose(); diff --git a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts index ead746ab08572..e78d9c3e3ad86 100644 --- a/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts +++ b/web/packages/teleterm/src/mainProcess/fixtures/mocks.ts @@ -25,10 +25,11 @@ export class MockMainProcessClient implements MainProcessClient { configService: ReturnType; constructor(private runtimeSettings: Partial = {}) { - this.configService = createConfigService( - createMockFileStorage(), - this.getRuntimeSettings().platform - ); + this.configService = createConfigService({ + configFile: createMockFileStorage(), + jsonSchemaFile: createMockFileStorage(), + platform: this.getRuntimeSettings().platform, + }); } getRuntimeSettings(): RuntimeSettings { diff --git a/web/packages/teleterm/src/mainProcess/types.ts b/web/packages/teleterm/src/mainProcess/types.ts index 214706bb049f6..12e06edd89fee 100644 --- a/web/packages/teleterm/src/mainProcess/types.ts +++ b/web/packages/teleterm/src/mainProcess/types.ts @@ -131,5 +131,7 @@ export enum ConfigServiceEventType { export enum FileStorageEventType { Get = 'Get', Put = 'Put', - PutAllSync = 'PutAllSync', + WriteSync = 'WriteSync', + Replace = 'Replace', + GetFilePath = 'GetFilePath', } diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts new file mode 100644 index 0000000000000..2429561d6eb8d --- /dev/null +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -0,0 +1,206 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; + +import { Platform } from 'teleterm/mainProcess/types'; + +import { createKeyboardShortcutSchema } from './keyboardShortcutSchema'; + +export type AppConfigSchema = ReturnType; +export type AppConfig = z.infer; + +export const createAppConfigSchema = (platform: Platform) => { + const defaultKeymap = getDefaultKeymap(platform); + const defaultTerminalFont = getDefaultTerminalFont(platform); + + const shortcutSchema = createKeyboardShortcutSchema(platform); + + // `keymap.` prefix is used in `initUi.ts` in a predicate function. + return z.object({ + 'usageReporting.enabled': z + .boolean() + .default(false) + .describe('Enables collecting of anonymous usage data.'), + 'keymap.tab1': shortcutSchema + .default(defaultKeymap['tab1']) + .describe(getShortcutDesc('open tab 1')), + 'keymap.tab2': shortcutSchema + .default(defaultKeymap['tab2']) + .describe(getShortcutDesc('open tab 2')), + 'keymap.tab3': shortcutSchema + .default(defaultKeymap['tab3']) + .describe(getShortcutDesc('open tab 3')), + 'keymap.tab4': shortcutSchema + .default(defaultKeymap['tab4']) + .describe(getShortcutDesc('open tab 4')), + 'keymap.tab5': shortcutSchema + .default(defaultKeymap['tab5']) + .describe(getShortcutDesc('open tab 5')), + 'keymap.tab6': shortcutSchema + .default(defaultKeymap['tab6']) + .describe(getShortcutDesc('open tab 6')), + 'keymap.tab7': shortcutSchema + .default(defaultKeymap['tab7']) + .describe(getShortcutDesc('open tab 7')), + 'keymap.tab8': shortcutSchema + .default(defaultKeymap['tab8']) + .describe(getShortcutDesc('open tab 8')), + 'keymap.tab9': shortcutSchema + .default(defaultKeymap['tab9']) + .describe(getShortcutDesc('open tab 9')), + 'keymap.closeTab': shortcutSchema + .default(defaultKeymap['closeTab']) + .describe(getShortcutDesc('close a tab')), + 'keymap.newTab': shortcutSchema + .default(defaultKeymap['newTab']) + .describe(getShortcutDesc('open a new tab')), + 'keymap.previousTab': shortcutSchema + .default(defaultKeymap['previousTab']) + .describe(getShortcutDesc('go to the previous tab')), + 'keymap.nextTab': shortcutSchema + .default(defaultKeymap['nextTab']) + .describe(getShortcutDesc('go to the next tab')), + 'keymap.openConnections': shortcutSchema + .default(defaultKeymap['openConnections']) + .describe(getShortcutDesc('open the connection panel')), + 'keymap.openClusters': shortcutSchema + .default(defaultKeymap['openClusters']) + .describe(getShortcutDesc('open the clusters panel')), + 'keymap.openProfiles': shortcutSchema + .default(defaultKeymap['openProfiles']) + .describe(getShortcutDesc('open the profiles panel')), + 'keymap.openQuickInput': shortcutSchema + .default(defaultKeymap['openQuickInput']) + .describe(getShortcutDesc('open the command bar')), + /** + * This value can be provided by the user and is unsanitized. This means that it cannot be directly interpolated + * in a styled component or used in CSS, as it may inject malicious CSS code. + * Before using it, sanitize it with `CSS.escape` or pass it as a `style` prop. + * Read more https://frontarm.com/james-k-nelson/how-can-i-use-css-in-js-securely/. + */ + 'terminal.fontFamily': z + .string() + .default(defaultTerminalFont) + .describe('Font family for the terminal.'), + 'terminal.fontSize': z + .number() + .int() + .min(1) + .max(256) + .default(15) + .describe('Font size for the terminal.'), + }); +}; + +export type KeyboardShortcutAction = + | 'tab1' + | 'tab2' + | 'tab3' + | 'tab4' + | 'tab5' + | 'tab6' + | 'tab7' + | 'tab8' + | 'tab9' + | 'closeTab' + | 'newTab' + | 'previousTab' + | 'nextTab' + | 'openQuickInput' + | 'openConnections' + | 'openClusters' + | 'openProfiles'; + +const getDefaultKeymap = (platform: Platform) => { + switch (platform) { + case 'win32': + return { + tab1: 'Ctrl+1', + tab2: 'Ctrl+2', + tab3: 'Ctrl+3', + tab4: 'Ctrl+4', + tab5: 'Ctrl+5', + tab6: 'Ctrl+6', + tab7: 'Ctrl+7', + tab8: 'Ctrl+8', + tab9: 'Ctrl+9', + closeTab: 'Ctrl+W', + newTab: 'Ctrl+T', + previousTab: 'Ctrl+Shift+Tab', + nextTab: 'Ctrl+Tab', + openQuickInput: 'Ctrl+K', + openConnections: 'Ctrl+P', + openClusters: 'Ctrl+E', + openProfiles: 'Ctrl+I', + }; + case 'linux': + return { + tab1: 'Alt+1', + tab2: 'Alt+2', + tab3: 'Alt+3', + tab4: 'Alt+4', + tab5: 'Alt+5', + tab6: 'Alt+6', + tab7: 'Alt+7', + tab8: 'Alt+8', + tab9: 'Alt+9', + closeTab: 'Ctrl+W', + newTab: 'Ctrl+T', + previousTab: 'Ctrl+Shift+Tab', + nextTab: 'Ctrl+Tab', + openQuickInput: 'Ctrl+K', + openConnections: 'Ctrl+P', + openClusters: 'Ctrl+E', + openProfiles: 'Ctrl+I', + }; + case 'darwin': + return { + tab1: 'Command+1', + tab2: 'Command+2', + tab3: 'Command+3', + tab4: 'Command+4', + tab5: 'Command+5', + tab6: 'Command+6', + tab7: 'Command+7', + tab8: 'Command+8', + tab9: 'Command+9', + closeTab: 'Command+W', + newTab: 'Command+T', + previousTab: 'Control+Shift+Tab', + nextTab: 'Control+Tab', + openQuickInput: 'Command+K', + openConnections: 'Command+P', + openClusters: 'Command+E', + openProfiles: 'Command+I', + }; + } +}; + +function getDefaultTerminalFont(platform: Platform) { + switch (platform) { + case 'win32': + return "'Consolas', 'Courier New', monospace"; + case 'linux': + return "'Droid Sans Mono', 'Courier New', monospace, 'Droid Sans Fallback'"; + case 'darwin': + return "Menlo, Monaco, 'Courier New', monospace"; + } +} + +function getShortcutDesc(actionDesc: string): string { + return `Shortcut to ${actionDesc}. A valid shortcut contains at least one modifier and a single key code, for example "Shift+Tab". Function keys do not require a modifier.`; +} diff --git a/web/packages/teleterm/src/services/config/configService.test.ts b/web/packages/teleterm/src/services/config/configService.test.ts new file mode 100644 index 0000000000000..69e5fe89afc04 --- /dev/null +++ b/web/packages/teleterm/src/services/config/configService.test.ts @@ -0,0 +1,105 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Logger, { NullService } from 'teleterm/logger'; +import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mocks'; + +import { createConfigService } from './configService'; + +beforeAll(() => { + Logger.init(new NullService()); +}); + +test('stored and default values are combined', () => { + const configFile = createMockFileStorage(); + configFile.put('usageReporting.enabled', true); + const configService = createConfigService({ + configFile, + jsonSchemaFile: createMockFileStorage(), + platform: 'darwin', + }); + + expect(configService.getStoredConfigErrors()).toBeUndefined(); + + const usageReportingEnabled = configService.get('usageReporting.enabled'); + expect(usageReportingEnabled.value).toBe(true); + expect(usageReportingEnabled.metadata.isStored).toBe(true); + + const terminalFontSize = configService.get('terminal.fontSize'); + expect(terminalFontSize.value).toBe(15); + expect(terminalFontSize.metadata.isStored).toBe(false); +}); + +test('in case of invalid value a default one is returned', () => { + const configFile = createMockFileStorage(); + configFile.put('usageReporting.enabled', 'abcde'); + const configService = createConfigService({ + configFile: configFile, + jsonSchemaFile: createMockFileStorage(), + platform: 'darwin', + }); + + expect(configService.getStoredConfigErrors()).toStrictEqual([ + { + code: 'invalid_type', + expected: 'boolean', + received: 'string', + message: 'Expected boolean, received string', + path: ['usageReporting.enabled'], + }, + ]); + + const usageReportingEnabled = configService.get('usageReporting.enabled'); + expect(usageReportingEnabled.value).toBe(false); + expect(usageReportingEnabled.metadata.isStored).toBe(false); + + const terminalFontSize = configService.get('terminal.fontSize'); + expect(terminalFontSize.value).toBe(15); + expect(terminalFontSize.metadata.isStored).toBe(false); +}); + +test('calling set updated the value in store', () => { + const configFile = createMockFileStorage(); + const configService = createConfigService({ + configFile, + jsonSchemaFile: createMockFileStorage(), + platform: 'darwin', + }); + + configService.set('usageReporting.enabled', true); + + const usageReportingEnabled = configService.get('usageReporting.enabled'); + expect(usageReportingEnabled.value).toBe(true); + expect(usageReportingEnabled.metadata.isStored).toBe(true); +}); + +test('field linking to the json schema and the json schema itself are updated', () => { + const configFile = createMockFileStorage(); + const jsonSchemaFile = createMockFileStorage({ + filePath: '~/config_schema.json', + }); + + jest.spyOn(jsonSchemaFile, 'replace'); + + createConfigService({ + configFile, + jsonSchemaFile, + platform: 'darwin', + }); + + expect(configFile.get('$schema')).toBe('config_schema.json'); + expect(jsonSchemaFile.replace).toHaveBeenCalledTimes(1); +}); diff --git a/web/packages/teleterm/src/services/config/configService.ts b/web/packages/teleterm/src/services/config/configService.ts index 3d229f5973c73..7563ef3f95734 100644 --- a/web/packages/teleterm/src/services/config/configService.ts +++ b/web/packages/teleterm/src/services/config/configService.ts @@ -14,168 +14,127 @@ * limitations under the License. */ -import { z } from 'zod'; +import path from 'path'; + +import { z, ZodIssue } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; import { FileStorage } from 'teleterm/services/fileStorage'; +import Logger from 'teleterm/logger'; import { Platform } from 'teleterm/mainProcess/types'; -import { createConfigStore } from './configStore'; -import { getKeyboardShortcutSchema } from './getKeyboardShortcutSchema'; - -const createAppConfigSchema = (platform: Platform) => { - const defaultKeymap = getDefaultKeymap(platform); - const defaultTerminalFont = getDefaultTerminalFont(platform); - - const keyboardShortcutSchema = getKeyboardShortcutSchema(platform); - - // `keymap.` prefix is used in `initUi.ts` in a predicate function. - return z.object({ - 'usageReporting.enabled': z.boolean().default(false), - 'keymap.tab1': keyboardShortcutSchema.default(defaultKeymap['tab1']), - 'keymap.tab2': keyboardShortcutSchema.default(defaultKeymap['tab2']), - 'keymap.tab3': keyboardShortcutSchema.default(defaultKeymap['tab3']), - 'keymap.tab4': keyboardShortcutSchema.default(defaultKeymap['tab4']), - 'keymap.tab5': keyboardShortcutSchema.default(defaultKeymap['tab5']), - 'keymap.tab6': keyboardShortcutSchema.default(defaultKeymap['tab6']), - 'keymap.tab7': keyboardShortcutSchema.default(defaultKeymap['tab7']), - 'keymap.tab8': keyboardShortcutSchema.default(defaultKeymap['tab8']), - 'keymap.tab9': keyboardShortcutSchema.default(defaultKeymap['tab9']), - 'keymap.closeTab': keyboardShortcutSchema.default( - defaultKeymap['closeTab'] - ), - 'keymap.newTab': keyboardShortcutSchema.default(defaultKeymap['newTab']), - 'keymap.previousTab': keyboardShortcutSchema.default( - defaultKeymap['previousTab'] - ), - 'keymap.nextTab': keyboardShortcutSchema.default(defaultKeymap['nextTab']), - 'keymap.openConnections': keyboardShortcutSchema.default( - defaultKeymap['openConnections'] - ), - 'keymap.openClusters': keyboardShortcutSchema.default( - defaultKeymap['openClusters'] - ), - 'keymap.openProfiles': keyboardShortcutSchema.default( - defaultKeymap['openProfiles'] - ), - 'keymap.openQuickInput': keyboardShortcutSchema.default( - defaultKeymap['openQuickInput'] - ), - /** - * This value can be provided by the user and is unsanitized. This means that it cannot be directly interpolated - * in a styled component or used in CSS, as it may inject malicious CSS code. - * Before using it, sanitize it with `CSS.escape` or pass it as a `style` prop. - * Read more https://frontarm.com/james-k-nelson/how-can-i-use-css-in-js-securely/. - */ - 'terminal.fontFamily': z.string().default(defaultTerminalFont), - 'terminal.fontSize': z.number().int().min(1).max(256).default(15), - }); -}; - -export type AppConfig = z.infer>; - -export type KeyboardShortcutAction = - | 'tab1' - | 'tab2' - | 'tab3' - | 'tab4' - | 'tab5' - | 'tab6' - | 'tab7' - | 'tab8' - | 'tab9' - | 'closeTab' - | 'newTab' - | 'previousTab' - | 'nextTab' - | 'openQuickInput' - | 'openConnections' - | 'openClusters' - | 'openProfiles'; - -const getDefaultKeymap = (platform: Platform) => { - switch (platform) { - case 'win32': - return { - tab1: 'Ctrl+1', - tab2: 'Ctrl+2', - tab3: 'Ctrl+3', - tab4: 'Ctrl+4', - tab5: 'Ctrl+5', - tab6: 'Ctrl+6', - tab7: 'Ctrl+7', - tab8: 'Ctrl+8', - tab9: 'Ctrl+9', - closeTab: 'Ctrl+W', - newTab: 'Ctrl+T', - previousTab: 'Ctrl+Shift+Tab', - nextTab: 'Ctrl+Tab', - openQuickInput: 'Ctrl+K', - openConnections: 'Ctrl+P', - openClusters: 'Ctrl+E', - openProfiles: 'Ctrl+I', - }; - case 'linux': - return { - tab1: 'Alt+1', - tab2: 'Alt+2', - tab3: 'Alt+3', - tab4: 'Alt+4', - tab5: 'Alt+5', - tab6: 'Alt+6', - tab7: 'Alt+7', - tab8: 'Alt+8', - tab9: 'Alt+9', - closeTab: 'Ctrl+W', - newTab: 'Ctrl+T', - previousTab: 'Ctrl+Shift+Tab', - nextTab: 'Ctrl+Tab', - openQuickInput: 'Ctrl+K', - openConnections: 'Ctrl+P', - openClusters: 'Ctrl+E', - openProfiles: 'Ctrl+I', - }; - case 'darwin': - return { - tab1: 'Command+1', - tab2: 'Command+2', - tab3: 'Command+3', - tab4: 'Command+4', - tab5: 'Command+5', - tab6: 'Command+6', - tab7: 'Command+7', - tab8: 'Command+8', - tab9: 'Command+9', - closeTab: 'Command+W', - newTab: 'Command+T', - previousTab: 'Control+Shift+Tab', - nextTab: 'Control+Tab', - openQuickInput: 'Command+K', - openConnections: 'Command+P', - openClusters: 'Command+E', - openProfiles: 'Command+I', - }; - } -}; - -function getDefaultTerminalFont(platform: Platform) { - switch (platform) { - case 'win32': - return "'Consolas', 'Courier New', monospace"; - case 'linux': - return "'Droid Sans Mono', 'Courier New', monospace, 'Droid Sans Fallback'"; - case 'darwin': - return "Menlo, Monaco, 'Courier New', monospace"; - } +import { + createAppConfigSchema, + AppConfigSchema, + AppConfig, +} from './appConfigSchema'; + +const logger = new Logger('ConfigService'); + +export interface ConfigService { + get( + key: K + ): { value: AppConfig[K]; metadata: { isStored: boolean } }; + + set(key: K, value: AppConfig[K]): void; + + getStoredConfigErrors(): ZodIssue[] | undefined; } -export function createConfigService( - appConfigFileStorage: FileStorage, - platform: Platform -) { - return createConfigStore( - createAppConfigSchema(platform), - appConfigFileStorage +export function createConfigService({ + configFile, + jsonSchemaFile, + platform, +}: { + configFile: FileStorage; + jsonSchemaFile: FileStorage; + platform: Platform; +}): ConfigService { + const schema = createAppConfigSchema(platform); + updateJsonSchema({ schema, configFile, jsonSchemaFile }); + + const { storedConfig, configWithDefaults, errors } = validateStoredConfig( + schema, + configFile ); + + return { + get: key => ({ + value: configWithDefaults[key], + metadata: { isStored: storedConfig[key] !== undefined }, + }), + set: (key, value) => { + configFile.put(key as string, value); + configWithDefaults[key] = value; + storedConfig[key] = value; + }, + getStoredConfigErrors: () => errors, + }; } -export type ConfigService = ReturnType; +function updateJsonSchema({ + schema, + configFile, + jsonSchemaFile, +}: { + schema: AppConfigSchema; + configFile: FileStorage; + jsonSchemaFile: FileStorage; +}): void { + const jsonSchema = zodToJsonSchema( + // Add $schema field to prevent marking it as a not allowed property. + schema.extend({ $schema: z.string() }), + { $refStrategy: 'none' } + ); + const jsonSchemaFileName = path.basename(jsonSchemaFile.getFilePath()); + const jsonSchemaFileNameInConfig = configFile.get('$schema'); + + jsonSchemaFile.replace(jsonSchema); + + if (jsonSchemaFileNameInConfig !== jsonSchemaFileName) { + configFile.put('$schema', jsonSchemaFileName); + } +} + +//TODO (gzdunek): syntax errors of the JSON file are silently ignored, report +// them to the user too +function validateStoredConfig( + schema: AppConfigSchema, + configFile: FileStorage +): { + storedConfig: Partial; + configWithDefaults: AppConfig; + errors: ZodIssue[] | undefined; +} { + const parse = (data: Partial) => schema.safeParse(data); + + const storedConfig = configFile.get>(); + const parsed = parse(storedConfig); + if (parsed.success === true) { + return { + storedConfig, + configWithDefaults: parsed.data, + errors: undefined, + }; + } + const withoutInvalidKeys = { ...storedConfig }; + parsed.error.issues.forEach(error => { + // remove only top-level keys + delete withoutInvalidKeys[error.path[0]]; + logger.info( + `Invalid config key, error: ${error.message} at ${error.path.join('.')}` + ); + }); + const reParsed = parse(withoutInvalidKeys); + if (reParsed.success === false) { + // it can happen when a default value does not pass validation + throw new Error( + `Re-parsing config file failed \n${reParsed.error.message}` + ); + } + return { + storedConfig: withoutInvalidKeys, + configWithDefaults: reParsed.data, + errors: parsed.error.issues, + }; +} diff --git a/web/packages/teleterm/src/services/config/configStore.test.ts b/web/packages/teleterm/src/services/config/configStore.test.ts deleted file mode 100644 index fd892edf947a2..0000000000000 --- a/web/packages/teleterm/src/services/config/configStore.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { z } from 'zod'; - -import Logger, { NullService } from 'teleterm/logger'; -import { createMockFileStorage } from 'teleterm/services/fileStorage/fixtures/mocks'; - -import { createConfigStore } from './configStore'; - -beforeAll(() => { - Logger.init(new NullService()); -}); - -const schema = z.object({ - 'fonts.monoFamily': z.string().default('Arial'), - 'usageReporting.enabled': z.boolean().default(false), -}); - -test('stored and default values are combined', () => { - const fileStorage = createMockFileStorage(); - fileStorage.put('usageReporting.enabled', true); - const configStore = createConfigStore(schema, fileStorage); - - expect(configStore.getStoredConfigErrors()).toBeUndefined(); - - const usageReportingEnabled = configStore.get('usageReporting.enabled'); - expect(usageReportingEnabled.value).toBe(true); - expect(usageReportingEnabled.metadata.isStored).toBe(true); - - const monoFontFamily = configStore.get('fonts.monoFamily'); - expect(monoFontFamily.value).toBe('Arial'); - expect(monoFontFamily.metadata.isStored).toBe(false); -}); - -test('in case of invalid value a default one is returned', () => { - const fileStorage = createMockFileStorage(); - fileStorage.put('usageReporting.enabled', 'abcde'); - const configStore = createConfigStore(schema, fileStorage); - - expect(configStore.getStoredConfigErrors()).toStrictEqual([ - { - code: 'invalid_type', - expected: 'boolean', - received: 'string', - message: 'Expected boolean, received string', - path: ['usageReporting.enabled'], - }, - ]); - - const usageReportingEnabled = configStore.get('usageReporting.enabled'); - expect(usageReportingEnabled.value).toBe(false); - expect(usageReportingEnabled.metadata.isStored).toBe(false); - - const monoFontFamily = configStore.get('fonts.monoFamily'); - expect(monoFontFamily.value).toBe('Arial'); - expect(monoFontFamily.metadata.isStored).toBe(false); -}); - -test('calling set updated the value in store', () => { - const fileStorage = createMockFileStorage(); - const configStore = createConfigStore(schema, fileStorage); - - configStore.set('usageReporting.enabled', true); - - const usageReportingEnabled = configStore.get('usageReporting.enabled'); - expect(usageReportingEnabled.value).toBe(true); - expect(usageReportingEnabled.metadata.isStored).toBe(true); -}); diff --git a/web/packages/teleterm/src/services/config/configStore.ts b/web/packages/teleterm/src/services/config/configStore.ts deleted file mode 100644 index a10e034465aa1..0000000000000 --- a/web/packages/teleterm/src/services/config/configStore.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright 2023 Gravitational, Inc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { z, ZodIssue } from 'zod'; - -import { FileStorage } from 'teleterm/services/fileStorage'; -import Logger from 'teleterm/logger'; - -const logger = new Logger('ConfigStore'); - -export function createConfigStore< - Schema extends z.ZodTypeAny, - Shape = z.infer ->(schema: Schema, fileStorage: FileStorage) { - const { storedConfig, configWithDefaults, errors } = validateStoredConfig(); - - function get( - key: K - ): { value: Shape[K]; metadata: { isStored: boolean } } { - return { - value: configWithDefaults[key], - metadata: { isStored: storedConfig[key] !== undefined }, - }; - } - - function set(key: K, value: Shape[K]): void { - fileStorage.put(key as string, value); - configWithDefaults[key] = value; - storedConfig[key] = value; - } - - function getStoredConfigErrors(): ZodIssue[] | undefined { - return errors; - } - - function parse(data: Partial) { - return schema.safeParse(data); - } - - //TODO (gzdunek): syntax errors of the JSON file are silently ignored, report - // them to the user too - function validateStoredConfig(): { - storedConfig: Partial; - configWithDefaults: Shape; - errors: ZodIssue[] | undefined; - } { - const storedConfig = fileStorage.get>(); - const parsed = parse(storedConfig); - if (parsed.success === true) { - return { - storedConfig, - configWithDefaults: parsed.data, - errors: undefined, - }; - } - const withoutInvalidKeys = { ...storedConfig }; - parsed.error.issues.forEach(error => { - // remove only top-level keys - delete withoutInvalidKeys[error.path[0]]; - logger.info( - `Invalid config key, error: ${error.message} at ${error.path.join('.')}` - ); - }); - const reParsed = parse(withoutInvalidKeys); - if (reParsed.success === false) { - // it can happen when a default value does not pass validation - throw new Error( - `Re-parsing config file failed \n${reParsed.error.message}` - ); - } - return { - storedConfig: withoutInvalidKeys, - configWithDefaults: reParsed.data, - errors: parsed.error.issues, - }; - } - - return { get, set, getStoredConfigErrors }; -} diff --git a/web/packages/teleterm/src/services/config/index.ts b/web/packages/teleterm/src/services/config/index.ts index 8bc8376c8e463..9fd174ba63317 100644 --- a/web/packages/teleterm/src/services/config/index.ts +++ b/web/packages/teleterm/src/services/config/index.ts @@ -16,3 +16,4 @@ export * from './configService'; export * from './configServiceClient'; +export { AppConfig, KeyboardShortcutAction } from './appConfigSchema'; diff --git a/web/packages/teleterm/src/services/config/getKeyboardShortcutSchema.test.ts b/web/packages/teleterm/src/services/config/keyboardShortcutSchema.test.ts similarity index 96% rename from web/packages/teleterm/src/services/config/getKeyboardShortcutSchema.test.ts rename to web/packages/teleterm/src/services/config/keyboardShortcutSchema.test.ts index 61427a95a5a7a..f2f08aef049f6 100644 --- a/web/packages/teleterm/src/services/config/getKeyboardShortcutSchema.test.ts +++ b/web/packages/teleterm/src/services/config/keyboardShortcutSchema.test.ts @@ -17,15 +17,15 @@ import { z, ZodError } from 'zod'; import { - getKeyboardShortcutSchema, + createKeyboardShortcutSchema, invalidModifierIssue, invalidKeyCodeIssue, duplicateModifierIssue, missingModifierIssue, -} from './getKeyboardShortcutSchema'; +} from './keyboardShortcutSchema'; const schema = z.object({ - 'keymap.tab1': getKeyboardShortcutSchema('darwin'), + 'keymap.tab1': createKeyboardShortcutSchema('darwin'), }); function getZodError(...issues: any[]): z.ZodError { diff --git a/web/packages/teleterm/src/services/config/getKeyboardShortcutSchema.ts b/web/packages/teleterm/src/services/config/keyboardShortcutSchema.ts similarity index 98% rename from web/packages/teleterm/src/services/config/getKeyboardShortcutSchema.ts rename to web/packages/teleterm/src/services/config/keyboardShortcutSchema.ts index 931e147234727..fda66e062ddcb 100644 --- a/web/packages/teleterm/src/services/config/getKeyboardShortcutSchema.ts +++ b/web/packages/teleterm/src/services/config/keyboardShortcutSchema.ts @@ -55,7 +55,7 @@ export function missingModifierIssue(keyCode: string): z.IssueData { }; } -export function getKeyboardShortcutSchema(platform: Platform) { +export function createKeyboardShortcutSchema(platform: Platform) { const allowedModifiers = getSupportedModifiers(platform); return z diff --git a/web/packages/teleterm/src/services/fileStorage/fileStorage.ts b/web/packages/teleterm/src/services/fileStorage/fileStorage.ts index 83e72dd57a713..94455aa9af25e 100644 --- a/web/packages/teleterm/src/services/fileStorage/fileStorage.ts +++ b/web/packages/teleterm/src/services/fileStorage/fileStorage.ts @@ -23,11 +23,20 @@ import Logger from 'teleterm/logger'; const logger = new Logger('FileStorage'); export interface FileStorage { - put(path: string, json: any): void; + /** Asynchronously updates value for a given key. */ + put(key: string, json: any): void; - putAllSync(): void; + /** Asynchronously replaces the entire storage state with a new value. */ + replace(json: any): void; - get(path?: string): T; + /** Synchronously writes the storage state to disk. */ + writeSync(): void; + + /** Returns value for a given key. If the key is omitted, the entire storage state is returned. */ + get(key?: string): T; + + /** Returns the file path used to create the storage. */ + getFilePath(): string; } export function createFileStorage(opts: { @@ -39,18 +48,14 @@ export function createFileStorage(opts: { } const { filePath } = opts; - const state = loadState(opts.filePath); + let state = loadState(opts.filePath); - function put(key: string, json: any) { + function put(key: string, json: any): void { state[key] = json; - const text = stringify(state); - - opts.debounceWrites - ? writeFileDebounced(filePath, text) - : writeFile(filePath, text); + stringifyAndWrite(); } - function putAllSync() { + function writeSync(): void { const text = stringify(state); try { fs.writeFileSync(filePath, text); @@ -63,10 +68,29 @@ export function createFileStorage(opts: { return key ? state[key] : (state as T); } + function replace(json: any): void { + state = json; + stringifyAndWrite(); + } + + function getFilePath(): string { + return opts.filePath; + } + + function stringifyAndWrite(): void { + const text = stringify(state); + + opts.debounceWrites + ? writeFileDebounced(filePath, text) + : writeFile(filePath, text); + } + return { put, - putAllSync, + writeSync, get, + replace, + getFilePath, }; } diff --git a/web/packages/teleterm/src/services/fileStorage/fileStorageClient.ts b/web/packages/teleterm/src/services/fileStorage/fileStorageClient.ts index c9ffff816b688..8be82f4b8e296 100644 --- a/web/packages/teleterm/src/services/fileStorage/fileStorageClient.ts +++ b/web/packages/teleterm/src/services/fileStorage/fileStorageClient.ts @@ -29,11 +29,15 @@ export function subscribeToFileStorageEvents(configService: FileStorage): void { (event, eventType: FileStorageEventType, item) => { switch (eventType) { case FileStorageEventType.Get: - return (event.returnValue = configService.get(item.path)); + return (event.returnValue = configService.get(item.key)); case FileStorageEventType.Put: - return configService.put(item.path, item.json); - case FileStorageEventType.PutAllSync: - return configService.putAllSync(); + return configService.put(item.key, item.json); + case FileStorageEventType.WriteSync: + return configService.writeSync(); + case FileStorageEventType.Replace: + return configService.replace(item.json); + case FileStorageEventType.GetFilePath: + return configService.getFilePath(); } } ); @@ -41,19 +45,29 @@ export function subscribeToFileStorageEvents(configService: FileStorage): void { export function createFileStorageClient(): FileStorage { return { - get: path => + get: key => ipcRenderer.sendSync(FileStorageEventChannel, FileStorageEventType.Get, { - path, + key, }), - put: (path, json) => + put: (key, json) => ipcRenderer.send(FileStorageEventChannel, FileStorageEventType.Put, { - path, + key, json, }), - putAllSync: () => + writeSync: () => ipcRenderer.send( FileStorageEventChannel, - FileStorageEventType.PutAllSync, + FileStorageEventType.WriteSync, + {} + ), + replace: json => + ipcRenderer.send(FileStorageEventChannel, FileStorageEventType.Replace, { + json, + }), + getFilePath: () => + ipcRenderer.sendSync( + FileStorageEventChannel, + FileStorageEventType.GetFilePath, {} ), }; diff --git a/web/packages/teleterm/src/services/fileStorage/fixtures/mocks.ts b/web/packages/teleterm/src/services/fileStorage/fixtures/mocks.ts index 5fce1077ffe17..09cd7c90bd10a 100644 --- a/web/packages/teleterm/src/services/fileStorage/fixtures/mocks.ts +++ b/web/packages/teleterm/src/services/fileStorage/fixtures/mocks.ts @@ -16,17 +16,27 @@ import { FileStorage } from 'teleterm/services/fileStorage'; -export function createMockFileStorage(): FileStorage { +export function createMockFileStorage(opts?: { + filePath: string; +}): FileStorage { let state = {}; return { - put(path: string, json: any) { - state[path] = json; + put(key: string, json: any) { + state[key] = json; }, get(key?: string): T { return key ? state[key] : (state as T); }, - putAllSync() {}, + writeSync() {}, + + replace(json: any) { + state = json; + }, + + getFilePath(): string { + return opts?.filePath || ''; + }, }; } diff --git a/yarn.lock b/yarn.lock index 928a6311f681e..1576c844fdfec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14851,10 +14851,15 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.20.0: - version "3.20.2" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.20.2.tgz#068606642c8f51b3333981f91c0a8ab37dfc2807" - integrity sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ== +zod-to-json-schema@^3.20.4: + version "3.20.4" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.20.4.tgz#155f687c5a059fdc0f1bb3ff32d6e9200036b6f4" + integrity sha512-Un9+kInJ2Zt63n6Z7mLqBifzzPcOyX+b+Exuzf7L1+xqck9Q2EPByyTRduV3kmSPaXaRer1JCsucubpgL1fipg== + +zod@^3.21.2: + version "3.21.2" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.2.tgz#a25425916d63b74d5ddd0b2a1bf733ecc093964b" + integrity sha512-0Ygy2/IZNIxHterZdHjE5Vb8hp1fUHJD/BGvSHj8QJx+UipEVNvo9WLchoyBpz5JIaN6KmdGDGYdloGzpFK98g== zone.js@^0.11.0: version "0.11.8"