From 8b03c96851360285f7525f76b9d1d0ad25e8affa Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Sun, 31 May 2026 02:01:02 +0200 Subject: [PATCH] ui: add launch-at-login (autostart) toggle for the UI Add an Autostart Wails service wrapping app.Autostart and a toggle in the General settings tab. The OS login-item registration is the single source of truth (nothing mirrored to the preferences file). Affects the graphical UI only, not the daemon. The toggle hides itself on platforms where autostart is unsupported. --- client/ui/CLAUDE.md | 1 + .../src/modules/settings/SettingsGeneral.tsx | 45 +++++++++++++- client/ui/i18n/locales/de/common.json | 3 + client/ui/i18n/locales/en/common.json | 3 + client/ui/i18n/locales/hu/common.json | 3 + client/ui/main.go | 1 + client/ui/services/autostart.go | 62 +++++++++++++++++++ 7 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 client/ui/services/autostart.go diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 7f2f0d49a..f3e01b36d 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -38,6 +38,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr | `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)` / `OpenInstallProgress(version)` / `CloseInstallProgress`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). | | `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. | | `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language, viewMode}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage` and persists; `SetViewMode(mode)` validates against the known set (`default`/`advanced`) and persists. Both broadcast `netbird:preferences:changed`. `main.go` reads `viewMode` from the store to size the main window at startup. | +| `Autostart` | `autostart.go` | Thin facade over Wails' `app.Autostart` (`*application.AutostartManager`). `Supported()` / `IsEnabled()` / `SetEnabled(bool)` — launch-the-UI-at-login toggle. The OS login-item registration (launchd/SMAppService on macOS, `HKCU\…\Run` on Windows, XDG `.desktop` on Linux) is the **single source of truth** — nothing is mirrored to the preferences file. `Enable` registers the running executable with no extra args (the app comes up hidden into the tray). Affects the **graphical UI only**, not the daemon/background service. `Supported()` is false on server/mobile builds (`ErrAutostartNotSupported`); the React toggle in `SettingsGeneral.tsx` hides itself when false. | `DaemonConn` is defined in `services/conn.go`; `ptrStr` (string-to-*string helper for proto pointer fields) lives there too. diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx index c493586b0..1f60b01d8 100644 --- a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Dialogs } from "@wailsio/runtime"; import { Button } from "@/components/buttons/Button"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import { HelpText } from "@/components/typography/HelpText"; @@ -10,12 +11,46 @@ import { useSettings } from "@/contexts/SettingsContext.tsx"; import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx"; import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts"; import { LanguagePicker } from "@/components/LanguagePicker.tsx"; +import { Autostart } from "@bindings/services"; +import i18next from "@/lib/i18n"; export function SettingsGeneral() { const { t } = useTranslation(); const { config, setField } = useSettings(); const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl(); + // Autostart lives in the OS login-item registry, not the daemon config, so + // it has its own read-on-mount state. supported gates whether we render the + // toggle at all (false on server/mobile builds). + const [autostartSupported, setAutostartSupported] = useState(false); + const [autostartEnabled, setAutostartEnabled] = useState(false); + useEffect(() => { + let cancelled = false; + (async () => { + const supported = await Autostart.Supported(); + if (cancelled) return; + setAutostartSupported(supported); + if (!supported) return; + setAutostartEnabled(await Autostart.IsEnabled()); + })().catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const onAutostartChange = async (enabled: boolean) => { + setAutostartEnabled(enabled); + try { + await Autostart.SetEnabled(enabled); + } catch (e) { + setAutostartEnabled(!enabled); + await Dialogs.Error({ + Title: i18next.t("settings.general.autostart.errorTitle"), + Message: String(e), + }); + } + }; + const inputRef = useRef(null); const prevMode = useRef(mode); useEffect(() => { @@ -44,6 +79,14 @@ export function SettingsGeneral() { label={t("settings.general.notifications.label")} helpText={t("settings.general.notifications.help")} /> + {autostartSupported && ( + + )} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index a5a3753fb..302538699 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -135,6 +135,9 @@ "settings.general.connectOnStartup.help": "Beim Start des Dienstes automatisch eine Verbindung herstellen.", "settings.general.notifications.label": "Desktop-Benachrichtigungen", "settings.general.notifications.help": "Desktop-Benachrichtigungen für neue Updates und Verbindungsereignisse anzeigen.", + "settings.general.autostart.label": "NetBird-UI beim Anmelden starten", + "settings.general.autostart.help": "Die NetBird-Oberfläche beim Anmelden automatisch starten. Dies betrifft nur die grafische Oberfläche, nicht den Hintergrunddienst.", + "settings.general.autostart.errorTitle": "Ändern des Autostarts fehlgeschlagen", "settings.general.language.label": "Anzeigesprache", "settings.general.language.help": "Wählen Sie die Sprache der NetBird-Oberfläche.", "settings.general.language.search": "Sprache suchen…", diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index bac7b1f5c..162ec6912 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -157,6 +157,9 @@ "settings.general.connectOnStartup.help": "Automatically establish a connection when the service starts.", "settings.general.notifications.label": "Desktop Notifications", "settings.general.notifications.help": "Show desktop notifications for new updates and connection events.", + "settings.general.autostart.label": "Launch NetBird UI at Login", + "settings.general.autostart.help": "Start the NetBird interface automatically when you log in. This affects the graphical interface only, not the background service.", + "settings.general.autostart.errorTitle": "Autostart Change Failed", "settings.general.language.label": "Display Language", "settings.general.language.help": "Choose the language for the NetBird interface.", "settings.general.language.search": "Search language…", diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index 79921ea67..78db7bf89 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -135,6 +135,9 @@ "settings.general.connectOnStartup.help": "A szolgáltatás indulásakor automatikusan kapcsolatot létesít.", "settings.general.notifications.label": "Asztali értesítések", "settings.general.notifications.help": "Asztali értesítések megjelenítése új frissítésekről és kapcsolati eseményekről.", + "settings.general.autostart.label": "NetBird UI indítása bejelentkezéskor", + "settings.general.autostart.help": "A NetBird felület automatikus indítása bejelentkezéskor. Ez csak a grafikus felületet érinti, a háttérszolgáltatást nem.", + "settings.general.autostart.errorTitle": "Az automatikus indítás módosítása sikertelen", "settings.general.language.label": "Megjelenítési nyelv", "settings.general.language.help": "Válassza ki a NetBird felület nyelvét.", "settings.general.language.search": "Nyelv keresése…", diff --git a/client/ui/main.go b/client/ui/main.go index 5a119c0c1..dfb98e0da 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -298,6 +298,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(s.profileSwitcher)) app.RegisterService(application.NewService(services.NewI18n(s.bundle))) app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) + app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) } // newMainWindow creates the hidden main window, sized to the user's last view diff --git a/client/ui/services/autostart.go b/client/ui/services/autostart.go new file mode 100644 index 000000000..dcdfaeafe --- /dev/null +++ b/client/ui/services/autostart.go @@ -0,0 +1,62 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Autostart is the Wails-bound facade over Wails' AutostartManager. The OS +// login-item registration (launchd/SMAppService on macOS, HKCU\…\Run on +// Windows, an XDG .desktop on Linux) is the single source of truth — IsEnabled +// reads it directly, so nothing is mirrored to the preferences file. Enable +// registers the running executable to launch at login with no extra arguments; +// the app comes up hidden into the tray, same as a normal launch. +type Autostart struct { + mgr *application.AutostartManager +} + +// NewAutostart wraps the application's AutostartManager (app.Autostart). +func NewAutostart(mgr *application.AutostartManager) *Autostart { + return &Autostart{mgr: mgr} +} + +// Supported reports whether autostart can be toggled on this platform. The +// frontend hides the toggle entirely when this is false. +func (a *Autostart) Supported(_ context.Context) bool { + _, err := a.mgr.Status() + return !errors.Is(err, application.ErrAutostartNotSupported) +} + +// IsEnabled reports whether the app is currently registered to launch at +// login. On an unsupported platform it returns false without error so the +// frontend can render the toggle off (gated by Supported). +func (a *Autostart) IsEnabled(_ context.Context) (bool, error) { + enabled, err := a.mgr.IsEnabled() + if err != nil { + if errors.Is(err, application.ErrAutostartNotSupported) { + return false, nil + } + return false, fmt.Errorf("read autostart state: %w", err) + } + return enabled, nil +} + +// SetEnabled registers (enabled) or removes (disabled) the launch-at-login +// entry. The change takes effect on the next login, not immediately. +func (a *Autostart) SetEnabled(_ context.Context, enabled bool) error { + if enabled { + if err := a.mgr.Enable(); err != nil { + return fmt.Errorf("enable autostart: %w", err) + } + return nil + } + if err := a.mgr.Disable(); err != nil { + return fmt.Errorf("disable autostart: %w", err) + } + return nil +}