Wire KeepRunningInTray to runtime so tray, close, and autostart follow the saved setting.

Autostart entries pass --start-in-tray only when the tray is enabled; Settings warns that the notification icon needs a restart (Fyne limitation).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 22:44:02 +03:00
parent 5170cc5f99
commit 5b0e6fe51b
22 changed files with 356 additions and 97 deletions
+8 -3
View File
@@ -6,6 +6,7 @@ import (
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/storage"
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
@@ -29,7 +30,9 @@ const defaultWindowHeight = 660
// mainwindow.go split keeps lifecycle separate from view construction.
func Run(startInTray bool) {
started := time.Now()
instanceListener, primary := acquireSingleInstance(!startInTray)
keepInTray := storage.PeekKeepRunningInTray()
startHidden := resolveStartHidden(startInTray, keepInTray)
instanceListener, primary := acquireSingleInstance(!startHidden)
if !primary {
return
}
@@ -56,7 +59,6 @@ func Run(startInTray bool) {
}
w := a.NewWindow("GoSentry " + app.Version)
configureSystemTray(a, w)
prefs := a.Preferences()
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
@@ -67,13 +69,16 @@ func Run(startInTray bool) {
a.Run()
return
}
keepInTray = svc.Store().Config.KeepRunningInTray
startHidden = resolveStartHidden(startInTray, keepInTray)
applyTrayBehavior(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, svc.Store().Config.Theme)
content, recordStartup := newMainView(w, svc)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
if startInTray {
if startHidden {
// Autostart launches intentionally stay hidden, so "window shown" would be
// a misleading metric. Record a separate startup event for the tray path
// instead of forcing one timing definition onto two different UX flows.
+36 -9
View File
@@ -37,8 +37,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
var loadFields func(domain.Config)
startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin)
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
autostartStatus := widget.NewLabel("")
trayRestartHint := widget.NewLabel("")
trayRestartHint.Truncation = fyne.TextTruncateClip
refreshAutostartStatus := func() {
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
autostartStatus.SetText("Pending: save settings to apply")
return
}
ok, message := svc.AutostartStatus()
if ok {
autostartStatus.SetText("OK: " + message)
@@ -46,18 +54,23 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
}
autostartStatus.SetText("Problem: " + message)
}
startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
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 != store.Config.KeepRunningInTray)
updateSaveState()
}
refreshAutostartStatus()
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
notifications.OnChanged = func(bool) { updateSaveState() }
@@ -158,6 +171,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
config.DefaultTimeoutSeconds = timeout
config.Theme = themeFromLabel(themeSelect.Selected)
previousKeepInTray := store.Config.KeepRunningInTray
if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error())
return
@@ -168,6 +182,12 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
return
}
refreshAutostartStatus()
applyTrayBehavior(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()
@@ -215,11 +235,12 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
logsDir.SetText(c.LogsDir)
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
if startOnLogin.Checked != store.Config.StartOnLogin {
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
autostartStatus.SetText("Pending: save settings to apply")
} else {
refreshAutostartStatus()
}
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
settingsStatus.SetText("")
updateSaveState()
}
@@ -234,6 +255,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
startOnLogin: startOnLogin,
autostartStatus: autostartStatus,
minimizeToTray: minimizeToTray,
trayRestartHint: trayRestartHint,
notifications: notifications,
themeSelect: themeSelect,
executionModeSelect: executionModeSelect,
@@ -254,6 +276,11 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
})
}
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
return startOnLogin.Checked != saved.StartOnLogin ||
minimizeToTray.Checked != saved.KeepRunningInTray
}
// Theme dropdown labels. These are the human-facing captions; themeLabel and
// themeFromLabel translate between them and the stored domain.Theme values so the
// select never leaks the on-disk "system"/"gosentry" strings to the user.
+2
View File
@@ -20,6 +20,7 @@ type settingsFormFields struct {
startOnLogin *widget.Check
autostartStatus *widget.Label
minimizeToTray *widget.Check
trayRestartHint *widget.Label
notifications *widget.Check
themeSelect *widget.Select
executionModeSelect *widget.Select
@@ -59,6 +60,7 @@ func newSettingsLayout(f settingsFormFields) fyne.CanvasObject {
// empty caption, so the Application section fits in a half-width column.
settingsRow(capW, "", f.autostartStatus),
settingsRow(capW, "Tray", f.minimizeToTray),
settingsRow(capW, "", f.trayRestartHint),
settingsRow(capW, "Notifications", f.notifications),
// Theme is the one row here whose value is not text: the Select paints
// a box out to the row's edge, so the section's overlap would leave it
+1
View File
@@ -56,6 +56,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
w.Show()
w.RequestFocus()
})
+51 -22
View File
@@ -4,12 +4,46 @@ import (
"runtime"
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
fynedesktop "fyne.io/fyne/v2/driver/desktop"
)
func configureSystemTray(a fyne.App, w fyne.Window) {
// 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
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
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
}
setWindowCloseBehavior(w, keepInTray)
if !keepInTray && revealIfHidden && mainWindowHidden {
mainWindowHidden = false
w.Show()
w.RequestFocus()
}
}
func registerSystemTray(a fyne.App, w fyne.Window) {
desk, ok := a.(fynedesktop.App)
if !ok {
// Not every Fyne driver exposes desktop tray features. Returning silently
@@ -34,26 +68,13 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
// localized label — which our literal "Quit" does not. Setting IsQuit makes
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
// Window size persistence is frozen: w.Canvas().Size() returns the maximized
// dimensions when the window is maximized, so saving here would corrupt the
// stored size. Needs cross-platform maximized-state detection (IsZoomed /
// _NET_WM_STATE / NSWindow.isZoomed) before it can be re-enabled safely.
// See ROADMAP.md — "Window size — skip saving when maximized".
//
// saveWindowSize := func() {
// size := w.Canvas().Size()
// prefs := a.Preferences()
// prefs.SetFloat("window.width", float64(size.Width))
// prefs.SetFloat("window.height", float64(size.Height))
// }
quit := fyne.NewMenuItem("Quit", func() {
// saveWindowSize()
a.Quit()
})
quit.IsQuit = true
menu := fyne.NewMenu("GoSentry",
fyne.NewMenuItem("Show", func() {
mainWindowHidden = false
w.Show()
w.RequestFocus()
}),
@@ -62,11 +83,19 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
)
desk.SetSystemTrayMenu(menu)
desk.SetSystemTrayWindow(w)
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.
// saveWindowSize()
w.Hide()
})
}
func 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
w.Hide()
})
return
}
mainWindowHidden = false
w.SetCloseIntercept(nil)
}
+14
View File
@@ -0,0 +1,14 @@
package ui
import "testing"
func TestResolveStartHiddenUsesDomainHelper(t *testing.T) {
// resolveStartHidden is the UI alias used at startup; it must stay aligned
// with domain.ResolveStartHidden so run.go and tests share one definition.
if got := resolveStartHidden(true, false); got {
t.Fatal("expected hidden start to require both CLI flag and keepInTray")
}
if !resolveStartHidden(true, true) {
t.Fatal("expected hidden start when both flags are set")
}
}