package ui import ( "net/url" "runtime" "runtime/debug" "strconv" "strings" "time" "gitea.mixdep.ru/mix/gosentry/assets" "gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/domain" "gitea.mixdep.ru/mix/gosentry/src/platform/autostart" "gitea.mixdep.ru/mix/gosentry/src/platform/desktop" "gitea.mixdep.ru/mix/gosentry/src/scheduler" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" ) const settingsLabelWidth float32 = 140 const settingsControlWidth float32 = 330 const settingsStatusWidth float32 = 280 const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" // The UI package aliases domain types to keep widget callbacks short. The actual // durable model still lives in src/domain, so UI code does not define a second // copy of the scheduler data. type job = domain.Job type event = domain.RunRecord func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { svc, err := app.Open() if err != nil { return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {} } store := svc.Store() if iconPath, err := desktop.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil { store.Paths.DesktopIcon = iconPath } // Build the initial event history from the current runtime state. Jobs and // runtimes are read here only for this one-time initialization; the jobs view // owns all subsequent state via its own syncFromService closure. initialJobs := svc.Jobs() initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs)) for _, j := range initialJobs { if rt := svc.Runtime(j.ID); rt != nil { initialRuntimes[j.ID] = rt } } events := collectActivity(initialJobs, initialRuntimes) jobsPanel, refreshJobsView := newJobsView(w, svc) history := newHistoryView(&events) recordStartup := func(duration time.Duration, windowShown bool) { // Startup is recorded as an in-memory History event instead of being // persisted into jobs.yaml. It is session diagnostics, not durable job // state, and keeping it ephemeral avoids polluting the human-editable YAML // file with process-lifetime bookkeeping. detail := "Window shown in " + duration.Round(time.Millisecond).String() if !windowShown { detail = "Started in tray in " + duration.Round(time.Millisecond).String() } events = append(events, newEvent(0, "Application", "Started", detail)) history.Refresh() } refresh := func() { refreshJobsView() history.Refresh() } // The Service announces every change through events. This single listener is // where the UI reacts: it appends run/activity records to History and redraws. // Events fire from two contexts — UI button handlers call into the Service // synchronously (main goroutine), while scheduled and manual run completions // emit from the run goroutine. fyne.Do marshals all of this widget work onto // the main thread in both cases, so the engine never mutates Fyne state off // the UI thread. This is the sole place events touch widgets. (Resolves #4.) svc.Subscribe(app.ObserverFunc(func(ev app.Event) { recorded, isRecorded := ev.(app.RunRecorded) fyne.Do(func() { if isRecorded { events = append(events, recorded.Record) } refresh() }) })) svc.Start(scheduler.NewRealClock()) tabs := container.NewAppTabs( container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel), container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)), ) tabs.SetTabLocation(container.TabLocationTop) return tabs, recordStartup } type minWidthLayout struct { width float32 } func (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { width := l.width var height float32 for _, object := range objects { if !object.Visible() { continue } min := object.MinSize() if min.Width > width { width = min.Width } if min.Height > height { height = min.Height } } return fyne.NewSize(width, height) } func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { for _, object := range objects { if !object.Visible() { continue } object.Move(fyne.NewPos(0, 0)) object.Resize(size) } } func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject { store := svc.Store() startOnLogin := widget.NewCheck("Start on login", nil) startOnLogin.SetChecked(store.Config.StartOnLogin) autostartStatus := widget.NewLabel("") refreshAutostartStatus := func() { ok, message := autostart.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath) if ok { autostartStatus.SetText("OK: " + message) return } autostartStatus.SetText("Problem: " + message) } startOnLogin.OnChanged = func(bool) { if startOnLogin.Checked != store.Config.StartOnLogin { autostartStatus.SetText("Pending: save settings to apply") return } refreshAutostartStatus() } refreshAutostartStatus() minimizeToTray := widget.NewCheck("Keep running in the system tray", nil) minimizeToTray.SetChecked(store.Config.KeepRunningInTray) notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil) notifications.SetChecked(store.Config.NotifyOnFailure) jobsDir := widget.NewEntry() jobsDir.SetText(store.Config.JobsDir) jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { chooseFolder(w, jobsDir) }) logsDir := widget.NewEntry() logsDir.SetText(store.Config.LogsDir) logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() { chooseFolder(w, logsDir) }) maxLogFiles := widget.NewEntry() maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles)) maxLogAgeDays := widget.NewEntry() maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays)) settingsStatus := widget.NewLabel("") saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() { files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text)) if err != nil || files <= 0 { settingsStatus.SetText("Max log files must be a positive number") return } days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text)) if err != nil || days <= 0 { settingsStatus.SetText("Max log age days must be a positive number") return } if strings.TrimSpace(jobsDir.Text) == "" { settingsStatus.SetText("Jobs directory is required") return } if strings.TrimSpace(logsDir.Text) == "" { settingsStatus.SetText("Logs directory is required") 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 := store.Config config.JobsDir = strings.TrimSpace(jobsDir.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 if err := svc.UpdateSettings(config); err != nil { settingsStatus.SetText("Save failed: " + err.Error()) return } // Autostart is platform integration the Service leaves to the caller (until // T5.2 introduces an injectable autostart.Manager), so apply it here. if err := autostart.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil { refreshAutostartStatus() settingsStatus.SetText("Saved, autostart failed: " + err.Error()) return } refreshAutostartStatus() settingsStatus.SetText("Saved") }) return container.NewPadded(container.NewVBox( widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), settingsRowWithStatus("Autostart", startOnLogin, autostartStatus), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)), settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)), widget.NewSeparator(), widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)), settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)), settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)), settingsRow("Max log files", maxLogFiles), settingsRow("Max log age days", maxLogAgeDays), saveSettings, settingsStatus, widget.NewSeparator(), widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), settingsRow("GoSentry", widget.NewLabel(app.Version)), settingsRow("Go", widget.NewLabel(runtime.Version())), settingsRow("Fyne", widget.NewLabel(fyneVersion())), settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))), )) } func fyneVersion() string { info, ok := debug.ReadBuildInfo() if !ok { return "unknown" } for _, dependency := range info.Deps { if dependency.Path == "fyne.io/fyne/v2" { if dependency.Replace != nil && dependency.Replace.Version != "" { return dependency.Replace.Version } if dependency.Version != "" { return dependency.Version } return "local" } } return "unknown" } func mustParseURL(raw string) *url.URL { parsed, err := url.Parse(raw) if err != nil { return &url.URL{} } return parsed } func chooseFolder(w fyne.Window, target *widget.Entry) { folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { if err != nil || uri == nil { return } target.SetText(uri.Path()) }, w) // The default folder picker can be cramped on Windows. A larger size makes // long paths readable and avoids forcing the user to resize it every time. folderDialog.Resize(fyne.NewSize(900, 640)) folderDialog.Show() } func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject { caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) caption.Wrapping = fyne.TextTruncate captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption) return container.NewBorder(nil, nil, captionBox, nil, value) } func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject { valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value) statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status) return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox)) }