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 +}