diff options
| author | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
|---|---|---|
| committer | spiperac <[email protected]> | 2026-06-15 06:42:13 +0200 |
| commit | 0a477fd288d064d265395c88dbd0f261b723804e (patch) | |
| tree | 9b129f8f41c907285b89ed8385679934bb59c14f /internal/ui/settings.go | |
Initial release
Diffstat (limited to 'internal/ui/settings.go')
| -rw-r--r-- | internal/ui/settings.go | 401 |
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, + ) +} |
