Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Connect: Generate json schema for app config #22538

Merged
merged 23 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b0a68cb
Rename `putAllSync` to `writeSync`
gzdunek Mar 2, 2023
e43f448
Extend `FileStorage` with functions to replace an entire state and re…
gzdunek Mar 2, 2023
eda4004
Add `updateJsonSchema` function
gzdunek Mar 2, 2023
53867ac
Generate schema for the app config
gzdunek Mar 2, 2023
43e4a2e
Always return a string from `createMockFileStorage().getFilePath()`
gzdunek Mar 3, 2023
e4ca2b8
Add missing license headers
gzdunek Mar 3, 2023
949bb06
Rename 'teleport_connect_config_schema.json' to 'schema_app_config.json'
gzdunek Mar 6, 2023
c64b61e
Rename `getKeyboardShortcutDescription` to `getShortcutDesc`
gzdunek Mar 6, 2023
1c38840
Rename `keyboardShortcutSchema` to `shortcutSchema`
gzdunek Mar 6, 2023
2ddd5c3
Update a valid shortcut message
gzdunek Mar 6, 2023
4168cbe
Rename `configJsonSchemaFile` to `jsonSchema`
gzdunek Mar 6, 2023
66e6cc3
Simplify `updateJsonSchema`
gzdunek Mar 6, 2023
33da51c
Merge branch 'master' into gzdunek/generate-json-schema
gzdunek Mar 6, 2023
3d6a147
Set `$refStrategy` to none
gzdunek Mar 6, 2023
26818e6
Add missing description for `usageReporting.enabled`
gzdunek Mar 6, 2023
6736a36
Bump zod to the latest (improves TS performance)
gzdunek Mar 6, 2023
d51f074
Move `configService` implementation to `configStore`
gzdunek Mar 7, 2023
0d5c7c7
Rename `configStore` to `configService`
gzdunek Mar 7, 2023
415f3d8
Move `updateJsonSchema` to `createConfigService`
gzdunek Mar 7, 2023
1380bbf
Move `validateStoredConfig` outside `createConfigService`
gzdunek Mar 7, 2023
d59e291
Rename `createAppConfigSchema.ts` to `appConfigSchema.ts`, `getKeyboa…
gzdunek Mar 7, 2023
60eeb22
Export `createKeyboardShortcutSchema`
gzdunek Mar 7, 2023
19a6027
Add license header
gzdunek Mar 7, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion web/packages/teleterm/package.json
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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({
ravicious marked this conversation as resolved.
Show resolved Hide resolved
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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);
});
Loading