Compare commits

...

6 Commits

Author SHA1 Message Date
Zoltan Papp
e2a4fdfe07 [client] Create GUI windows on demand and destroy them on close
The main and Settings windows were created at startup and kept alive hidden on
close, so an idle tray held two webview processes for surfaces the user may
never open. Both are now built on first show and destroyed on close, which
takes the idle footprint on macOS from ~160 MB to ~74 MB.

The WindowManager owns creation: it rebuilds the main window on the next show
and hands out live pointers, since a stored one goes stale. Every show is
deferred until the frontend reports it has rendered, so a freshly created
window is never on screen empty, with a timeout so a frontend that never
reports cannot strand a window hidden.
2026-08-07 14:42:18 +02:00
Zoltan Papp
2ce6323602 [client] Update the wails fork reference to the integration branch head (#7087) 2026-08-07 09:57:52 +02:00
Viktor Liu
9a05a1c698 [client] Reword the firewalld package comment (#7081) 2026-08-06 20:36:13 +02:00
Viktor Liu
5dd914782a [client] Stop the macOS UI on pkg upgrade (#7079) 2026-08-06 16:50:17 +02:00
Ben
8c19b7a30a [infrastructure] Generate a session cookie encryption key on fresh self-hosted installs (#7056)
The community and enterprise bootstrap scripts now generate a dedicated
`server.auth.sessionCookieEncryptionKey` from 32 random bytes for fresh
installations.
The key is Base64-encoded, persisted independently from the datastore
encryption key, and reused from the generated configuration after
restarts.

The community script now also creates `config.yaml` with mode `0600`
before writing it, matching the existing enterprise behavior.

The server already supports the session cookie encryption key, but the
bootstrap scripts left it unset.
This adds defense-in-depth for newly generated deployments while
preserving the existing server-side nonce validation.
Because this only changes newly generated configuration, existing
installations and sessions are unchanged.

A focused regression test checks key presence, decoded length,
separation from the datastore key, YAML placement, and file mode for
both scripts.
It is included in the infrastructure workflow.
2026-08-06 13:55:43 +02:00
Zoltan Papp
1e2a7aa571 [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.
2026-08-06 12:56:27 +02:00
29 changed files with 557 additions and 88 deletions

View File

@@ -257,6 +257,15 @@ jobs:
with:
persist-credentials: false
- name: Verify fresh-install session cookie key hardening
run: |
grep -Fxq ' SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)' infrastructure_files/getting-started.sh
grep -Fxq ' sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"' infrastructure_files/getting-started.sh
grep -Fxq ' install -m 600 /dev/null config.yaml' infrastructure_files/getting-started.sh
grep -Fxq ' openssl rand -base64 32' infrastructure_files/getting-started-enterprise.sh
grep -Fxq ' NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)' infrastructure_files/getting-started-enterprise.sh
grep -Fxq ' sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"' infrastructure_files/getting-started-enterprise.sh
- name: Verify Dex retirement notice
run: |
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then

View File

@@ -2,8 +2,8 @@
// its wg interface into firewalld's "trusted" zone. This is required because
// firewalld's nftables chains are created with NFT_CHAIN_OWNER on recent
// versions, which returns EPERM to any other process that tries to insert
// rules into them. The workaround mirrors what Tailscale does: let firewalld
// itself add the accept rules to its own chains by trusting the interface.
// rules into them. Trusting the interface makes firewalld itself add the
// accept rules to its own chains instead.
package firewalld
// TrustedZone is the firewalld zone name used for interfaces whose traffic

View File

@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
import { Events } from "@wailsio/runtime";
import { useStatus } from "@/contexts/StatusContext.tsx";
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
export const ReadySignal = () => {
const { isReady } = useStatus();
const sent = useRef(false);
useEffect(() => {
if (!isReady || sent.current) return;
sent.current = true;
void Events.Emit(EVENT_WINDOW_PAINTED);
}, [isReady]);
return null;
};

View 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 };
};

View File

@@ -5,6 +5,7 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
import { ReadySignal } from "@/components/ReadySignal.tsx";
export const AppLayout = () => {
return (
@@ -16,6 +17,7 @@ export const AppLayout = () => {
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
<ReadySignal />
</ClientVersionProvider>
</DebugBundleProvider>
</RestrictionsProvider>

View File

@@ -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 && (

View File

@@ -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"
},

View File

@@ -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."

View File

@@ -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"
},

View File

@@ -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 daffichage"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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": "表示言語"
},

View File

@@ -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"
},

View File

@@ -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": "Язык интерфейса"
},

View File

@@ -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": "显示语言"
},

View File

@@ -139,13 +139,11 @@ func main() {
prefStore: prefStore,
})
window := newMainWindow(app, prefStore)
// Settings is created eagerly (hidden) so the first gear click paints
// instantly and React keeps per-tab state across reopens. The other
// auxiliary windows stay lazy + destroy-on-close so Wails's macOS
// dock-reopen handler can't resurrect them.
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow)
windowManager.SetMainFactory(func() *application.WebviewWindow {
return newMainWindow(app, prefStore, windowManager)
})
registerDockReopenHook(app, windowManager)
// Minimal WMs (XEmbed-tray path) neither center small windows nor restore
// position across hide -> show, dropping them top-left. Gate Go-side
// re-centering on that environment; nil leaves placement to the WM on full
@@ -168,7 +166,7 @@ func main() {
// RegisterStatusNotifierItem hits a watcher we control.
startStatusNotifierWatcher()
tray = NewTray(app, window, TrayServices{
tray = NewTray(app, nil, TrayServices{
Connection: connection,
Settings: settings,
Profiles: profiles,
@@ -180,6 +178,7 @@ func main() {
WindowManager: windowManager,
Session: authSession,
Localizer: localizer,
Preferences: prefStore,
})
listenForShowSignal(context.Background(), tray)
@@ -337,9 +336,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(s.compat))
}
// newMainWindow creates the hidden main window, sized to the user's last view
// mode, and installs the hide-on-close and macOS dock-reopen hooks.
func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow {
func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager) *application.WebviewWindow {
// Width matches the last view mode so Advanced-mode users don't see the
// window pop from 380px to 900px on launch. Height is mode-agnostic.
initialWidth := 380
@@ -367,29 +364,25 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
},
})
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
if services.ShuttingDown() {
return
}
e.Cancel()
window.Hide()
wm.ForgetMain()
})
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
window.Show()
window.Focus()
})
}
return window
}
func registerDockReopenHook(app *application.App, wm *services.WindowManager) {
if runtime.GOOS != "darwin" {
return
}
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
wm.ShowMain()
})
}

View File

@@ -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 == "" {

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -29,6 +30,10 @@ const EventBrowserLoginCancel = "browser-login:cancel"
// EventSettingsOpen tells the mounted settings window which tab to show.
const EventSettingsOpen = "netbird:settings:open"
const EventWindowPainted = "netbird:window-painted"
const paintedFallback = 2 * time.Second
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
// WindowHeight is shared by the main and Settings windows.
@@ -94,9 +99,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
}
}
// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
type WindowManager struct {
app *application.App
mainWindow *application.WebviewWindow
@@ -112,15 +114,29 @@ type WindowManager struct {
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
hiddenForLogin []application.Window
mu sync.Mutex
newMain func() *application.WebviewWindow
ready map[uint]bool
showPending map[uint]bool
pendingTab map[uint]string
fallbackTimers map[uint]*time.Timer
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
recenterOnShow func() bool
}
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
// Settings window is created here (hidden) so the first OpenSettings is instant.
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
s := &WindowManager{
app: app,
mainWindow: mainWindow,
translator: translator,
prefs: prefs,
linuxIcon: linuxIcon,
ready: map[uint]bool{},
showPending: map[uint]bool{},
pendingTab: map[uint]string{},
fallbackTimers: map[uint]*time.Timer{},
}
s.watchPainted()
// Re-title live windows on language flip. Wired internally so the binding generator
// doesn't try to expose the interface param.
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
@@ -136,7 +152,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
}
}()
}
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
return s
}
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: s.title("window.title.settings"),
Width: 900,
@@ -150,18 +170,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
URL: "/#/settings",
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
Linux: LinuxAppearanceOptions(linuxIcon),
Linux: LinuxAppearanceOptions(s.linuxIcon),
})
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if ShuttingDown() {
return
}
e.Cancel()
s.app.Event.Emit(EventSettingsOpen, "general")
s.settings.Hide()
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.settings = nil
s.forgetWindowLocked(w)
s.mu.Unlock()
})
return s
return w
}
// OpenSettings shows the settings window on tab (empty → General), switching tab via
@@ -171,11 +188,23 @@ func (s *WindowManager) OpenSettings(tab string) {
if target == "" {
target = "general"
}
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.settings)
s.mu.Lock()
fresh := s.settings == nil
if fresh {
s.settings = s.newSettingsWindow()
s.armReady(s.settings)
}
w := s.settings
if fresh {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
if !fresh {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
}
// OpenBrowserLogin shows the SSO popup, creating it on first use.
@@ -440,13 +469,150 @@ func (s *WindowManager) OpenMain() {
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
func (s *WindowManager) ShowMain() {
if s.mainWindow == nil {
s.showWhenReady(s.MainWindow())
}
func (s *WindowManager) MainWindow() *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
if s.mainWindow == nil && s.newMain != nil {
s.mainWindow = s.newMain()
s.armReady(s.mainWindow)
}
return s.mainWindow
}
func (s *WindowManager) armReady(w *application.WebviewWindow) {
if w == nil {
return
}
s.mainWindow.Show()
s.mainWindow.Focus()
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.mainWindow)
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
timer := time.AfterFunc(paintedFallback, func() {
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
s.markReady(w)
})
s.mu.Lock()
s.fallbackTimers[w.ID()] = timer
s.mu.Unlock()
})
}
func (s *WindowManager) watchPainted() {
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
if w := s.windowByName(e.Sender); w != nil {
s.markReady(w)
}
})
}
func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
if w == nil {
return
}
id := w.ID()
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
}
delete(s.fallbackTimers, id)
delete(s.ready, id)
delete(s.showPending, id)
delete(s.pendingTab, id)
kept := s.hiddenForLogin[:0]
for _, hidden := range s.hiddenForLogin {
if hidden != application.Window(w) {
kept = append(kept, hidden)
}
}
s.hiddenForLogin = kept
}
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
switch name {
case "main":
return s.mainWindow
case "settings":
return s.settings
default:
return nil
}
}
func (s *WindowManager) markReady(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
already := s.ready[id]
s.ready[id] = true
wanted := s.showPending[id]
tab, hasTab := s.pendingTab[id]
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
delete(s.fallbackTimers, id)
}
delete(s.showPending, id)
delete(s.pendingTab, id)
s.mu.Unlock()
if already {
return
}
if hasTab {
s.app.Event.Emit(EventSettingsOpen, tab)
}
if wanted {
s.showNow(w)
}
}
func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
if w == nil {
return
}
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
s.showPending[id] = true
}
s.mu.Unlock()
if ready {
s.showNow(w)
}
}
func (s *WindowManager) showNow(w *application.WebviewWindow) {
w.Show()
w.Focus()
s.centerWhenReady(w)
}
func (s *WindowManager) ShowMainAt(url string) {
w := s.MainWindow()
if w == nil {
return
}
w.SetURL(url)
s.showWhenReady(w)
}
func (s *WindowManager) SetMainFactory(f func() *application.WebviewWindow) {
s.mu.Lock()
defer s.mu.Unlock()
s.newMain = f
}
func (s *WindowManager) ForgetMain() {
s.mu.Lock()
defer s.mu.Unlock()
s.forgetWindowLocked(s.mainWindow)
s.mainWindow = nil
}
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).

View File

@@ -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 {
@@ -172,7 +174,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// in the right locale — no English flash then re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection before the first paint so the initial icon
// matches the panel's light/dark scheme (Linux only).
@@ -239,9 +241,6 @@ func (t *Tray) ShowWindow() {
w.Focus()
return
}
if t.window == nil {
return
}
// Route through WindowManager so the main window is centered on first
// show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in
// the top-left corner.
@@ -249,8 +248,40 @@ func (t *Tray) ShowWindow() {
t.svc.WindowManager.ShowMain()
return
}
t.window.Show()
t.window.Focus()
if w := t.mainWindow(); w != nil {
w.Show()
w.Focus()
}
}
func (t *Tray) mainWindow() *application.WebviewWindow {
if t.svc.WindowManager == nil {
return t.window
}
return t.svc.WindowManager.MainWindow()
}
func (t *Tray) showMainAt(url string) {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMainAt(url)
return
}
if w := t.mainWindow(); w != nil {
w.SetURL(url)
w.Show()
w.Focus()
}
}
func (t *Tray) showMain() {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
if w := t.mainWindow(); w != nil {
w.Show()
w.Focus()
}
}
// applyLanguage re-renders every translated surface in the Localizer's current
@@ -461,10 +492,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()
}

View File

@@ -30,10 +30,7 @@ const (
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.Show()
t.window.Focus()
}
t.showMain()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -307,6 +304,7 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
t.showMain()
t.app.Event.Emit(services.EventTriggerLogin)
return
}

View File

@@ -19,7 +19,7 @@ import (
// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray.
type trayUpdater struct {
app *application.App
window *application.WebviewWindow
showMainAt func(url string)
update *services.Update
notifier *Notifier
loc *Localizer
@@ -36,10 +36,10 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
showMainAt: showMainAt,
update: update,
notifier: notifier,
loc: loc,
@@ -185,14 +185,12 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
// openProgressWindow points the main window at the /update progress page and
// brings it forward.
func (u *trayUpdater) openProgressWindow(version string) {
if u.window == nil {
if u.showMainAt == nil {
return
}
url := "/#/update"
if version != "" {
url += "?version=" + version
}
u.window.SetURL(url)
u.window.Show()
u.window.Focus()
u.showMainAt(url)
}

2
go.mod
View File

@@ -340,4 +340,4 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701

4
go.sum
View File

@@ -490,8 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4 h1:UKztc3QjWvzU5DZk+uYaOWN0x62NSe/pkxuPvzqZIy4=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260803205919-ad21e92381f4/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701 h1:QL9nupfRom0L9jcY7N9l/Bc6QK2PtC6pHzC+ftpTqpw=
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260807055527-fc03f984d701/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=

View File

@@ -262,6 +262,7 @@ init_environment() {
POSTGRES_DB="netbird"
POSTGRES_PASSWORD=$(rand_secret)
NETBIRD_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY=$(rand_b64_key)
NETBIRD_RELAY_AUTH_SECRET=$(rand_secret)
POSTGRES_DSN="host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable TimeZone=UTC"
@@ -696,6 +697,7 @@ server:
issuer: "https://${NETBIRD_DOMAIN}/oauth2"
localAuthDisabled: false
signKeyRefreshEnabled: false
sessionCookieEncryptionKey: "${NETBIRD_SESSION_COOKIE_ENCRYPTION_KEY}"
dashboardRedirectURIs:
- "https://${NETBIRD_DOMAIN}/nb-auth"
- "https://${NETBIRD_DOMAIN}/nb-silent-auth"

View File

@@ -348,6 +348,7 @@ initialize_default_values() {
NETBIRD_RELAY_AUTH_SECRET=$(openssl rand -base64 32 | sed "$SED_STRIP_PADDING")
# Note: DataStoreEncryptionKey must keep base64 padding (=) for Go's base64.StdEncoding
DATASTORE_ENCRYPTION_KEY=$(openssl rand -base64 32)
SESSION_COOKIE_ENCRYPTION_KEY=$(openssl rand -base64 32)
NETBIRD_STUN_PORT=3478
# Docker images
@@ -527,7 +528,8 @@ generate_configuration_files() {
# Common files for all configurations
render_dashboard_env > dashboard.env
render_combined_yaml > config.yaml
install -m 600 /dev/null config.yaml
render_combined_yaml >> config.yaml
return 0
}
@@ -911,6 +913,7 @@ server:
auth:
issuer: "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/oauth2"
signKeyRefreshEnabled: true
sessionCookieEncryptionKey: "$SESSION_COOKIE_ENCRYPTION_KEY"
dashboardRedirectURIs:
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-auth"
- "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN/nb-silent-auth"

View File

@@ -4,9 +4,67 @@ set -x
LOG_FILE=/var/log/netbird/client_pre_install.log
AGENT=/usr/local/bin/netbird
UI_PROCESS=netbird-ui
mkdir -p /var/log/netbird/
# wait_for_ui_exit polls for up to $1 seconds, returning 0 as soon as no UI
# process is left and 1 if one is still running when the time is up.
wait_for_ui_exit() {
waited=0
while [ "$waited" -lt "$1" ]; do
pgrep -x "$UI_PROCESS" > /dev/null 2>&1 || return 0
sleep 1
waited=$((waited + 1))
done
return 1
}
# request_ui_quit asks the UI to quit from inside the console user's session and
# reports whether the request could be sent at all. The installer runs as root
# outside that session, so a quit Apple event sent straight from here always
# fails with -600.
request_ui_quit() {
console_user=$(stat -f%Su /dev/console 2>/dev/null)
case "$console_user" in
""|root|loginwindow|_mbsetupuser)
echo "No active GUI user session (console user: '${console_user:-none}'); skipping the quit request."
return 1
;;
esac
uid=$(id -u "$console_user" 2>/dev/null)
if [ -z "$uid" ]; then
echo "Could not resolve uid for console user '$console_user'; skipping the quit request."
return 1
fi
echo "Asking the NetBird UI to quit as console user $console_user (uid $uid)."
launchctl asuser "$uid" sudo -u "$console_user" -H osascript -e 'quit app "NetBird"' || true
}
# quit_ui stops a running UI so the app bundle can be replaced underneath it. A
# UI process that survives the install keeps serving the old binary until it is
# quit by hand, so anything still running once the quit request is out of the
# way is signalled. Waiting for a graceful exit only makes sense when a quit
# request was actually sent.
quit_ui() {
if request_ui_quit && wait_for_ui_exit 10; then
return 0
fi
pgrep -x "$UI_PROCESS" > /dev/null 2>&1 || return 0
echo "NetBird UI still running; terminating it."
pkill -x "$UI_PROCESS" || true
if wait_for_ui_exit 3; then
return 0
fi
echo "NetBird UI ignored SIGTERM; killing it."
pkill -KILL -x "$UI_PROCESS" || true
}
{
# check if it was installed with brew
brew list --formula | grep netbird
@@ -15,10 +73,9 @@ mkdir -p /var/log/netbird/
echo "NetBird has been installed with Brew. Please use Brew to update the package."
exit 1
fi
osascript -e 'quit app "Netbird"' || true
quit_ui
$AGENT service stop || true
echo "Preinstall complete"
exit 0 # all good
} &> $LOG_FILE