diff --git a/README.md b/README.md index 19b5170..b7b29af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 816e1f6..aae8d41 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7f7b1bd..8dfe1db 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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.** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5cdcc28..53f764e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/STANDARDS.md b/docs/STANDARDS.md index 4266df6..4dc7bee 100644 --- a/docs/STANDARDS.md +++ b/docs/STANDARDS.md @@ -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 diff --git a/docs/TESTS.md b/docs/TESTS.md index c684d0f..fc4dfd6 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -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`. | --- diff --git a/src/app/platform.go b/src/app/platform.go index 041386e..bd38ee7 100644 --- a/src/app/platform.go +++ b/src/app/platform.go @@ -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) } diff --git a/src/domain/config.go b/src/domain/config.go index a83175b..2446033 100644 --- a/src/domain/config.go +++ b/src/domain/config.go @@ -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 diff --git a/src/domain/config_test.go b/src/domain/config_test.go index bf3ab90..ee782fc 100644 --- a/src/domain/config_test.go +++ b/src/domain/config_test.go @@ -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}, +func TestAutostartArguments(t *testing.T) { + if got := AutostartArguments(true); got != StartInTrayArgument { + t.Errorf("AutostartArguments(true) = %q, want %q", got, StartInTrayArgument) } - 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) - } + 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) + } } } diff --git a/src/platform/autostart/autostart.go b/src/platform/autostart/autostart.go index 5139f16..f72e671 100644 --- a/src/platform/autostart/autostart.go +++ b/src/platform/autostart/autostart.go @@ -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) } diff --git a/src/platform/autostart/autostart_linux.go b/src/platform/autostart/autostart_linux.go index 1f2c3f6..428d29b 100644 --- a/src/platform/autostart/autostart_linux.go +++ b/src/platform/autostart/autostart_linux.go @@ -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" } diff --git a/src/platform/autostart/autostart_linux_test.go b/src/platform/autostart/autostart_linux_test.go index fa92ffc..b629500 100644 --- a/src/platform/autostart/autostart_linux_test.go +++ b/src/platform/autostart/autostart_linux_test.go @@ -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) + } +} + diff --git a/src/platform/autostart/autostart_other.go b/src/platform/autostart/autostart_other.go index 96a1010..8f99fb7 100644 --- a/src/platform/autostart/autostart_other.go +++ b/src/platform/autostart/autostart_other.go @@ -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" } diff --git a/src/platform/autostart/autostart_windows.go b/src/platform/autostart/autostart_windows.go index 32a7097..1adbc2c 100644 --- a/src/platform/autostart/autostart_windows.go +++ b/src/platform/autostart/autostart_windows.go @@ -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,8 +74,12 @@ 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 { - return false, "Autostart shortcut does not start in tray" + 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, ) diff --git a/src/platform/autostart/autostart_windows_test.go b/src/platform/autostart/autostart_windows_test.go index 5992bd6..a4c7a8e 100644 --- a/src/platform/autostart/autostart_windows_test.go +++ b/src/platform/autostart/autostart_windows_test.go @@ -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) } diff --git a/src/storage/store.go b/src/storage/store.go index e35c685..6502f2b 100644 --- a/src/storage/store.go +++ b/src/storage/store.go @@ -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 { diff --git a/src/ui/run.go b/src/ui/run.go index 477d47d..fe899c9 100644 --- a/src/ui/run.go +++ b/src/ui/run.go @@ -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. diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go index 73afb4f..142fb39 100644 --- a/src/ui/settings_view.go +++ b/src/ui/settings_view.go @@ -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. diff --git a/src/ui/settings_view_layout.go b/src/ui/settings_view_layout.go index 6afd217..56d6bdc 100644 --- a/src/ui/settings_view_layout.go +++ b/src/ui/settings_view_layout.go @@ -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 diff --git a/src/ui/singleinstance.go b/src/ui/singleinstance.go index 59b59f8..67fdd64 100644 --- a/src/ui/singleinstance.go +++ b/src/ui/singleinstance.go @@ -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() }) diff --git a/src/ui/tray.go b/src/ui/tray.go index 631e4ad..544f71f 100644 --- a/src/ui/tray.go +++ b/src/ui/tray.go @@ -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) } diff --git a/src/ui/tray_test.go b/src/ui/tray_test.go new file mode 100644 index 0000000..ea2bea7 --- /dev/null +++ b/src/ui/tray_test.go @@ -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") + } +}