chore: land the remaining low-severity items from the whole-project review
Phase 11 of PROJECT_REVIEW_PLAN.md: the themed cleanup pass over every low-severity finding still open (2.2-2.3, 3.4-3.6, 4.3-4.7, 6.4-6.7, 7.1-7.3, 8.2-8.3, 9.1-9.4, and the under-documented decisions in §10/§11). Behavioral fixes: - Reassign duplicate job IDs in a hand-edited jobs.json instead of letting two jobs share one runtime, schedule entry, and SeedStats bucket. - Disambiguate run-log file names that collide within the same second. - Compute AvgDurationMS as DurationSumMS/TimedRunCount instead of an incremental integer mean, so it always matches the seeded-from-logs average instead of drifting from truncation error. - Clean absolute paths in ResolveConfiguredPath so two spellings of the same jobs file do not trigger a spurious adoption. - Report InstallDesktopIcon failures through ErrorOccurred instead of discarding them silently. - Move settingsView's blocking AutostartStatus (PowerShell on Windows) off the UI thread. - Give notify-timing.tsv its own extension so CleanupLogs no longer manages it as a run log. - Replace the settingsView Save handler's second copy of validateConfig's rules with a bare parse, letting the Service's own error surface. Cleanups: - Delete collectActivity, the dead yaml tags on RunRecord, and the logArguments/LogArguments alias. - Fold the two systemTrayRegistered/mainWindowHidden globals into one trayState instance Run owns and threads through Settings and the single-instance reveal path. - Fix stale comments/docs: the frozen window-size restore claim, a reference to a renamed recordRun, README's "Pause all" and notification wording, the PowerShell quoting note for TESTS.md's coverage command, and scripts/test.bat's UTF-8 checkmarks under a non-UTF-8 code page. - Document the single-instance fallback's consequence and the unauthenticated instance-channel port in STANDARDS.md; record the config-shim retirement plan in ROADMAP.md. 3.5, 7.3, and 9.4 turned out to already be fixed by earlier phases; no change needed for those three. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,8 +5,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
@@ -26,22 +24,6 @@ func newEvent(jobID int, jobName string, state string, detail string) event {
|
||||
}
|
||||
}
|
||||
|
||||
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
|
||||
var events []event
|
||||
for _, current := range jobs {
|
||||
// At startup this is usually empty because jobs.json does not persist
|
||||
// runtime logs. The function still centralizes the merge for future
|
||||
// history loading from log metadata.
|
||||
if rt := runtimes[current.ID]; rt != nil {
|
||||
events = append(events, rt.Logs...)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(events, func(left int, right int) bool {
|
||||
return events[left].Time < events[right].Time
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
// textWidth measures how wide s renders at the theme's current body text size.
|
||||
func textWidth(s string) float32 {
|
||||
return fyne.MeasureText(s, theme.TextSize(), fyne.TextStyle{}).Width
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/test"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
@@ -57,31 +55,6 @@ func TestIndexOfID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectActivityMergesAndSorts(t *testing.T) {
|
||||
jobs := []job{
|
||||
{ID: 1, Name: "A"},
|
||||
{ID: 2, Name: "B"},
|
||||
}
|
||||
runtimes := map[int]*domain.JobRuntime{
|
||||
1: {Logs: []domain.RunRecord{{Time: "2026-01-02 10:00:00", JobID: 1}}},
|
||||
2: {Logs: []domain.RunRecord{{Time: "2026-01-01 09:00:00", JobID: 2}}},
|
||||
}
|
||||
got := collectActivity(jobs, runtimes)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Time != "2026-01-01 09:00:00" || got[1].Time != "2026-01-02 10:00:00" {
|
||||
t.Errorf("sort order = %v, want ascending by Time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectActivitySkipsMissingRuntimes(t *testing.T) {
|
||||
jobs := []job{{ID: 1, Name: "A"}}
|
||||
if got := collectActivity(jobs, nil); len(got) != 0 {
|
||||
t.Errorf("nil runtimes: got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryCellText(t *testing.T) {
|
||||
events := []event{{
|
||||
Time: "2026-06-01 12:00:00",
|
||||
|
||||
+3
-1
@@ -85,8 +85,10 @@ func (v *jobsView) refresh() {
|
||||
// it is re-read here rather than mirrored from the tap handler alone — that is
|
||||
// what makes this view a consumer of SchedulerStateChanged.
|
||||
v.applySchedulerState(v.svc.Config().Paused)
|
||||
// updateDetails already ends in a d.logs.Refresh() (both its update and clear
|
||||
// paths do), so refreshing the activity list again here would redraw it twice
|
||||
// per call.
|
||||
v.updateDetails()
|
||||
v.dp.logs.Refresh()
|
||||
v.list.Refresh()
|
||||
v.syncListSelection()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// lastJobLogs returns a fresh slice of the most recent activity entries for the
|
||||
// "Selected job activity" panel. Logs are stored newest-first (see
|
||||
// app.Service.recordRun), so the leading entries are the latest; the result is
|
||||
// app.prependLog), so the leading entries are the latest; the result is
|
||||
// capped at maxJobActivityRows.
|
||||
func lastJobLogs(logs []event) []event {
|
||||
n := len(logs)
|
||||
|
||||
+9
-15
@@ -21,20 +21,11 @@ const runRecordTimeLayout = "2006-01-02 15:04:05"
|
||||
type job = domain.Job
|
||||
type event = domain.RunRecord
|
||||
|
||||
func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
||||
|
||||
// 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 := newHistoryLog(collectActivity(initialJobs, initialRuntimes))
|
||||
func newMainView(w fyne.Window, svc *app.Service, tray *trayState) (fyne.CanvasObject, func(time.Duration, bool)) {
|
||||
// History is session-only: jobs.json never persists JobRuntime.Logs (see
|
||||
// domain.JobRuntime), so there is nothing to seed the History tab with at
|
||||
// startup. It starts empty and fills as events arrive.
|
||||
events := newHistoryLog(nil)
|
||||
|
||||
jobsPanel, refreshJobsView := newJobsView(w, svc)
|
||||
|
||||
@@ -106,12 +97,15 @@ func newMainView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func(time.
|
||||
refresh()
|
||||
})
|
||||
}))
|
||||
// Installed after Subscribe so a failure reaches History through
|
||||
// ErrorOccurred instead of being emitted to no listener.
|
||||
svc.InstallDesktopIcon(appID, assets.IconBytes())
|
||||
svc.Start()
|
||||
|
||||
tabs := container.NewAppTabs(
|
||||
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
|
||||
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
|
||||
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc, tray)),
|
||||
)
|
||||
tabs.SetTabLocation(container.TabLocationTop)
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
|
||||
svc := app.NewService(store, nil)
|
||||
defer svc.Stop()
|
||||
|
||||
content, _ := newMainView(w, svc)
|
||||
content, _ := newMainView(w, svc, &trayState{})
|
||||
min := content.MinSize()
|
||||
if min.Width > defaultWindowWidth || min.Height > defaultWindowHeight {
|
||||
t.Errorf("content.MinSize() = %v, want within %vx%v", min, defaultWindowWidth, defaultWindowHeight)
|
||||
@@ -111,7 +111,7 @@ func TestMainViewRecordStartupAddsHistoryRow(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
defer svc.Stop()
|
||||
|
||||
content, recordStartup := newMainView(w, svc)
|
||||
content, recordStartup := newMainView(w, svc, &trayState{})
|
||||
w.SetContent(content)
|
||||
|
||||
table := historyTable(t, content)
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const notificationTimingLogName = "notify-timing.log"
|
||||
// notificationTimingLogName deliberately does not end in .log: runner.CleanupLogs
|
||||
// only manages .log files in the logs directory, and this diagnostic file
|
||||
// should not be subject to (or counted against) that retention policy.
|
||||
const notificationTimingLogName = "notify-timing.tsv"
|
||||
|
||||
// notificationTiming captures wall-clock points from a failed run through
|
||||
// SendNotification. It does not include OS toast display latency — Fyne on
|
||||
|
||||
+9
-10
@@ -17,9 +17,10 @@ import (
|
||||
const appID = "ru.mixeme.gosentry.desktop"
|
||||
|
||||
// defaultWindowWidth and defaultWindowHeight are the size the window opens at
|
||||
// on first launch (later launches restore the last size from preferences).
|
||||
// Fyne enforces the assembled content's MinSize as a hard floor over these, so
|
||||
// they only take effect if the content actually fits within them.
|
||||
// on every launch. Window size persistence is frozen (see ROADMAP.md), so
|
||||
// there is no saved size to restore. Fyne enforces the assembled content's
|
||||
// MinSize as a hard floor over these, so they only take effect if the content
|
||||
// actually fits within them.
|
||||
const defaultWindowWidth = 1024
|
||||
const defaultWindowHeight = 660
|
||||
|
||||
@@ -60,10 +61,7 @@ func Run(startInTray bool) {
|
||||
|
||||
w := a.NewWindow("GoSentry " + app.Version)
|
||||
setWindowsNotificationIcon()
|
||||
prefs := a.Preferences()
|
||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
||||
w.Resize(fyne.NewSize(winW, winH))
|
||||
w.Resize(fyne.NewSize(defaultWindowWidth, defaultWindowHeight))
|
||||
svc, err := app.Open()
|
||||
if err != nil {
|
||||
w.SetContent(container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())))
|
||||
@@ -73,13 +71,14 @@ func Run(startInTray bool) {
|
||||
config := svc.Config()
|
||||
keepInTray = config.KeepRunningInTray
|
||||
startHidden = resolveStartHidden(startInTray, keepInTray)
|
||||
applyTrayBehavior(a, w, keepInTray, false)
|
||||
tray := &trayState{}
|
||||
tray.apply(a, w, keepInTray, false)
|
||||
// Apply the persisted theme before building content so the window renders in
|
||||
// the chosen theme from the first frame rather than flashing the default one.
|
||||
applyTheme(a, config.Theme)
|
||||
content, recordStartup := newMainView(w, svc)
|
||||
content, recordStartup := newMainView(w, svc, tray)
|
||||
w.SetContent(content)
|
||||
serveSingleInstance(instanceListener, w)
|
||||
serveSingleInstance(instanceListener, w, tray)
|
||||
if startHidden {
|
||||
// Autostart launches intentionally stay hidden, so "window shown" would be
|
||||
// a misleading metric. Record a separate startup event for the tray path
|
||||
|
||||
+37
-29
@@ -26,7 +26,7 @@ var settingsCaptions = []string{
|
||||
"GoSentry", "Go", "Fyne", "Repository",
|
||||
}
|
||||
|
||||
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
func settingsView(w fyne.Window, svc *app.Service, tray *trayState) fyne.CanvasObject {
|
||||
// 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
|
||||
@@ -49,17 +49,36 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
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
|
||||
}
|
||||
ok, message := svc.AutostartStatus()
|
||||
if ok {
|
||||
autostartStatus.SetText("OK: " + message)
|
||||
return
|
||||
}
|
||||
autostartStatus.SetText("Problem: " + message)
|
||||
// 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 {
|
||||
@@ -142,27 +161,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
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 zero (unlimited) or 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 zero (unlimited) or a positive number")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(jobsFile.Text) == "" {
|
||||
settingsStatus.SetText("Jobs file is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(logsDir.Text) == "" {
|
||||
settingsStatus.SetText("Logs directory is required")
|
||||
return
|
||||
}
|
||||
timeout, err := strconv.Atoi(strings.TrimSpace(defaultTimeout.Text))
|
||||
if err != nil || timeout < 0 {
|
||||
settingsStatus.SetText("Default timeout must not be negative (0 = no timeout)")
|
||||
// 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
|
||||
@@ -196,7 +204,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
return
|
||||
}
|
||||
refreshAutostartStatus()
|
||||
applyTrayBehavior(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||
tray.apply(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||
if previousKeepInTray != config.KeepRunningInTray {
|
||||
trayRestartHint.SetText(trayRestartHintText)
|
||||
} else {
|
||||
|
||||
@@ -34,11 +34,14 @@ func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
|
||||
// If the port is unavailable but does not answer as GoSentry, continue
|
||||
// startup instead of making the application impossible to open because of an
|
||||
// unrelated local listener. In the normal duplicate-start case the dial above
|
||||
// succeeds and this process exits after waking the first instance.
|
||||
// succeeds and this process exits after waking the first instance. The
|
||||
// consequence of this fallback — two schedulers able to run against the same
|
||||
// jobs.json and logs directory — is recorded in STANDARDS.md alongside the
|
||||
// unauthenticated nature of this same port.
|
||||
return nil, true
|
||||
}
|
||||
|
||||
func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
||||
func serveSingleInstance(listener net.Listener, w fyne.Window, tray *trayState) {
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
@@ -56,7 +59,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
||||
// Accept runs on its own goroutine, so focusing the window must be
|
||||
// marshaled onto the main thread like every other widget update.
|
||||
fyne.Do(func() {
|
||||
mainWindowHidden = false
|
||||
tray.hidden = false
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
})
|
||||
|
||||
+28
-25
@@ -10,16 +10,19 @@ import (
|
||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||
)
|
||||
|
||||
// systemTrayRegistered tracks whether this process registered a tray icon at
|
||||
// launch. Fyne cannot add or remove the icon mid-session, so toggling
|
||||
// KeepRunningInTray in Settings updates close behavior immediately and shows a
|
||||
// restart hint for the icon itself.
|
||||
var systemTrayRegistered bool
|
||||
|
||||
// mainWindowHidden tracks whether the primary window was hidden via the tray
|
||||
// close intercept. Fyne exposes no Window.Visible API, so the flag drives the
|
||||
// reveal-on-tray-disable path in applyTrayBehavior.
|
||||
var mainWindowHidden bool
|
||||
// trayState tracks the two pieces of tray-related process state that Fyne
|
||||
// itself does not expose: whether this process has registered the tray icon
|
||||
// (Fyne cannot add or remove it mid-session, so toggling KeepRunningInTray in
|
||||
// Settings updates close behavior immediately but shows a restart hint for the
|
||||
// icon itself) and whether the primary window is currently hidden via the tray
|
||||
// close intercept (Fyne exposes no Window.Visible API). Run owns one instance
|
||||
// and passes it to every call site of apply — settingsView's Save handler is
|
||||
// the other one — so the coupling between them is explicit instead of hidden
|
||||
// behind package-level globals that no test can reset.
|
||||
type trayState struct {
|
||||
registered bool
|
||||
hidden bool
|
||||
}
|
||||
|
||||
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
|
||||
|
||||
@@ -27,23 +30,23 @@ func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
||||
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
|
||||
}
|
||||
|
||||
// applyTrayBehavior configures window close handling for KeepRunningInTray.
|
||||
// When revealIfHidden is true and the tray is off, a hidden window is shown so
|
||||
// the user can still reach the app after disabling the tray mid-session.
|
||||
func applyTrayBehavior(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
||||
if keepInTray && !systemTrayRegistered {
|
||||
registerSystemTray(a, w)
|
||||
systemTrayRegistered = true
|
||||
// apply configures window close handling for KeepRunningInTray. When
|
||||
// revealIfHidden is true and the tray is off, a hidden window is shown so the
|
||||
// user can still reach the app after disabling the tray mid-session.
|
||||
func (t *trayState) apply(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
||||
if keepInTray && !t.registered {
|
||||
t.registerSystemTray(a, w)
|
||||
t.registered = true
|
||||
}
|
||||
setWindowCloseBehavior(w, keepInTray)
|
||||
if !keepInTray && revealIfHidden && mainWindowHidden {
|
||||
mainWindowHidden = false
|
||||
t.setWindowCloseBehavior(w, keepInTray)
|
||||
if !keepInTray && revealIfHidden && t.hidden {
|
||||
t.hidden = false
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
func (t *trayState) registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
desk, ok := a.(fynedesktop.App)
|
||||
if !ok {
|
||||
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||
@@ -74,7 +77,7 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
quit.IsQuit = true
|
||||
menu := fyne.NewMenu("GoSentry",
|
||||
fyne.NewMenuItem("Show", func() {
|
||||
mainWindowHidden = false
|
||||
t.hidden = false
|
||||
w.Show()
|
||||
w.RequestFocus()
|
||||
}),
|
||||
@@ -85,17 +88,17 @@ func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||
desk.SetSystemTrayWindow(w)
|
||||
}
|
||||
|
||||
func setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||
func (t *trayState) setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||
if keepInTray {
|
||||
w.SetCloseIntercept(func() {
|
||||
// Closing hides the window instead of quitting because scheduler tools are
|
||||
// expected to keep working in the background. The explicit Quit tray item
|
||||
// remains the way to stop the process.
|
||||
mainWindowHidden = true
|
||||
t.hidden = true
|
||||
w.Hide()
|
||||
})
|
||||
return
|
||||
}
|
||||
mainWindowHidden = false
|
||||
t.hidden = false
|
||||
w.SetCloseIntercept(nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user