summaryrefslogtreecommitdiff
path: root/internal/ui/settings.go
diff options
context:
space:
mode:
authorspiperac <[email protected]>2026-06-15 06:42:13 +0200
committerspiperac <[email protected]>2026-06-15 06:42:13 +0200
commit0a477fd288d064d265395c88dbd0f261b723804e (patch)
tree9b129f8f41c907285b89ed8385679934bb59c14f /internal/ui/settings.go
Initial release
Diffstat (limited to 'internal/ui/settings.go')
-rw-r--r--internal/ui/settings.go401
1 files changed, 401 insertions, 0 deletions
diff --git a/internal/ui/settings.go b/internal/ui/settings.go
new file mode 100644
index 0000000..1565066
--- /dev/null
+++ b/internal/ui/settings.go
@@ -0,0 +1,401 @@
+package ui
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/dialog"
+ "fyne.io/fyne/v2/storage"
+ "fyne.io/fyne/v2/widget"
+
+ "github.com/shiv/internal/cert"
+ "github.com/shiv/internal/events"
+ "github.com/shiv/internal/logger"
+)
+
+var settingsWin fyne.Window
+
+func showSettingsDialog(fyneApp fyne.App, parentWin fyne.Window, bus *events.Bus, keybinds *Keybinds) {
+ if settingsWin != nil {
+ settingsWin.RequestFocus()
+ return
+ }
+
+ tabs := container.NewAppTabs(
+ container.NewTabItem("Certificate", buildCertificateTab(parentWin)),
+ container.NewTabItem("Proxy", buildProxyTab(fyneApp, bus)),
+ container.NewTabItem("Browser", buildBrowserTab(fyneApp, parentWin)),
+ container.NewTabItem("Appearance & Shortcuts", buildAppearanceTab(fyneApp, keybinds)),
+ )
+ tabs.SetTabLocation(container.TabLocationTop)
+
+ settingsWin = fyneApp.NewWindow("Settings")
+ settingsWin.SetContent(container.NewPadded(tabs))
+ settingsWin.Resize(fyne.NewSize(480, 420))
+ settingsWin.SetOnClosed(func() { settingsWin = nil })
+ closeOnEscape(settingsWin, settingsWin.Close)
+ settingsWin.Show()
+}
+
+func buildCertificateTab(parentWin fyne.Window) fyne.CanvasObject {
+ statusLabel := widget.NewLabel("")
+ statusLabel.Wrapping = fyne.TextWrapBreak
+
+ installBtn := widget.NewButton("Install CA Certificate", func() {
+ certAuthority, err := cert.Load()
+ if err != nil {
+ statusLabel.SetText("Error loading CA: " + err.Error())
+ logger.Error("settings: load CA: %v", err)
+ return
+ }
+ msg, err := certAuthority.InstallCA()
+ if err != nil {
+ statusLabel.SetText("Installation failed:\n" + err.Error())
+ logger.Error("settings: install CA: %v", err)
+ return
+ }
+ statusLabel.SetText(msg)
+ })
+ installBtn.Importance = widget.HighImportance
+
+ exportBtn := widget.NewButton("Export Certificate (PEM)", func() {
+ certAuthority, err := cert.Load()
+ if err != nil {
+ statusLabel.SetText("Error loading CA: " + err.Error())
+ logger.Error("settings: load CA for export: %v", err)
+ return
+ }
+ pemBytes, err := certAuthority.CertPEM()
+ if err != nil {
+ statusLabel.SetText("Error reading certificate: " + err.Error())
+ logger.Error("settings: cert PEM: %v", err)
+ return
+ }
+ saveDialog := dialog.NewFileSave(func(writeCloser fyne.URIWriteCloser, saveErr error) {
+ if saveErr != nil || writeCloser == nil {
+ return
+ }
+ defer writeCloser.Close()
+ if _, writeErr := writeCloser.Write(pemBytes); writeErr != nil {
+ statusLabel.SetText("Export failed: " + writeErr.Error())
+ logger.Error("settings: export cert: %v", writeErr)
+ return
+ }
+ statusLabel.SetText("Certificate exported successfully.")
+ }, parentWin)
+ saveDialog.SetFileName("shiv-ca.pem")
+ saveDialog.Show()
+ })
+
+ return container.NewVBox(
+ newBoldLabel("CA Certificate"),
+ widget.NewLabel("Install the Shiv CA into your system trust store so browsers accept intercepted traffic."),
+ installBtn,
+ widget.NewSeparator(),
+ widget.NewLabel("Export the CA certificate to import manually into a browser or device."),
+ exportBtn,
+ statusLabel,
+ )
+}
+
+func buildProxyTab(fyneApp fyne.App, bus *events.Bus) fyne.CanvasObject {
+ prefs := fyneApp.Preferences()
+
+ hostEntry := widget.NewEntry()
+ hostEntry.SetText(prefs.StringWithFallback(prefKeyProxyHost, defaultProxyHost))
+ hostEntry.SetPlaceHolder("127.0.0.1")
+
+ portEntry := widget.NewEntry()
+ portEntry.SetText(strconv.Itoa(prefs.IntWithFallback(prefKeyProxyPort, defaultProxyPort)))
+ portEntry.SetPlaceHolder("9090")
+
+ enabledCheck := widget.NewCheck("Proxy enabled", nil)
+ enabledCheck.Checked = prefs.BoolWithFallback(prefKeyProxyEnabled, defaultProxyEnabled)
+
+ proxyStatus := widget.NewLabel("")
+ proxyStatus.Wrapping = fyne.TextWrapBreak
+
+ saveBtn := widget.NewButton("Save & Restart Proxy", func() {
+ proxyHost := hostEntry.Text
+ proxyPort, err := strconv.Atoi(portEntry.Text)
+ if err != nil || proxyPort < 1 || proxyPort > 65535 {
+ proxyStatus.SetText("Invalid port number.")
+ return
+ }
+ prefs.SetString(prefKeyProxyHost, proxyHost)
+ prefs.SetInt(prefKeyProxyPort, proxyPort)
+ prefs.SetBool(prefKeyProxyEnabled, enabledCheck.Checked)
+
+ if enabledCheck.Checked {
+ addr := fmt.Sprintf("%s:%d", proxyHost, proxyPort)
+ bus.EmitProxyRestart(events.ProxyRestartEvent{Addr: addr})
+ proxyStatus.SetText(fmt.Sprintf("Proxy restarted on %s:%d", proxyHost, proxyPort))
+ proxyRunningBinding.Set(true)
+ proxyStatusBinding.Set(fmt.Sprintf("Proxy: running on %s:%d", proxyHost, proxyPort))
+ } else {
+ bus.EmitProxyStop(events.ProxyStopEvent{})
+ proxyStatus.SetText("Proxy stopped.")
+ proxyRunningBinding.Set(false)
+ proxyStatusBinding.Set("Proxy: stopped")
+ }
+ })
+ saveBtn.Importance = widget.HighImportance
+
+ return container.NewVBox(
+ newBoldLabel("Proxy"),
+ widget.NewForm(
+ widget.NewFormItem("Host", hostEntry),
+ widget.NewFormItem("Port", portEntry),
+ ),
+ enabledCheck,
+ saveBtn,
+ proxyStatus,
+ )
+}
+
+// loadThemeByName loads a theme by name, checking embedded themes first then user themes on disk.
+func loadThemeByName(name, themesDir string) (*LoadedTheme, error) {
+ for _, embedded := range ScanEmbeddedThemes() {
+ if embedded == name {
+ return LoadEmbeddedTheme(name)
+ }
+ }
+ return LoadTheme(filepath.Join(themesDir, name+".toml"))
+}
+
+func buildAppearanceTab(fyneApp fyne.App, keybinds *Keybinds) fyne.CanvasObject {
+ prefs := fyneApp.Preferences()
+ themesDir := ThemesDir(fyneApp.Storage().RootURI().Path())
+
+ themeStatus := widget.NewLabel("")
+ themeStatus.Importance = widget.LowImportance
+
+ themeSelect := widget.NewSelect(ScanAllThemes(themesDir), nil)
+ if saved := prefs.String(prefKeyUserTheme); saved != "" {
+ themeSelect.SetSelected(saved)
+ } else {
+ themeSelect.SetSelected(ThemeDefaultOption)
+ }
+
+ variantSelect := widget.NewSelect([]string{"Dark", "Light"}, func(selected string) {
+ prefs.SetBool(prefKeyDarkTheme, selected == "Dark")
+ applyTheme(fyneApp)
+ })
+ if prefs.BoolWithFallback(prefKeyDarkTheme, defaultDarkTheme) {
+ variantSelect.SetSelected("Dark")
+ } else {
+ variantSelect.SetSelected("Light")
+ }
+ if lt := loadActiveTheme(fyneApp); lt != nil && !lt.HasBoth {
+ variantSelect.Hide()
+ }
+
+ applyBtn := widget.NewButton("Apply Theme", func() {
+ selected := themeSelect.Selected
+ if selected != ThemeDefaultOption {
+ if _, err := loadThemeByName(selected, themesDir); err != nil {
+ themeStatus.SetText("Invalid theme: " + err.Error())
+ logger.Error("settings: load theme %s: %v", selected, err)
+ return
+ }
+ prefs.SetString(prefKeyUserTheme, selected)
+ } else {
+ prefs.SetString(prefKeyUserTheme, "")
+ }
+ applyTheme(fyneApp)
+ if lt := loadActiveTheme(fyneApp); lt != nil && !lt.HasBoth {
+ variantSelect.Hide()
+ } else {
+ variantSelect.Show()
+ }
+ themeStatus.SetText("Applied. Restart to update the dark/light toggle visibility.")
+ })
+
+ addThemeBtn := widget.NewButtonWithIcon("Add Theme", AppIcon("save"), func() {
+ d := dialog.NewFileOpen(func(rc fyne.URIReadCloser, err error) {
+ if err != nil || rc == nil {
+ return
+ }
+ defer rc.Close()
+ data, err := io.ReadAll(rc)
+ if err != nil {
+ themeStatus.SetText("Failed to read file: " + err.Error())
+ return
+ }
+ dest := filepath.Join(themesDir, filepath.Base(rc.URI().Path()))
+ if err := os.WriteFile(dest, data, 0644); err != nil {
+ themeStatus.SetText("Failed to save theme: " + err.Error())
+ return
+ }
+ themeStatus.SetText("Theme added: " + filepath.Base(dest))
+ themeSelect.Options = ScanAllThemes(themesDir)
+ themeSelect.Refresh()
+ }, settingsWin)
+ d.SetFilter(storage.NewExtensionFileFilter([]string{".toml"}))
+ d.Show()
+ })
+
+ themeDirLabel := widget.NewLabel("User themes: " + themesDir)
+ themeDirLabel.Wrapping = fyne.TextWrapBreak
+ themeDirLabel.Importance = widget.LowImportance
+
+ sendKeyEntry := widget.NewEntry()
+ sendKeyEntry.SetText(prefs.StringWithFallback(prefKeySendRequest, string(defaultKeySendRequest)))
+
+ closeTabKeyEntry := widget.NewEntry()
+ closeTabKeyEntry.SetText(prefs.StringWithFallback(prefKeyCloseTab, string(defaultKeyCloseTab)))
+
+ toRepeaterKeyEntry := widget.NewEntry()
+ toRepeaterKeyEntry.SetText(prefs.StringWithFallback(prefKeyToRepeater, string(defaultKeyToRepeater)))
+
+ for _, e := range []*widget.Entry{sendKeyEntry, closeTabKeyEntry, toRepeaterKeyEntry} {
+ entry := e
+ entry.OnChanged = func(text string) {
+ if len(text) > 0 {
+ entry.SetText(strings.ToUpper(string([]rune(text)[0])))
+ }
+ }
+ }
+
+ shortcutStatus := widget.NewLabel("")
+
+ saveShortcutsBtn := widget.NewButton("Save Shortcuts", func() {
+ keybinds.KeySendRequest = fyne.KeyName(strings.ToUpper(sendKeyEntry.Text))
+ keybinds.KeyCloseTab = fyne.KeyName(strings.ToUpper(closeTabKeyEntry.Text))
+ keybinds.KeyToRepeater = fyne.KeyName(strings.ToUpper(toRepeaterKeyEntry.Text))
+ prefs.SetString(prefKeySendRequest, strings.ToUpper(sendKeyEntry.Text))
+ prefs.SetString(prefKeyCloseTab, strings.ToUpper(closeTabKeyEntry.Text))
+ prefs.SetString(prefKeyToRepeater, strings.ToUpper(toRepeaterKeyEntry.Text))
+ keybinds.Update()
+ shortcutStatus.SetText("Shortcuts updated.")
+ })
+
+ return container.NewVBox(
+ newBoldLabel("Appearance"),
+ widget.NewForm(
+ widget.NewFormItem("Variant", variantSelect),
+ widget.NewFormItem("Theme", themeSelect),
+ ),
+ applyBtn,
+ addThemeBtn,
+ themeStatus,
+ themeDirLabel,
+ widget.NewSeparator(),
+ newBoldLabel("Shortcuts"),
+ widget.NewLabel("Single letter keys always, Ctrl modifier always applied (eg. Send is Ctrl + s)."),
+ widget.NewForm(
+ widget.NewFormItem("Send Request", sendKeyEntry),
+ widget.NewFormItem("Close Tab", closeTabKeyEntry),
+ widget.NewFormItem("Send to Repeater", toRepeaterKeyEntry),
+ ),
+ saveShortcutsBtn,
+ shortcutStatus,
+ )
+}
+
+func buildBrowserTab(fyneApp fyne.App, win fyne.Window) fyne.CanvasObject {
+ prefs := fyneApp.Preferences()
+ statusLabel := widget.NewLabel("")
+ statusLabel.Wrapping = fyne.TextWrapBreak
+
+ browsers := DetectBrowsers()
+ names := make([]string, len(browsers))
+ for i, b := range browsers {
+ names[i] = b.Name
+ }
+
+ var browserSelect *widget.Select
+ if len(browsers) == 0 {
+ browserSelect = widget.NewSelect([]string{"No browsers detected"}, nil)
+ browserSelect.SetSelected("No browsers detected")
+ browserSelect.Disable()
+ } else {
+ browserSelect = widget.NewSelect(names, nil)
+ savedPath := prefs.String(prefKeyDefaultBrowser)
+ for _, b := range browsers {
+ if b.Path == savedPath {
+ browserSelect.SetSelected(b.Name)
+ break
+ }
+ }
+ if browserSelect.Selected == "" {
+ browserSelect.SetSelected(names[0])
+ }
+ }
+
+ rescanBtn := widget.NewButton("Rescan", func() {
+ fresh := DetectBrowsers()
+ freshNames := make([]string, len(fresh))
+ for i, b := range fresh {
+ freshNames[i] = b.Name
+ }
+ browsers = fresh
+ names = freshNames
+ if len(freshNames) == 0 {
+ browserSelect.Options = []string{"No browsers detected"}
+ browserSelect.SetSelected("No browsers detected")
+ browserSelect.Disable()
+ statusLabel.SetText("No supported browsers found.")
+ } else {
+ browserSelect.Enable()
+ browserSelect.Options = freshNames
+ browserSelect.SetSelected(freshNames[0])
+ statusLabel.SetText(fmt.Sprintf("Found %d browser(s).", len(freshNames)))
+ }
+ browserSelect.Refresh()
+ })
+
+ saveBtn := widget.NewButton("Save Default", func() {
+ if len(browsers) == 0 {
+ return
+ }
+ for _, b := range browsers {
+ if b.Name == browserSelect.Selected {
+ prefs.SetString(prefKeyDefaultBrowser, b.Path)
+ statusLabel.SetText("Default browser saved: " + b.Name)
+ return
+ }
+ }
+ })
+ saveBtn.Importance = widget.HighImportance
+
+ clearProfileBtn := widget.NewButton("Clear Browser Profile", func() {
+ for _, b := range browsers {
+ if b.Name == browserSelect.Selected {
+ if err := ClearBrowserProfile(fyneApp, b.Path); err != nil {
+ statusLabel.SetText("Error: " + err.Error())
+ } else {
+ statusLabel.SetText("Profile cleared for: " + b.Name)
+ }
+ return
+ }
+ }
+ })
+
+ return container.NewVBox(
+ newBoldLabel("Browser"),
+ widget.NewLabel("Launch a browser pre-configured to route traffic through Shiv's proxy with an isolated profile."),
+ widget.NewForm(
+ widget.NewFormItem("Default browser", container.NewBorder(nil, nil, nil, rescanBtn, browserSelect)),
+ ),
+ container.NewHBox(saveBtn, clearProfileBtn),
+ statusLabel,
+ )
+}
+
+func newBrowserPickerContent(names []string, onChanged func(string)) fyne.CanvasObject {
+ sel := widget.NewSelect(names, onChanged)
+ if len(names) > 0 {
+ sel.SetSelected(names[0])
+ }
+ return container.NewVBox(
+ widget.NewLabel("Multiple browsers detected. Choose one to set as default:"),
+ sel,
+ )
+}