From 1e2a7aa571d48b4197c8b0c692de6d713222b913 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 6 Aug 2026 10:56:27 +0000 Subject: [PATCH] [client] Add a UI setting to stay connected after quitting (#7078) Quitting the GUI from the tray always sent a Down RPC, dropping the VPN connection with it. Some users want the connection to survive the UI. --- .../src/hooks/useKeepConnectedOnQuit.ts | 35 ++++++++++++++++++ .../src/modules/settings/SettingsGeneral.tsx | 11 ++++++ client/ui/i18n/locales/de/common.json | 8 +++++ client/ui/i18n/locales/en/common.json | 8 +++++ client/ui/i18n/locales/es/common.json | 8 +++++ client/ui/i18n/locales/fr/common.json | 8 +++++ client/ui/i18n/locales/hu/common.json | 8 +++++ client/ui/i18n/locales/it/common.json | 8 +++++ client/ui/i18n/locales/ja/common.json | 8 +++++ client/ui/i18n/locales/pt/common.json | 8 +++++ client/ui/i18n/locales/ru/common.json | 8 +++++ client/ui/i18n/locales/zh-CN/common.json | 8 +++++ client/ui/main.go | 1 + client/ui/preferences/store.go | 24 +++++++++++++ client/ui/preferences/store_test.go | 36 +++++++++++++++++++ client/ui/services/preferences.go | 4 +++ client/ui/tray.go | 16 +++++---- 17 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts diff --git a/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts new file mode 100644 index 000000000..eb68dd997 --- /dev/null +++ b/client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts @@ -0,0 +1,35 @@ +import { useCallback, useEffect, useState } from "react"; +import { Preferences } from "@bindings/services"; + +export const useKeepConnectedOnQuit = () => { + const [keepConnected, setKeepConnected] = useState(null); + + useEffect(() => { + let cancelled = false; + Preferences.Get() + .then((prefs) => { + if (cancelled) return; + setKeepConnected(prefs?.keepConnectedOnQuit ?? false); + }) + .catch((err: unknown) => { + if (cancelled) return; + console.warn("[useKeepConnectedOnQuit] load preferences failed", err); + setKeepConnected(false); + }); + return () => { + cancelled = true; + }; + }, []); + + const setKeepConnectedOnQuit = useCallback(async (keep: boolean) => { + setKeepConnected(keep); + try { + await Preferences.SetKeepConnectedOnQuit(keep); + } catch (err: unknown) { + setKeepConnected(!keep); + console.error("[useKeepConnectedOnQuit] SetKeepConnectedOnQuit failed", err); + } + }, []); + + return { keepConnected, setKeepConnectedOnQuit }; +}; diff --git a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx index 05d40e15c..71720aebe 100644 --- a/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsGeneral.tsx @@ -11,6 +11,7 @@ import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx" import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts"; import { LanguagePicker } from "@/components/LanguagePicker.tsx"; import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; +import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts"; export function SettingsGeneral() { const { t } = useTranslation(); @@ -19,6 +20,7 @@ export function SettingsGeneral() { const { mode, setMode, setUrl, displayUrl, showError, canSave, save, checking, unreachable } = useManagementUrl(); const { mdm, features } = useRestrictions(); + const { keepConnected, setKeepConnectedOnQuit } = useKeepConnectedOnQuit(); const inputRef = useRef(null); const managementUrlId = useId(); @@ -57,6 +59,15 @@ export function SettingsGeneral() { helpText={t("settings.general.autostart.help")} /> )} + { + void setKeepConnectedOnQuit(v); + }} + loading={keepConnected === null} + label={t("settings.general.keepConnectedOnQuit.label")} + helpText={t("settings.general.keepConnectedOnQuit.help")} + /> {!mdm.managementURL && !features.disableUpdateSettings && ( diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e91e8d88..d02589591 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Ändern des Autostarts fehlgeschlagen" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Nach dem Beenden verbunden bleiben", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Die Verbindung bleibt im Hintergrund bestehen, nachdem Sie NetBird schließen. Sie endet erst, wenn Sie sie selbst trennen.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Anzeigesprache" }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index b668146e8..9769e772f 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -735,6 +735,14 @@ "message": "Autostart Change Failed", "description": "Error-dialog title when changing the autostart setting fails." }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Stay Connected After Quitting", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "The connection stays up in the background after you close NetBird. It only stops when you disconnect it yourself.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Display Language", "description": "Label for the display-language picker." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index c036e4f75..3420b612b 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Error al cambiar el inicio automático" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Permanecer conectado al salir", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La conexión sigue activa en segundo plano después de cerrar NetBird. Solo se detiene cuando la desconectas tú.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Idioma de la interfaz" }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index c6b91fb25..a83f85c12 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Échec de la modification du démarrage automatique" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Rester connecté après la fermeture", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La connexion reste active en arrière-plan après la fermeture de NetBird. Elle ne s'arrête que si vous la coupez vous-même.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Langue d’affichage" }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index dd5a1af6c..b291f7a01 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Az automatikus indítás módosítása sikertelen" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Kapcsolat megtartása kilépéskor", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "A kapcsolat a háttérben megmarad, miután bezárod a NetBirdöt. Csak akkor szakad meg, ha te magad bontod.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Megjelenítési nyelv" }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 7a2eb610c..a68a8b32b 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Modifica avvio automatico non riuscita" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Resta connesso dopo la chiusura", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "La connessione resta attiva in background dopo la chiusura di NetBird. Si interrompe solo quando la disconnetti tu.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Lingua dell'interfaccia" }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 326c825bf..10cf7598d 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "自動起動の変更に失敗しました" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "終了後も接続を維持", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "NetBird を閉じたあとも接続はバックグラウンドで維持されます。自分で切断したときにだけ停止します。", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "表示言語" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 37b02d5a8..ef1bfd372 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Falha ao alterar o início automático" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Permanecer conectado ao sair", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "A conexão continua ativa em segundo plano depois de fechar o NetBird. Ela só para quando você mesmo a desconecta.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Idioma de exibição" }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index b9ae59df2..a876387f4 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "Не удалось изменить автозапуск" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "Оставаться подключённым после выхода", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "Соединение остаётся активным в фоне после закрытия NetBird. Оно прервётся, только когда вы отключите его сами.", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "Язык интерфейса" }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 2141a770d..542b2b045 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -551,6 +551,14 @@ "settings.general.autostart.errorTitle": { "message": "更改自启动设置失败" }, + "settings.general.keepConnectedOnQuit.label": { + "message": "退出后保持连接", + "description": "Toggle label: keep the VPN connection up after quitting the UI." + }, + "settings.general.keepConnectedOnQuit.help": { + "message": "关闭 NetBird 后,连接会在后台保持。只有你自己断开时才会停止。", + "description": "Helper text for the stay-connected-after-quitting toggle." + }, "settings.general.language.label": { "message": "显示语言" }, diff --git a/client/ui/main.go b/client/ui/main.go index e2d172e5b..5f740f5ec 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -180,6 +180,7 @@ func main() { WindowManager: windowManager, Session: authSession, Localizer: localizer, + Preferences: prefStore, }) listenForShowSignal(context.Background(), tray) diff --git a/client/ui/preferences/store.go b/client/ui/preferences/store.go index 49acb7917..3b677016f 100644 --- a/client/ui/preferences/store.go +++ b/client/ui/preferences/store.go @@ -58,6 +58,10 @@ type UIPreferences struct { // decision has run for this OS user. It only ever transitions to true // and is never reset, so the default-on flow runs at most once, ever. AutostartInitialized bool `json:"autostartInitialized"` + // KeepConnectedOnQuit leaves the daemon connected when the GUI quits. + // Its false zero value preserves the historical disconnect-on-quit + // behaviour for preference files written before the field existed. + KeepConnectedOnQuit bool `json:"keepConnectedOnQuit"` } // LanguageValidator rejects SetLanguage inputs with no shipped bundle. @@ -183,6 +187,26 @@ func (s *Store) SetAutostartInitialized(done bool) error { return nil } +// SetKeepConnectedOnQuit persists the disconnect-on-quit opt-out. No-op if unchanged. +func (s *Store) SetKeepConnectedOnQuit(keep bool) error { + s.mu.Lock() + if s.current.KeepConnectedOnQuit == keep { + s.mu.Unlock() + return nil + } + next := s.current + next.KeepConnectedOnQuit = keep + if err := s.persistLocked(next); err != nil { + s.mu.Unlock() + return fmt.Errorf("persist preferences: %w", err) + } + s.current = next + s.mu.Unlock() + + s.broadcast(next) + return nil +} + // SetLanguage validates, persists, and broadcasts. No-op if unchanged. func (s *Store) SetLanguage(lang i18n.LanguageCode) error { if lang == "" { diff --git a/client/ui/preferences/store_test.go b/client/ui/preferences/store_test.go index 6384fddb8..3e1cb3107 100644 --- a/client/ui/preferences/store_test.go +++ b/client/ui/preferences/store_test.go @@ -238,6 +238,42 @@ func TestStore_SetAutostartInitializedPersistsAcrossReload(t *testing.T) { assert.True(t, reloaded.Get().AutostartInitialized, "marker must survive a reload from disk") } +func TestStore_SetKeepConnectedOnQuitPersistsAcrossReload(t *testing.T) { + withTempConfigDir(t) + emitter := &recordingEmitter{} + s, err := NewStore(nil, emitter) + require.NoError(t, err) + + assert.False(t, s.Get().KeepConnectedOnQuit, "quitting must disconnect by default") + + require.NoError(t, s.SetKeepConnectedOnQuit(true)) + assert.True(t, s.Get().KeepConnectedOnQuit, "Get should reflect the persisted opt-out") + require.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "first write should broadcast") + + require.NoError(t, s.SetKeepConnectedOnQuit(true)) + assert.Len(t, emitter.calledWith(EventPreferencesChanged), 1, "idempotent write should not broadcast again") + + reloaded, err := NewStore(nil, nil) + require.NoError(t, err) + assert.True(t, reloaded.Get().KeepConnectedOnQuit, "opt-out must survive a reload from disk") +} + +func TestStore_KeepConnectedOnQuitDefaultsFalseForPreExistingFile(t *testing.T) { + withTempConfigDir(t) + + // A preferences file written before the field existed must keep the + // historical disconnect-on-quit behaviour rather than silently opting out. + path, err := preferencesPath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(`{"language":"en","viewMode":"default"}`), 0o600)) + + s, err := NewStore(nil, nil) + require.NoError(t, err) + assert.False(t, s.Get().KeepConnectedOnQuit, "a file predating the field must not opt out of disconnect-on-quit") + assert.True(t, s.ExistedAtLoad(), "the pre-existing file must be seen on disk") +} + func TestStore_ExistedAtLoad(t *testing.T) { withTempConfigDir(t) diff --git a/client/ui/services/preferences.go b/client/ui/services/preferences.go index dae086de8..77faa4ef6 100644 --- a/client/ui/services/preferences.go +++ b/client/ui/services/preferences.go @@ -34,3 +34,7 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error { return s.store.SetOnboardingCompleted(done) } + +func (s *Preferences) SetKeepConnectedOnQuit(_ context.Context, keep bool) error { + return s.store.SetKeepConnectedOnQuit(keep) +} diff --git a/client/ui/tray.go b/client/ui/tray.go index 3093c693b..148dd50b3 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -16,6 +16,7 @@ import ( "github.com/netbirdio/netbird/client/ui/authsession" "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/preferences" "github.com/netbirdio/netbird/client/ui/services" "github.com/netbirdio/netbird/version" ) @@ -50,8 +51,9 @@ type TrayServices struct { WindowManager *services.WindowManager // Session is bound to authsession directly because the services wrapper // only re-exposes the React subset. - Session *authsession.Session - Localizer *Localizer + Session *authsession.Session + Localizer *Localizer + Preferences *preferences.Store } type Tray struct { @@ -461,10 +463,12 @@ func (t *Tray) handleQuit() { t.profileMu.Unlock() t.svc.DaemonFeed.CancelProfileSwitch() - ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) - defer cancel() - if err := t.svc.Connection.Down(ctx); err != nil { - log.Errorf("disconnect on quit: %v", err) + if t.svc.Preferences == nil || !t.svc.Preferences.Get().KeepConnectedOnQuit { + ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout) + defer cancel() + if err := t.svc.Connection.Down(ctx); err != nil { + log.Errorf("disconnect on quit: %v", err) + } } t.app.Quit() }