diff --git a/src/browser.ts b/src/browser.ts index 0030083f..366fe670 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -9,17 +9,27 @@ import { getCertificateSPKI } from './proxy' import { mkdtemp } from 'fs/promises' import path from 'path' import os from 'os' -import { proxyPort } from './main' +import { appSettings } from './main' const createUserDataDir = async () => { return mkdtemp(path.join(os.tmpdir(), 'k6-studio-')) } +function getBrowserPath() { + const { recorder } = appSettings + + if (recorder.detectBrowserPath) { + return computeSystemExecutablePath({ + browser: Browser.CHROME, + channel: ChromeReleaseChannel.STABLE, + }) + } + + return recorder.browserPath as string +} + export const launchBrowser = async (browserWindow: BrowserWindow) => { - const path = computeSystemExecutablePath({ - browser: Browser.CHROME, - channel: ChromeReleaseChannel.STABLE, - }) + const path = getBrowserPath() console.info(`browser path: ${path}`) const userDataDir = await createUserDataDir() @@ -54,7 +64,7 @@ export const launchBrowser = async (browserWindow: BrowserWindow) => { '--disable-background-networking', '--disable-component-update', '--disable-search-engine-choice-screen', - `--proxy-server=http://localhost:${proxyPort}`, + `--proxy-server=http://localhost:${appSettings.proxy.port}`, `--ignore-certificate-errors-spki-list=${certificateSPKI}`, disableChromeOptimizations, ], diff --git a/src/components/Form/FieldGroup.tsx b/src/components/Form/FieldGroup.tsx index 67a36ffc..0c6f93a4 100644 --- a/src/components/Form/FieldGroup.tsx +++ b/src/components/Form/FieldGroup.tsx @@ -11,6 +11,7 @@ type FieldGroupProps = BoxProps & { name: string label?: React.ReactNode hint?: React.ReactNode + hintType?: 'tooltip' | 'text' } export function FieldGroup({ @@ -19,6 +20,7 @@ export function FieldGroup({ name, errors, hint, + hintType = 'tooltip', ...props }: FieldGroupProps) { return ( @@ -29,12 +31,17 @@ export function FieldGroup({ {label} - {hint && ( + {hint && hintType === 'tooltip' && ( )} + {hint && hintType === 'text' && ( + + {hint} + + )} )} {children} diff --git a/src/components/Layout/ActivityBar/ActivityBar.tsx b/src/components/Layout/ActivityBar/ActivityBar.tsx index 41ad9061..c1990dac 100644 --- a/src/components/Layout/ActivityBar/ActivityBar.tsx +++ b/src/components/Layout/ActivityBar/ActivityBar.tsx @@ -8,6 +8,7 @@ import { VersionLabel } from './VersionLabel' import { HomeIcon } from '@/components/icons' import { NavIconButton } from './NavIconButton' import { ApplicationLogButton } from './ApplicationLogButton' +import { SettingsButton } from './SettingsButton' export function ActivityBar() { return ( @@ -41,6 +42,7 @@ export function ActivityBar() { + diff --git a/src/components/Layout/ActivityBar/SettingsButton.tsx b/src/components/Layout/ActivityBar/SettingsButton.tsx new file mode 100644 index 00000000..10615d5f --- /dev/null +++ b/src/components/Layout/ActivityBar/SettingsButton.tsx @@ -0,0 +1,25 @@ +import { SettingsDialog } from '@/components/Settings/SettingsDialog' +import { GearIcon } from '@radix-ui/react-icons' +import { Tooltip, IconButton } from '@radix-ui/themes' +import { useState } from 'react' + +export function SettingsButton() { + const [open, setOpen] = useState(false) + + return ( + <> + + setOpen(true)} + > + + + + + + + ) +} diff --git a/src/components/Settings/ProxySettings.tsx b/src/components/Settings/ProxySettings.tsx new file mode 100644 index 00000000..b748b45b --- /dev/null +++ b/src/components/Settings/ProxySettings.tsx @@ -0,0 +1,127 @@ +import { FieldGroup } from '@/components/Form' +import { ProxyStatus } from '@/types' +import { stringAsNumber } from '@/utils/form' +import { css } from '@emotion/react' +import { Flex, Text, TextField, Checkbox } from '@radix-ui/themes' +import { useEffect, useState } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { UpstreamProxySettings } from './UpstreamProxySettings' +import { SettingsSection } from './SettingsSection' +import { ControlledRadioGroup } from '@/components/Form/ControllerRadioGroup' +import { AppSettings } from '@/types/settings' + +const modeOptions = [ + { + value: 'regular', + label: 'Regular (requests are performed from this computer)', + }, + { + value: 'upstream', + label: 'Upstream (requests are forwarded to an upstream server)', + }, +] + +export const ProxySettings = () => { + const { + formState: { errors }, + control, + register, + watch, + } = useFormContext() + const [proxyStatus, setProxyStatus] = useState() + + const { proxy } = watch() + + useEffect(() => { + async function fetchProxyStatus() { + const status = await window.studio.proxy.getProxyStatus() + setProxyStatus(status) + } + fetchProxyStatus() + + return window.studio.proxy.onProxyStatusChange((status) => + setProxyStatus(status) + ) + }, []) + + return ( + + + + + + + ( + + {' '} + Allow k6 Studio to find an available port if this port is in use + + )} + /> + + + + + + + {proxy && proxy.mode === 'upstream' && } + + + + Proxy status: + + + + ) +} + +function ProxyStatusIndicator({ status }: { status?: ProxyStatus }) { + const statusColorMap: Record = { + ['online']: 'var(--green-9)', + ['offline']: 'var(--gray-9)', + ['restarting']: 'var(--blue-9)', + } + const backgroundColor = status ? statusColorMap[status] : '#fff' + + return ( + + {status} + + ) +} diff --git a/src/components/Settings/RecorderSettings.tsx b/src/components/Settings/RecorderSettings.tsx new file mode 100644 index 00000000..47a50331 --- /dev/null +++ b/src/components/Settings/RecorderSettings.tsx @@ -0,0 +1,74 @@ +import { FieldGroup } from '@/components/Form' +import { AppSettings } from '@/types/settings' +import { Flex, Text, TextField, Checkbox, Button } from '@radix-ui/themes' +import { Controller, useFormContext } from 'react-hook-form' +import { SettingsSection } from './SettingsSection' + +export const RecorderSettings = () => { + const { + formState: { errors }, + control, + register, + watch, + setValue, + clearErrors, + } = useFormContext() + + const { recorder } = watch() + + const handleSelectFile = async () => { + const result = await window.studio.settings.selectBrowserExecutable() + const { canceled, filePaths } = result + if (canceled || !filePaths.length) return + setValue('recorder.browserPath', filePaths[0], { shouldDirty: true }) + clearErrors('recorder.browserPath') + } + + return ( + + + ( + + {' '} + Automatically detect browser + + )} + /> + + + {recorder && !recorder.detectBrowserPath && ( + + + + + + + + )} + + ) +} diff --git a/src/components/Settings/SettingsDialog.tsx b/src/components/Settings/SettingsDialog.tsx new file mode 100644 index 00000000..ffe8cdf6 --- /dev/null +++ b/src/components/Settings/SettingsDialog.tsx @@ -0,0 +1,94 @@ +import { Box, Button, Dialog, Flex, ScrollArea } from '@radix-ui/themes' +import { ProxySettings } from './ProxySettings' +import { FormProvider, useForm } from 'react-hook-form' +import { AppSettingsSchema } from '@/schemas/appSettings' +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useState } from 'react' +import { ButtonWithTooltip } from '@/components/ButtonWithTooltip' +import { RecorderSettings } from './RecorderSettings' +import { AppSettings } from '@/types/settings' + +type SettingsDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void +} + +export const SettingsDialog = ({ open, onOpenChange }: SettingsDialogProps) => { + const [settings, setSettings] = useState() + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + async function fetchSettings() { + const data = await window.studio.settings.getSettings() + setSettings(data) + } + fetchSettings() + }, []) + + const formMethods = useForm({ + resolver: zodResolver(AppSettingsSchema), + shouldFocusError: false, + values: settings, + }) + + const { + formState: { isDirty }, + handleSubmit, + reset, + } = formMethods + + const onSubmit = async (data: AppSettings) => { + try { + setSubmitting(true) + const isSuccess = await window.studio.settings.saveSettings(data) + isSuccess && reset(data) + onOpenChange(false) + } catch (error) { + console.error('Error saving settings', error) + } finally { + setSubmitting(false) + } + } + + const handleCancelClick = () => { + reset(settings) + } + + return ( + + + Settings + + + + + + + + + + + + + + + Save changes + + + + + + + ) +} diff --git a/src/components/Settings/SettingsSection.tsx b/src/components/Settings/SettingsSection.tsx new file mode 100644 index 00000000..7af0ad6f --- /dev/null +++ b/src/components/Settings/SettingsSection.tsx @@ -0,0 +1,27 @@ +import { css } from '@emotion/react' +import { Flex, Heading, Box } from '@radix-ui/themes' + +type SettingsSectionProps = { + title: string + children: React.ReactNode +} + +export function SettingsSection({ title, children }: SettingsSectionProps) { + return ( + + + {title} + + + {children} + + ) +} diff --git a/src/components/Settings/UpstreamProxySettings.tsx b/src/components/Settings/UpstreamProxySettings.tsx new file mode 100644 index 00000000..6739d2c3 --- /dev/null +++ b/src/components/Settings/UpstreamProxySettings.tsx @@ -0,0 +1,81 @@ +import { FieldGroup } from '@/components/Form' +import { AppSettings } from '@/types/settings' +import { TextField, Flex, Checkbox, Text } from '@radix-ui/themes' +import { Controller, useFormContext } from 'react-hook-form' + +export function UpstreamProxySettings() { + const { + watch, + control, + register, + formState: { errors }, + } = useFormContext() + + const { proxy } = watch() + + return ( + <> + + + + + + ( + + {' '} + Require authentication + + )} + /> + + + {proxy && proxy.mode === 'upstream' && proxy.requiresAuth && ( + <> + + + + + + + + + )} + + ) +} diff --git a/src/main.ts b/src/main.ts index f81f2a7d..edf155c2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,6 +45,9 @@ import kill from 'tree-kill' import find from 'find-process' import { initializeLogger, openLogFolder } from './logger' import log from 'electron-log/main' +import { AppSettings } from './types/settings' +import { getSettings, saveSettings, selectBrowserExecutable } from './settings' +import { ProxyStatus } from './types' // handle auto updates if (process.env.NODE_ENV !== 'development') { @@ -56,8 +59,8 @@ const proxyEmitter = new eventEmitter() // Used mainly to avoid starting a new proxy when closing the active one on shutdown let appShuttingDown: boolean = false let currentProxyProcess: ProxyProcess | null -let proxyReady = false -export let proxyPort = 6000 +let proxyStatus: ProxyStatus = 'offline' +export let appSettings: AppSettings let currentBrowserProcess: Process | null let currentk6Process: K6Process | null @@ -152,11 +155,19 @@ const createWindow = async () => { } mainWindow.once('ready-to-show', () => configureWatcher(mainWindow)) + proxyEmitter.on('status:change', (statusName: ProxyStatus) => { + proxyStatus = statusName + mainWindow.webContents.send('proxy:status:change', statusName) + }) + mainWindow.on('closed', () => + proxyEmitter.removeAllListeners('status:change') + ) return mainWindow } app.whenReady().then(async () => { + appSettings = await getSettings() await createSplashWindow() await setupProjectStructure() await createWindow() @@ -188,11 +199,11 @@ app.on('before-quit', async () => { }) // Proxy -ipcMain.handle('proxy:start', async (event, port?: number) => { +ipcMain.handle('proxy:start', async (event) => { console.info('proxy:start event received') const browserWindow = browserWindowFromEvent(event) - currentProxyProcess = await launchProxyAndAttachEmitter(browserWindow, port) + currentProxyProcess = await launchProxyAndAttachEmitter(browserWindow) }) ipcMain.on('proxy:stop', async () => { @@ -201,7 +212,7 @@ ipcMain.on('proxy:stop', async () => { }) const waitForProxy = async (): Promise => { - if (proxyReady) { + if (proxyStatus === 'online') { return Promise.resolve() } @@ -292,7 +303,7 @@ ipcMain.handle( currentk6Process = await runScript( browserWindow, resolvedScriptPath, - proxyPort + appSettings.proxy.port ) } ) @@ -506,6 +517,58 @@ ipcMain.handle('app:open-log', () => { openLogFolder() }) +ipcMain.handle('settings:get', async () => { + console.info('settings:get event received') + return await getSettings() +}) + +ipcMain.handle('settings:save', async (event, data: AppSettings) => { + console.info('settings:save event received') + + const browserWindow = browserWindowFromEvent(event) + try { + const modifiedSettings = await saveSettings(data) + applySettings(modifiedSettings, browserWindow) + + sendToast(browserWindow.webContents, { + title: 'Settings saved successfully', + status: 'success', + }) + return true + } catch (error) { + log.error(error) + sendToast(browserWindow.webContents, { + title: 'Failed to save settings', + status: 'error', + }) + return false + } +}) + +ipcMain.handle('settings:select-browser-executable', async () => { + return selectBrowserExecutable() +}) + +ipcMain.handle('proxy:status:get', async () => { + console.info('proxy:status:get event received') + return proxyStatus +}) + +async function applySettings( + modifiedSettings: Partial, + browserWindow: BrowserWindow +) { + if (modifiedSettings.proxy) { + stopProxyProcess() + appSettings.proxy = modifiedSettings.proxy + proxyEmitter.emit('status:change', 'restarting') + currentProxyProcess = await launchProxyAndAttachEmitter(browserWindow) + } + if (modifiedSettings.recorder) { + appSettings.recorder = modifiedSettings.recorder + } +} + const browserWindowFromEvent = ( event: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent ) => { @@ -518,29 +581,25 @@ const browserWindowFromEvent = ( return browserWindow } -const launchProxyAndAttachEmitter = async ( - browserWindow: BrowserWindow, - port: number = proxyPort -) => { - // confirm that the port is still open and if not get the next open one - const availableOpenport = await findOpenPort(port) - console.log(`proxy open port found: ${availableOpenport}`) +const launchProxyAndAttachEmitter = async (browserWindow: BrowserWindow) => { + const { port, automaticallyFindPort } = appSettings.proxy - if (availableOpenport !== proxyPort) { - proxyPort = availableOpenport - } + const proxyPort = automaticallyFindPort ? await findOpenPort(port) : port + appSettings.proxy.port = proxyPort + + console.log(`launching proxy ${JSON.stringify(appSettings.proxy)}`) - return launchProxy(browserWindow, proxyPort, { + return launchProxy(browserWindow, appSettings.proxy, { onReady: () => { - proxyReady = true + proxyEmitter.emit('status:change', 'online') proxyEmitter.emit('ready') }, onFailure: async () => { - if (appShuttingDown) { - // we don't have to restart the proxy if the app is shutting down + if (appShuttingDown || proxyStatus === 'restarting') { + // don't restart the proxy if the app is shutting down or if it's already restarting return } - proxyReady = false + proxyEmitter.emit('status:change', 'restarting') currentProxyProcess = await launchProxyAndAttachEmitter(browserWindow) sendToast(browserWindow.webContents, { @@ -593,7 +652,6 @@ const stopProxyProcess = () => { // NOTE: this might not kill the second spawned process on windows currentProxyProcess.kill() currentProxyProcess = null - proxyReady = false } } diff --git a/src/preload.ts b/src/preload.ts index 1702deda..cadb86ee 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -1,8 +1,9 @@ import { ipcRenderer, contextBridge, IpcRendererEvent } from 'electron' -import { ProxyData, K6Log, FolderContent, K6Check } from './types' +import { ProxyData, K6Log, FolderContent, K6Check, ProxyStatus } from './types' import { HarFile } from './types/har' import { GeneratorFile } from './types/generator' import { AddToastPayload } from './types/toast' +import { AppSettings } from './types/settings' // Create listener and return clean up function to be used in useEffect function createListener(channel: string, callback: (data: T) => void) { @@ -27,6 +28,12 @@ const proxy = { onProxyData: (callback: (data: ProxyData) => void) => { return createListener('proxy:data', callback) }, + getProxyStatus: () => { + return ipcRenderer.invoke('proxy:status:get') + }, + onProxyStatusChange: (callback: (status: ProxyStatus) => void) => { + return createListener('proxy:status:change', callback) + }, } as const const browser = { @@ -147,6 +154,18 @@ const app = { }, } as const +const settings = { + getSettings: () => { + return ipcRenderer.invoke('settings:get') + }, + saveSettings: (settings: AppSettings): Promise => { + return ipcRenderer.invoke('settings:save', settings) + }, + selectBrowserExecutable: (): Promise => { + return ipcRenderer.invoke('settings:select-browser-executable') + }, +} + const studio = { proxy, browser, @@ -155,6 +174,7 @@ const studio = { ui, generator, app, + settings, } as const contextBridge.exposeInMainWorld('studio', studio) diff --git a/src/proxy.ts b/src/proxy.ts index fa98ea76..1c8d068a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,6 +8,7 @@ import { ProxyData } from './types' import readline from 'readline/promises' import { safeJsonParse } from './utils/json' import log from 'electron-log/main' +import { ProxySettings } from './types/settings' export type ProxyProcess = ChildProcessWithoutNullStreams @@ -18,7 +19,7 @@ interface options { export const launchProxy = ( browserWindow: BrowserWindow, - port?: number, + proxySettings: ProxySettings, { onReady, onFailure }: options = {} ): ProxyProcess => { let proxyScript: string @@ -50,10 +51,17 @@ export const launchProxy = ( proxyScript, '--set', `confdir=${certificatesPath}`, + '--listen-port', + `${proxySettings.port}`, '--mode', - `regular@${port}`, + getProxyMode(proxySettings), ] + if (proxySettings.mode === 'upstream' && proxySettings.requiresAuth) { + const { username, password } = proxySettings + proxyArgs.push('--upstream-auth', `${username}:${password}`) + } + const proxy = spawn(proxyPath, proxyArgs) // we use a reader to read entire lines from stdout instead of buffered data @@ -94,6 +102,14 @@ export const launchProxy = ( return proxy } +const getProxyMode = (proxySettings: ProxySettings) => { + if (proxySettings.mode === 'upstream') { + return `upstream:${proxySettings.url}` + } + + return 'regular' +} + export const getCertificatesPath = () => { if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { return path.join(app.getAppPath(), 'resources', 'certificates') diff --git a/src/schemas/appSettings.ts b/src/schemas/appSettings.ts new file mode 100644 index 00000000..9eb24f75 --- /dev/null +++ b/src/schemas/appSettings.ts @@ -0,0 +1,88 @@ +import { z } from 'zod' + +export const RegularProxySettingsSchema = z.object({ + mode: z.literal('regular'), + port: z + .number({ message: 'Port number is required' }) + .int() + .min(1) + .max(65535, { message: 'Port number must be between 1 and 65535' }), + automaticallyFindPort: z.boolean(), +}) + +export const UpstreamProxySettingsSchema = RegularProxySettingsSchema.extend({ + mode: z.literal('upstream'), + url: z.string().url({ message: 'Invalid URL' }).or(z.literal('')), + requiresAuth: z.boolean(), + username: z.string().optional(), + password: z.string().optional(), +}) + +export const ProxySettingsSchema = z + .discriminatedUnion('mode', [ + RegularProxySettingsSchema, + UpstreamProxySettingsSchema, + ]) + .superRefine((data, ctx) => { + if (data.mode === 'upstream') { + const { url, requiresAuth, username, password } = data + + // url is required when mode is 'upstream' + if (!url) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Upstream server is required', + path: ['url'], + }) + } + + // username is required when requiresAuth is true + if (requiresAuth && !username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Username is required', + path: ['username'], + }) + } + + // password is required when requiresAuth is true + if (requiresAuth && !password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Password is required', + path: ['password'], + }) + } + } + }) + +const RecorderDetectBrowserPathSchema = z.object({ + detectBrowserPath: z.literal(true), +}) + +const RecorderBrowserPathSchema = RecorderDetectBrowserPathSchema.extend({ + detectBrowserPath: z.literal(false), + browserPath: z.string().optional(), +}) + +export const RecorderSettingsSchema = z + .discriminatedUnion('detectBrowserPath', [ + RecorderDetectBrowserPathSchema, + RecorderBrowserPathSchema, + ]) + .superRefine((data, ctx) => { + // browserPath is required when detectBrowserPath is false + if (!data.detectBrowserPath && !data.browserPath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Browser path is required', + path: ['browserPath'], + }) + } + }) + +export const AppSettingsSchema = z.object({ + version: z.string(), + proxy: ProxySettingsSchema, + recorder: RecorderSettingsSchema, +}) diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 00000000..d85a23f5 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,93 @@ +import { app, dialog } from 'electron' +import { writeFile, open } from 'fs/promises' +import path from 'node:path' +import { AppSettings } from './types/settings' + +const defaultSettings: AppSettings = { + version: '1.0', + proxy: { + mode: 'regular', + port: 6000, + automaticallyFindPort: true, + }, + recorder: { + detectBrowserPath: true, + }, +} + +const fileName = + process.env.NODE_ENV === 'development' + ? 'k6-studio-dev.json' + : 'k6-studio.json' +const filePath = path.join(app.getPath('userData'), fileName) + +/** + * Initializes the settings file if it doesn't exist. + */ +async function initSettings() { + try { + const fileHandle = await open(filePath, 'r') + await fileHandle.close() + } catch { + await writeFile(filePath, JSON.stringify(defaultSettings)) + } +} + +/** + * Retrieve the current settings from the settings file + * @returns The current settings as JSON + */ +export async function getSettings() { + await initSettings() + const fileHandle = await open(filePath, 'r') + try { + const settings = await fileHandle?.readFile({ encoding: 'utf-8' }) + return JSON.parse(settings) as AppSettings + } finally { + await fileHandle?.close() + } +} + +/** + * Write the new settings to the settings file + * @param newSettings + * @returns The settings that have changed + */ +export async function saveSettings(newSettings: AppSettings) { + console.log(newSettings) + const currentSettings = await getSettings() + const diff = getSettingsDiff(currentSettings, newSettings) + await writeFile(filePath, JSON.stringify(newSettings)) + return diff +} + +/** + * Compares old and new settings + * @param oldSettings + * @param newSettings + * @returns the difference between the old and new settings + */ +function getSettingsDiff(oldSettings: AppSettings, newSettings: AppSettings) { + const diff: Record = {} + + for (const key in newSettings) { + const typedKey = key as keyof AppSettings + const oldJSON = JSON.stringify(oldSettings[typedKey]) + const newJSON = JSON.stringify(newSettings[typedKey]) + + if (oldJSON !== newJSON) { + diff[typedKey] = newSettings[typedKey] + } + } + + return diff +} + +export async function selectBrowserExecutable() { + const extensions = process.platform === 'darwin' ? ['app'] : ['exe'] + return dialog.showOpenDialog({ + title: 'Select browser executable', + properties: ['openFile'], + filters: [{ name: 'Executables', extensions }], + }) +} diff --git a/src/types/index.ts b/src/types/index.ts index 8a0dbe77..49fb4bca 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -91,3 +91,5 @@ export interface FolderContent { generators: string[] scripts: string[] } + +export type ProxyStatus = 'online' | 'offline' | 'restarting' diff --git a/src/types/settings.ts b/src/types/settings.ts new file mode 100644 index 00000000..40dcfaf0 --- /dev/null +++ b/src/types/settings.ts @@ -0,0 +1,5 @@ +import { AppSettingsSchema, ProxySettingsSchema } from '@/schemas/appSettings' +import { z } from 'zod' + +export type AppSettings = z.infer +export type ProxySettings = z.infer diff --git a/src/utils/form.ts b/src/utils/form.ts index ae95585e..834b99bc 100644 --- a/src/utils/form.ts +++ b/src/utils/form.ts @@ -5,3 +5,7 @@ export function stringAsNullableNumber(value: string) { export function stringAsOptionalNumber(value: string) { return value !== '' ? parseFloat(value) : undefined } + +export function stringAsNumber(value: string) { + return parseFloat(value) +}