package ui import ( "strconv" "strings" "gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/domain" "fyne.io/fyne/v2" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) // buildSettingsForm constructs every Settings tab widget and wires save, load, // and cancel handlers. settingsView delegates here so the constructor file stays // focused on the thin entry point and theme label helpers. func buildSettingsForm(w fyne.Window, svc *app.Service, tray *trayState) settingsFormFields { // saved mirrors the config as last persisted (or freshly loaded at // construction); it is a local copy the closures below compare the form // against and reassign after a successful save, rather than holding onto // the live *storage.Store the Service owns (see app.Service.Config). // paths never changes after construction of this view — AppDir and // ConfigPath are fixed for the process — so it is read once, not refreshed. saved := svc.Config() paths := svc.Paths() // updateSaveState compares the form to the saved config and enables Save only // when something differs. It is defined below (once Save and every field // exist) but declared here so the field change handlers can reference it. var updateSaveState func() // loadFields populates every form control from the given config. It backs // both the initial load and the Cancel/Defaults buttons below. var loadFields func(domain.Config) startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin.SetChecked(saved.StartOnLogin) minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) minimizeToTray.SetChecked(saved.KeepRunningInTray) autostartStatus := widget.NewLabel("") trayRestartHint := widget.NewLabel("") trayRestartHint.Truncation = fyne.TextTruncateClip // autostartCheckGen guards against an in-flight check's result landing after // a newer one started (e.g. the user toggles a checkbox again before the // first check's PowerShell call returns). Both the increment and the compare // happen on the main/Fyne thread, so this needs no lock of its own. var autostartCheckGen int refreshAutostartStatus := func() { if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) { autostartStatus.SetText("Pending: save settings to apply") return } // svc.AutostartStatus() reaches readShortcut on Windows, which spawns // powershell.exe and blocks on CombinedOutput() — hundreds of milliseconds // of cold start. Running it off the main thread keeps that from freezing // the window on construction and on every checkbox toggle. autostartStatus.SetText("Checking...") autostartCheckGen++ gen := autostartCheckGen go func() { ok, message := svc.AutostartStatus() fyne.Do(func() { if gen != autostartCheckGen { return } if ok { autostartStatus.SetText("OK: " + message) return } autostartStatus.SetText("Problem: " + message) }) }() } refreshTrayRestartHint := func(pending bool) { if pending { trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.") return } trayRestartHint.SetText("") } startOnLogin.OnChanged = func(bool) { refreshAutostartStatus() updateSaveState() } minimizeToTray.OnChanged = func(bool) { refreshAutostartStatus() refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray) updateSaveState() } refreshAutostartStatus() notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) notifications.SetChecked(saved.NotifyOnFailure) notifications.OnChanged = func(bool) { updateSaveState() } themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil) themeSelect.SetSelected(themeLabel(saved.Theme)) // Preview the theme the moment it is picked so the choice is visible before // saving; Save persists it. Reverting the selection reverts the preview, and // closing without saving falls back to the stored theme on next launch. themeSelect.OnChanged = func(string) { applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected)) updateSaveState() } executionModeSelect := widget.NewSelect( []string{string(domain.ExecutionModeParallel), string(domain.ExecutionModeSequential)}, nil, ) executionModeSelect.SetSelected(string(saved.ExecutionMode)) executionModeSelect.OnChanged = func(string) { updateSaveState() } overlapPolicySelect := widget.NewSelect( []string{string(domain.OverlapPolicySkip), string(domain.OverlapPolicyQueue)}, nil, ) overlapPolicySelect.SetSelected(string(saved.OverlapPolicy)) overlapPolicySelect.OnChanged = func(string) { updateSaveState() } defaultTimeout := widget.NewEntry() defaultTimeout.SetPlaceHolder("0 = no timeout") defaultTimeout.SetText(strconv.Itoa(saved.DefaultTimeoutSeconds)) defaultTimeout.OnChanged = func(string) { updateSaveState() } jobsFile := widget.NewEntry() jobsFile.SetText(saved.JobsFile) jobsFile.OnChanged = func(string) { updateSaveState() } // The picker only offers existing files; a jobs file that does not exist yet // is entered by typing its path, which Save then creates. jobsFileBrowse := widget.NewButtonWithIcon("Browse", theme.FileIcon(), func() { chooseJSONFile(w, jobsFile) }) logsDir := widget.NewEntry() logsDir.SetText(saved.LogsDir) logsDir.OnChanged = func(string) { updateSaveState() } logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { chooseFolder(w, logsDir) }) // Log files are read outside the app, so the folder gets a direct shortcut // beside its path instead of making the user copy the path into a file // manager. It reveals whatever the field currently holds, so an edit can be // checked before Save. logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() { openFolder(w, settingsFolderPath(paths.AppDir, logsDir.Text)) }) maxLogFiles := widget.NewEntry() maxLogFiles.SetPlaceHolder("0 = unlimited") maxLogFiles.SetText(strconv.Itoa(saved.MaxLogFiles)) maxLogFiles.OnChanged = func(string) { updateSaveState() } maxLogAgeDays := widget.NewEntry() maxLogAgeDays.SetPlaceHolder("0 = unlimited") maxLogAgeDays.SetText(strconv.Itoa(saved.MaxLogAgeDays)) maxLogAgeDays.OnChanged = func(string) { updateSaveState() } // Autostart status sits on its own row beneath the checkbox (rather than // beside it) so the Application section fits within a half-width column. // Truncating keeps a long status message from forcing the column wider. autostartStatus.Truncation = fyne.TextTruncateClip settingsStatus := widget.NewLabel("") saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { // Only the parse itself happens here: a numeric field has to become an int // before it can go into a domain.Config at all. Everything else — required // fields, negative numbers, valid enum values — is Service.UpdateSettings' // job (see app.validateConfig), so its error is what the user sees rather // than a second copy of the same rules with different wording. files, filesErr := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text)) days, daysErr := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text)) timeout, timeoutErr := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text)) if filesErr != nil || daysErr != nil || timeoutErr != nil { settingsStatus.SetText("Max log files, max log age days, and default timeout must be numbers") return } // Build the new config from the form and hand it to the Service, which // validates it, persists config and jobs to the (possibly new) directory, // and runs log cleanup so tightened retention limits take effect at once. config := saved config.JobsFile = strings.TrimSpace(jobsFile.Text) config.LogsDir = strings.TrimSpace(logsDir.Text) config.MaxLogFiles = files config.MaxLogAgeDays = days config.StartOnLogin = startOnLogin.Checked config.KeepRunningInTray = minimizeToTray.Checked config.NotifyOnFailure = notifications.Checked config.ExecutionMode = domain.ExecutionMode(executionModeSelect.Selected) config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected) config.DefaultTimeoutSeconds = timeout config.Theme = themeFromLabel(themeSelect.Selected) previousKeepInTray := saved.KeepRunningInTray if err := svc.UpdateSettings(config); err != nil { settingsStatus.SetText("Save failed: " + err.Error()) return } // UpdateSettings may re-resolve paths (a jobs-file switch adopts a // different directory), so pick up the fresh copy rather than assuming // config is exactly what landed. saved = svc.Config() paths = svc.Paths() if err := svc.ApplyAutostart(); err != nil { refreshAutostartStatus() settingsStatus.SetText("Saved, autostart failed: " + err.Error()) return } refreshAutostartStatus() tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true) if previousKeepInTray != config.KeepRunningInTray { trayRestartHint.SetText(trayRestartHintText) } else { refreshTrayRestartHint(false) } settingsStatus.SetText("Saved") // The form now matches the persisted config, so disable Save again. updateSaveState() }) // Save stays disabled until a field differs from the saved config, so the // button only invites a click when there is something to persist. The numeric // fields compare against their canonical string form; any unparsable text // counts as a change so the user can click Save and see the validation error. updateSaveState = func() { c := saved changed := startOnLogin.Checked != c.StartOnLogin || minimizeToTray.Checked != c.KeepRunningInTray || notifications.Checked != c.NotifyOnFailure || executionModeSelect.Selected != string(c.ExecutionMode) || overlapPolicySelect.Selected != string(c.OverlapPolicy) || strings.TrimSpace(defaultTimeout.Text) != strconv.Itoa(c.DefaultTimeoutSeconds) || strings.TrimSpace(jobsFile.Text) != c.JobsFile || strings.TrimSpace(logsDir.Text) != c.LogsDir || strings.TrimSpace(maxLogFiles.Text) != strconv.Itoa(c.MaxLogFiles) || strings.TrimSpace(maxLogAgeDays.Text) != strconv.Itoa(c.MaxLogAgeDays) || themeSelect.Selected != themeLabel(c.Theme) if changed { saveSettings.Enable() } else { saveSettings.Disable() } } updateSaveState() // loadFields populates every form control from a config without saving it, // backing both the Cancel button (reload the saved config, discarding edits) // and the Defaults button (load the built-in defaults for review before // Save is clicked). loadFields = func(c domain.Config) { startOnLogin.SetChecked(c.StartOnLogin) minimizeToTray.SetChecked(c.KeepRunningInTray) notifications.SetChecked(c.NotifyOnFailure) themeSelect.SetSelected(themeLabel(c.Theme)) applyTheme(fyne.CurrentApp(), themeFromLabel(themeSelect.Selected)) executionModeSelect.SetSelected(string(c.ExecutionMode)) overlapPolicySelect.SetSelected(string(c.OverlapPolicy)) defaultTimeout.SetText(strconv.Itoa(c.DefaultTimeoutSeconds)) jobsFile.SetText(c.JobsFile) logsDir.SetText(c.LogsDir) maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles)) maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays)) if settingsPendingAutostart(startOnLogin, minimizeToTray, saved) { autostartStatus.SetText("Pending: save settings to apply") } else { refreshAutostartStatus() } refreshTrayRestartHint(minimizeToTray.Checked != saved.KeepRunningInTray) settingsStatus.SetText("") updateSaveState() } cancelSettings := widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() { loadFields(saved) }) restoreDefaults := widget.NewButtonWithIcon("Defaults", theme.MediaReplayIcon(), func() { loadFields(domain.DefaultConfig()) }) return settingsFormFields{ startOnLogin: startOnLogin, autostartStatus: autostartStatus, minimizeToTray: minimizeToTray, trayRestartHint: trayRestartHint, notifications: notifications, themeSelect: themeSelect, executionModeSelect: executionModeSelect, overlapPolicySelect: overlapPolicySelect, defaultTimeout: defaultTimeout, configPath: paths.ConfigPath, jobsFile: jobsFile, jobsFileBrowse: jobsFileBrowse, logsDir: logsDir, logsDirOpen: logsDirOpen, logsDirBrowse: logsDirBrowse, maxLogFiles: maxLogFiles, maxLogAgeDays: maxLogAgeDays, saveSettings: saveSettings, cancelSettings: cancelSettings, restoreDefaults: restoreDefaults, settingsStatus: settingsStatus, } }