Skip to content

Commit

Permalink
Connect: Generate json schema for app config (#22538)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
gzdunek committed Mar 7, 2023
1 parent a5370d5 commit 9967149
Show file tree
Hide file tree
Showing 16 changed files with 536 additions and 376 deletions.
3 changes: 2 additions & 1 deletion web/packages/teleterm/package.json
Expand Up @@ -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"
}
18 changes: 12 additions & 6 deletions web/packages/teleterm/src/main.ts
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 5 additions & 4 deletions web/packages/teleterm/src/mainProcess/fixtures/mocks.ts
Expand Up @@ -25,10 +25,11 @@ export class MockMainProcessClient implements MainProcessClient {
configService: ReturnType<typeof createConfigService>;

constructor(private runtimeSettings: Partial<RuntimeSettings> = {}) {
this.configService = createConfigService(
createMockFileStorage(),
this.getRuntimeSettings().platform
);
this.configService = createConfigService({
configFile: createMockFileStorage(),
jsonSchemaFile: createMockFileStorage(),
platform: this.getRuntimeSettings().platform,
});
}

getRuntimeSettings(): RuntimeSettings {
Expand Down
4 changes: 3 additions & 1 deletion web/packages/teleterm/src/mainProcess/types.ts
Expand Up @@ -131,5 +131,7 @@ export enum ConfigServiceEventType {
export enum FileStorageEventType {
Get = 'Get',
Put = 'Put',
PutAllSync = 'PutAllSync',
WriteSync = 'WriteSync',
Replace = 'Replace',
GetFilePath = 'GetFilePath',
}
206 changes: 206 additions & 0 deletions 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<typeof createAppConfigSchema>;
export type AppConfig = z.infer<AppConfigSchema>;

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.`;
}
105 changes: 105 additions & 0 deletions 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);
});

0 comments on commit 9967149

Please sign in to comment.