mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-30 19:41:30 +02:00
[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.
This commit is contained in:
35
client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
Normal file
35
client/ui/frontend/src/hooks/useKeepConnectedOnQuit.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Preferences } from "@bindings/services";
|
||||
|
||||
export const useKeepConnectedOnQuit = () => {
|
||||
const [keepConnected, setKeepConnected] = useState<boolean | null>(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 };
|
||||
};
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const managementUrlId = useId();
|
||||
@@ -57,6 +59,15 @@ export function SettingsGeneral() {
|
||||
helpText={t("settings.general.autostart.help")}
|
||||
/>
|
||||
)}
|
||||
<FancyToggleSwitch
|
||||
value={keepConnected ?? false}
|
||||
onChange={(v) => {
|
||||
void setKeepConnectedOnQuit(v);
|
||||
}}
|
||||
loading={keepConnected === null}
|
||||
label={t("settings.general.keepConnectedOnQuit.label")}
|
||||
helpText={t("settings.general.keepConnectedOnQuit.help")}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
{!mdm.managementURL && !features.disableUpdateSettings && (
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "表示言語"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Язык интерфейса"
|
||||
},
|
||||
|
||||
@@ -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": "显示语言"
|
||||
},
|
||||
|
||||
@@ -180,6 +180,7 @@ func main() {
|
||||
WindowManager: windowManager,
|
||||
Session: authSession,
|
||||
Localizer: localizer,
|
||||
Preferences: prefStore,
|
||||
})
|
||||
listenForShowSignal(context.Background(), tray)
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user