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
+6 -2
View File
@@ -201,8 +201,12 @@ loading a new list discards the run state of the old one.
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
it enabled writes an autostart entry using the current executable path.
Autostart entries include `--start-in-tray` so scheduled jobs run after sign-in
without opening the main window.
When **Keep running in the system tray** is also enabled, the entry includes
`--start-in-tray` so scheduled jobs run after sign-in without opening the main
window. With the tray option off, autostart still works but opens the main
window normally. Changing the tray setting updates close behaviour and the
autostart entry immediately; the tray icon itself updates only after you restart
GoSentry (a Fyne limitation — see [docs/ROADMAP.md](docs/ROADMAP.md)).
## Queue Settings
+4 -2
View File
@@ -113,8 +113,10 @@ flowchart LR
7. Autostart:
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
interface has two implementations: Windows writes a `.lnk` shortcut to the
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
entries pass `--start-in-tray`.
user Startup folder; Linux writes an XDG Autostart `.desktop` file. When
`KeepRunningInTray` is enabled the entry passes `--start-in-tray`; when it is
off the entry launches the executable without that flag so the main window
opens after sign-in.
8. Error surfacing:
Background errors (failed JSON saves, cleanup errors) are emitted as
+14
View File
@@ -4,6 +4,20 @@ All notable GoSentry changes are recorded in this file.
## 1.0.1 - 2026-08-04
**KeepRunningInTray is wired to runtime; autostart respects the tray setting.**
**Application:**
- **Keep running in the system tray** now controls behaviour: with the tray on
(default), closing the window hides it and autostart uses `--start-in-tray`;
with the tray off, closing quits the app and autostart opens the main window.
- Saving a tray change updates close behaviour and the autostart entry
immediately. The notification-area icon follows the saved value after a
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
remove the icon mid-session).
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
the window when the tray setting is off — saved config wins over the CLI flag.
**The branded theme is the default, Fyne's built-in theme is System, and the
test suite is leaner.**
+16
View File
@@ -5,6 +5,22 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
## Open Items
### Dynamic tray icon toggle
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
There is no supported way to register or remove the notification-area icon
after the process is running.
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
autostart entry update immediately when the user saves Settings; the tray icon
follows the saved value on the next launch. Settings shows a restart hint when
the tray checkbox changes.
Revisit when Fyne adds a documented API for mid-session tray registration, or
when a stable cross-platform approach exists without reaching into driver
internals. Until then, removing the restart hint and applying the icon on save
is blocked.
### Update check from GitHub releases
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
+10
View File
@@ -70,6 +70,16 @@ change to their shape has to stay compatible on its own.
functions sit at 0% on purpose. Both lists live in
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
coverage gap as an oversight.
- **`KeepRunningInTray` controls tray and close behavior.** When enabled (the
default), the app registers a system tray icon at launch, closing the window
hides it, and autostart passes `--start-in-tray`. When disabled, no tray icon
is registered at launch, closing the window quits the app, and autostart opens
the main window. Toggling the setting in Settings updates close behavior and
rewrites the autostart entry immediately; the tray icon itself follows the
saved value only after a restart because Fyne has no API to add or remove it
mid-session (see [ROADMAP.md](ROADMAP.md)).
- **`--start-in-tray` defers to config.** A stale autostart shortcut that still
passes the flag does not hide the window when `KeepRunningInTray` is off.
## Out of scope
+18 -2
View File
@@ -93,11 +93,12 @@ Tests schedule parsing and validation.
**Package:** `domain`
Tests the normalization rule shared by every consumer of the jobs-list density
setting.
Tests autostart argument helpers and the jobs-list density normalization rule.
| Test | Purpose |
|------|---------|
| `TestAutostartArguments` | Verifies `AutostartArguments` returns `--start-in-tray` when the tray is enabled and an empty string when it is off. |
| `TestResolveStartHidden` | Verifies hidden autostart requires both the CLI flag and `KeepRunningInTray`. |
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
@@ -387,6 +388,8 @@ Tests Windows autostart via shortcuts in the Startup folder.
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
| `TestCreateStartupShortcutWithoutTrayFlag` | Verifies that autostart shortcuts omit `--start-in-tray` when the tray setting is off. |
| `TestAutostartStatusRequiresMatchingTrayFlag` | Verifies `AutostartStatus` reports a problem when the shortcut arguments do not match `KeepRunningInTray`. |
---
@@ -400,6 +403,19 @@ Tests Linux autostart via XDG Desktop Entry files.
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
| `TestLinuxAutostartWithoutTrayFlag` | Verifies that the desktop entry omits `--start-in-tray` when the tray setting is off. |
---
### src/ui/tray_test.go
**Package:** `ui`
Tests startup helpers for tray and autostart interaction.
| Test | Purpose |
|------|---------|
| `TestResolveStartHiddenUsesDomainHelper` | Verifies the UI startup helper stays aligned with `domain.ResolveStartHidden`. |
---
+6 -4
View File
@@ -16,24 +16,26 @@ func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
}
// AutostartStatus reports whether the platform autostart entry matches the
// current StartOnLogin setting in the stored config.
// current StartOnLogin and KeepRunningInTray settings in the stored config.
func (s *Service) AutostartStatus() (ok bool, message string) {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
startInTray := s.store.Config.KeepRunningInTray
execPath := s.store.Paths.ExecutablePath
manager := s.manager
s.mu.Unlock()
if manager == nil {
return false, "autostart not available"
}
return manager.Status(enabled, execPath)
return manager.Status(enabled, startInTray, execPath)
}
// ApplyAutostart writes or removes the platform autostart entry to match the
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
// current StartOnLogin and KeepRunningInTray settings. Call after UpdateSettings.
func (s *Service) ApplyAutostart() error {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
startInTray := s.store.Config.KeepRunningInTray
execPath := s.store.Paths.ExecutablePath
iconPath := s.store.Paths.DesktopIcon
manager := s.manager
@@ -41,5 +43,5 @@ func (s *Service) ApplyAutostart() error {
if manager == nil {
return nil
}
return manager.Set(enabled, execPath, iconPath)
return manager.Set(enabled, startInTray, execPath, iconPath)
}
+17
View File
@@ -5,6 +5,23 @@ package domain
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// AutostartArguments returns the command-line suffix written to a platform
// autostart entry when KeepRunningInTray is enabled. An empty string means the
// app should open its window normally after sign-in.
func AutostartArguments(keepInTray bool) string {
if keepInTray {
return StartInTrayArgument
}
return ""
}
// ResolveStartHidden reports whether an autostart launch should skip showing
// the main window. The CLI flag is ignored when KeepRunningInTray is off so a
// stale shortcut cannot hide the app with no tray icon to restore it.
func ResolveStartHidden(cliStartInTray, keepInTray bool) bool {
return cliStartInTray && keepInTray
}
// ExecutionMode controls whether due jobs run concurrently or one at a time.
type ExecutionMode string
+18 -20
View File
@@ -2,29 +2,27 @@ package domain
import "testing"
// TestJobListViewIsCompact pins the normalization rule: only the exact
// "compact" value selects the one-line rows, so empty and unrecognised values
// (including configs written before the field existed) keep the detailed look.
func TestJobListViewIsCompact(t *testing.T) {
cases := []struct {
view JobListView
want bool
}{
{JobListViewCompact, true},
{JobListViewDetailed, false},
{"", false},
{"Compact", false},
{"tiny", false},
}
for _, tc := range cases {
if got := tc.view.IsCompact(); got != tc.want {
t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want)
func TestAutostartArguments(t *testing.T) {
if got := AutostartArguments(true); got != StartInTrayArgument {
t.Errorf("AutostartArguments(true) = %q, want %q", got, StartInTrayArgument)
}
if got := AutostartArguments(false); got != "" {
t.Errorf("AutostartArguments(false) = %q, want empty", got)
}
}
func TestDefaultConfigUsesDetailedJobList(t *testing.T) {
if got := DefaultConfig().JobListView; got != JobListViewDetailed {
t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed)
func TestResolveStartHidden(t *testing.T) {
cases := []struct {
cli, keep, want bool
}{
{true, true, true},
{true, false, false},
{false, true, false},
{false, false, false},
}
for _, tc := range cases {
if got := ResolveStartHidden(tc.cli, tc.keep); got != tc.want {
t.Errorf("ResolveStartHidden(%v, %v) = %v, want %v", tc.cli, tc.keep, got, tc.want)
}
}
}
+5 -3
View File
@@ -3,7 +3,9 @@ package autostart
// Manager controls platform autostart for the application.
type Manager interface {
// Set writes or removes the platform autostart entry to match enabled.
Set(enabled bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled.
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
// When enabled, startInTray selects whether the entry passes --start-in-tray.
Set(enabled, startInTray bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled
// and startInTray.
Status(expectedEnabled, startInTray bool, executablePath string) (ok bool, message string)
}
+20 -10
View File
@@ -17,17 +17,17 @@ type linuxManager struct{}
// New returns the Linux autostart Manager.
func New() Manager { return linuxManager{} }
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return SetAutostart(enabled, startInTray, executablePath, iconPath)
}
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartDesktopFileName = "gosentry.desktop"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
desktopPath, err := autostartDesktopPath()
if err != nil {
return err
@@ -36,15 +36,19 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
execLine := quoteDesktopExec(executablePath)
if args := domain.AutostartArguments(startInTray); args != "" {
execLine += " " + args
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s %s
Exec=%s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
`, execLine, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
@@ -54,7 +58,7 @@ X-GNOME-Autostart-enabled=true
return nil
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
desktopPath, err := autostartDesktopPath()
if err != nil {
return false, "Cannot resolve XDG autostart directory"
@@ -70,9 +74,15 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
if args := domain.AutostartArguments(startInTray); args != "" {
expectedExec += " " + args
}
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
if startInTray {
return false, "Autostart desktop entry does not start in tray"
}
return false, "Autostart desktop entry starts in tray while setting is off"
}
return true, "Autostart is configured"
}
+27 -1
View File
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
if err := SetAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
t.Fatalf("enable autostart: %v", err)
}
@@ -33,3 +33,29 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
}
}
func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, false, executablePath, ""); err != nil {
t.Fatalf("enable autostart: %v", err)
}
desktopPath, err := autostartDesktopPath()
if err != nil {
t.Fatalf("resolve desktop path: %v", err)
}
data, err := os.ReadFile(desktopPath)
if err != nil {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry should not include tray flag: %s", data)
}
if strings.Contains(string(data), domain.StartInTrayArgument) {
t.Fatalf("desktop entry must not pass --start-in-tray: %s", data)
}
}
+6 -6
View File
@@ -9,22 +9,22 @@ type otherManager struct{}
// New returns the stub autostart Manager for unsupported platforms.
func New() Manager { return otherManager{} }
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return SetAutostart(enabled, startInTray, executablePath, iconPath)
}
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, startInTray, executablePath)
}
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
+14 -10
View File
@@ -16,18 +16,18 @@ type windowsManager struct{}
// New returns the Windows autostart Manager.
func New() Manager { return windowsManager{} }
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
return SetAutostart(enabled, startInTray, executablePath, iconPath)
}
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, startInTray, executablePath)
}
const autostartName = "GoSentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
// Windows autostart used to write HKCU\Run values, but that approach became
// brittle once paths with spaces and the "--start-in-tray" argument entered
// the picture. A Startup-folder shortcut stores target path and arguments as
@@ -39,12 +39,12 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
}
if enabled {
return createStartupShortcut(shortcutPath, executablePath, iconPath)
return createStartupShortcut(shortcutPath, executablePath, iconPath, domain.AutostartArguments(startInTray))
}
return removeIfExists(shortcutPath)
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
shortcutPath, err := startupShortcutPath()
if err != nil {
return false, "Startup folder cannot be resolved"
@@ -74,9 +74,13 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
expectedArgs := domain.AutostartArguments(startInTray)
if strings.TrimSpace(arguments) != expectedArgs {
if startInTray {
return false, "Autostart shortcut does not start in tray"
}
return false, "Autostart shortcut starts in tray while setting is off"
}
return true, "Autostart is configured"
}
@@ -88,7 +92,7 @@ func startupShortcutPath() (string, error) {
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
}
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string, arguments string) error {
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
return err
}
@@ -106,7 +110,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_ARGUMENTS="+arguments,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
@@ -82,7 +82,7 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
@@ -98,6 +98,51 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
}
}
func TestCreateStartupShortcutWithoutTrayFlag(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
targetPath := filepath.Join(tempDir, "gosentry.exe")
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", ""); err != nil {
t.Fatalf("create shortcut: %v", err)
}
_, arguments, err := readShortcut(shortcutPath)
if err != nil {
t.Fatalf("read shortcut: %v", err)
}
if arguments != "" {
t.Fatalf("shortcut arguments mismatch: got %q want empty", arguments)
}
}
func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("APPDATA", tempDir)
shortcutPath, err := startupShortcutPath()
if err != nil {
t.Fatalf("startupShortcutPath: %v", err)
}
targetPath := filepath.Join(tempDir, "gosentry.exe")
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
t.Fatalf("create target: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
ok, message := AutostartStatus(true, false, targetPath)
if ok {
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
}
if message != "Autostart shortcut starts in tray while setting is off" {
t.Fatalf("unexpected message: %q", message)
}
}
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
@@ -109,7 +154,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
t.Fatalf("create target file: %v", err)
}
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
t.Fatalf("create shortcut: %v", err)
}
+15
View File
@@ -16,6 +16,21 @@ type Store struct {
Config domain.Config
}
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
// decisions that must run before app.Open(). On error it returns the built-in
// default.
func PeekKeepRunningInTray() bool {
paths, err := ResolvePaths()
if err != nil {
return domain.DefaultConfig().KeepRunningInTray
}
config, err := loadOrCreateConfig(paths)
if err != nil {
return domain.DefaultConfig().KeepRunningInTray
}
return config.KeepRunningInTray
}
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
+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()
})
+45 -16
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)
}
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.
// saveWindowSize()
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")
}
}