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)
+}