From db8c9a0e3072ca421d906d53737ca3c557abe1df Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Fri, 15 May 2026 10:14:01 +0200 Subject: [PATCH] add window manager --- .../netbird/client/ui/services/index.ts | 4 +- client/ui/frontend/src/app.tsx | 4 +- .../src/layouts/ConnectionStatusSwitch.tsx | 83 ++++++++- client/ui/frontend/src/layouts/Header.tsx | 4 +- client/ui/frontend/src/pages/BrowserLogin.tsx | 54 ++++++ client/ui/frontend/src/pages/Login.tsx | 159 ------------------ client/ui/main.go | 21 +-- client/ui/services/window_manager.go | 132 +++++++++++++++ client/ui/services/windows.go | 66 -------- 9 files changed, 275 insertions(+), 252 deletions(-) create mode 100644 client/ui/frontend/src/pages/BrowserLogin.tsx delete mode 100644 client/ui/frontend/src/pages/Login.tsx create mode 100644 client/ui/services/window_manager.go delete mode 100644 client/ui/services/windows.go diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts index d1ed09bda..a50cb9322 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts @@ -10,7 +10,7 @@ import * as ProfileSwitcher from "./profileswitcher.js"; import * as Profiles from "./profiles.js"; import * as Settings from "./settings.js"; import * as Update from "./update.js"; -import * as Windows from "./windows.js"; +import * as WindowManager from "./windowmanager.js"; export { Connection, Debug, @@ -21,7 +21,7 @@ export { Profiles, Settings, Update, - Windows + WindowManager }; export { diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 7377b19d3..5e159e9c8 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -12,7 +12,7 @@ import { Settings } from "@/modules/settings/Settings.tsx"; import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { welcome } from "@/lib/welcome"; -import Login from "@/pages/Login.tsx"; +import BrowserLogin from "@/pages/BrowserLogin.tsx"; welcome(); @@ -22,7 +22,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( } /> - } /> + } /> } /> } /> }> diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx index 2e733bd77..d4868f813 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Dialogs } from "@wailsio/runtime"; -import { Connection } from "@bindings/services"; +import { Dialogs, Events } from "@wailsio/runtime"; +import { Connection, WindowManager } from "@bindings/services"; import { ConnectionState } from "@/components/NetBirdConnectToggle.tsx"; import { ToggleSwitch } from "@/components/ToggleSwitch.tsx"; import { useStatus } from "@/hooks/useStatus"; @@ -16,13 +15,87 @@ const STATUS_LABEL: Record = { [ConnectionState.Disconnecting]: "Disconnecting...", }; +const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel"; + const errorMessage = (e: unknown) => e instanceof Error ? e.message : String(e); +// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin +// popup window is the only login UI; errors surface as a native +// Dialogs.Error. Concurrent calls are dropped via the inFlight guard. +let loginInFlight = false; +async function startLogin(): Promise { + if (loginInFlight) return; + loginInFlight = true; + + let cancelled = false; + let offCancel: (() => void) | undefined; + + try { + const result = await Connection.Login({ + profileName: "", + username: "", + managementUrl: "", + setupKey: "", + preSharedKey: "", + hostname: "", + hint: "", + }); + + if (result.needsSsoLogin) { + const uri = result.verificationUriComplete || result.verificationUri; + if (uri) { + Connection.OpenURL(uri).catch(console.error); + WindowManager.OpenBrowserLogin(uri).catch(console.error); + } + + const cancelPromise = new Promise((resolve) => { + offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { + cancelled = true; + resolve(); + }); + }); + + const waitPromise = Connection.WaitSSOLogin({ + userCode: result.userCode, + hostname: "", + }); + + try { + await Promise.race([waitPromise, cancelPromise]); + } finally { + WindowManager.CloseBrowserLogin().catch(console.error); + } + + if (cancelled) { + // Tell the daemon to drop the in-flight WaitSSOLogin so a + // future Login starts fresh; see services/connection.go:74. + try { + await Connection.Down(); + } catch (e) { + console.error(e); + } + return; + } + } + + await Connection.Up({ profileName: "", username: "" }); + } catch (e) { + WindowManager.CloseBrowserLogin().catch(console.error); + if (cancelled) return; + await Dialogs.Error({ + Title: "Login Failed", + Message: errorMessage(e), + }); + } finally { + offCancel?.(); + loginInFlight = false; + } +} + export const ConnectionStatusSwitch = () => { const { status, refresh } = useStatus(); const { activeProfile, username } = useProfile(); - const navigate = useNavigate(); const daemonState = status?.status ?? "Idle"; const needsLogin = @@ -89,7 +162,7 @@ export const ConnectionStatusSwitch = () => { const handleSwitch = (next: boolean) => { if (unreachable || action !== null) return; if (needsLogin) { - navigate("/login"); + void startLogin().finally(() => refresh()); return; } if (next && connState === ConnectionState.Disconnected) { diff --git a/client/ui/frontend/src/layouts/Header.tsx b/client/ui/frontend/src/layouts/Header.tsx index 502d143ee..3e91aca9b 100644 --- a/client/ui/frontend/src/layouts/Header.tsx +++ b/client/ui/frontend/src/layouts/Header.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import { PanelRightCloseIcon, PanelRightOpenIcon, SettingsIcon } from "lucide-react"; import { Window } from "@wailsio/runtime"; -import { Windows as WindowsSvc } from "@bindings/services"; +import { WindowManager } from "@bindings/services"; import { ProfileSelector } from "@/components/ProfileSelector.tsx"; import { IconButton } from "@/components/IconButton.tsx"; import { useAppearance } from "@/modules/appearance/AppearanceContext.tsx"; @@ -47,7 +47,7 @@ export const Header = () => { }; const openSettings = () => { - void WindowsSvc.OpenSettings().catch(() => {}); + void WindowManager.OpenSettings().catch(() => {}); }; return ( diff --git a/client/ui/frontend/src/pages/BrowserLogin.tsx b/client/ui/frontend/src/pages/BrowserLogin.tsx new file mode 100644 index 000000000..81f4da74a --- /dev/null +++ b/client/ui/frontend/src/pages/BrowserLogin.tsx @@ -0,0 +1,54 @@ +import { useCallback } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Events } from "@wailsio/runtime"; +import { Loader2 } from "lucide-react"; +import { Connection } from "@bindings/services"; +import { Button } from "../components/Button"; +import netbirdFull from "@/assets/logos/netbird-full.svg"; + +const EVENT_CANCEL = "browser-login:cancel"; + +export default function BrowserLogin() { + const [params] = useSearchParams(); + const uri = params.get("uri") ?? ""; + + const tryAgain = useCallback(() => { + if (!uri) return; + Connection.OpenURL(uri).catch(console.error); + }, [uri]); + + const cancel = useCallback(() => { + void Events.Emit(EVENT_CANCEL); + }, []); + + return ( +
+ NetBird +

+ Continue in your browser to complete the login +

+

+ Please complete the account authentication process in the browser tab + and continue from there. +

+
+ + Waiting for sign-in… +
+

+ Not seeing the browser tab?{" "} + +

+ +
+ ); +} diff --git a/client/ui/frontend/src/pages/Login.tsx b/client/ui/frontend/src/pages/Login.tsx deleted file mode 100644 index 4aaca879a..000000000 --- a/client/ui/frontend/src/pages/Login.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { ExternalLink, Loader2, AlertTriangle, X, RotateCcw } from "lucide-react"; -import { Connection } from "../../bindings/github.com/netbirdio/netbird/client/ui/services"; -import { Button } from "../components/Button"; - -type Phase = "starting" | "browser" | "connecting" | "error"; - -export default function Login() { - const navigate = useNavigate(); - const [phase, setPhase] = useState("starting"); - const [verificationUri, setVerificationUri] = useState(""); - const [errorMsg, setErrorMsg] = useState(""); - // attempt is bumped every time the user asks for a fresh start, which - // re-arms the useEffect below so the daemon's Login RPC is dialed again. - const [attempt, setAttempt] = useState(0); - const cancelledRef = useRef(false); - - useEffect(() => { - cancelledRef.current = false; - setPhase("starting"); - setVerificationUri(""); - setErrorMsg(""); - - (async () => { - try { - const result = await Connection.Login({ - profileName: "", - username: "", - managementUrl: "", - setupKey: "", - preSharedKey: "", - hostname: "", - hint: "", - }); - if (cancelledRef.current) return; - - if (result.needsSsoLogin) { - const uri = result.verificationUriComplete || result.verificationUri; - setVerificationUri(uri); - setPhase("browser"); - if (uri) Connection.OpenURL(uri).catch(console.error); - - await Connection.WaitSSOLogin({ - userCode: result.userCode, - hostname: "", - }); - if (cancelledRef.current) return; - } - - setPhase("connecting"); - await Connection.Up({ profileName: "", username: "" }); - if (cancelledRef.current) return; - - navigate("/", { replace: true }); - } catch (e) { - if (cancelledRef.current) return; - setErrorMsg(String(e)); - setPhase("error"); - } - })(); - - return () => { - cancelledRef.current = true; - }; - }, [navigate, attempt]); - - // restart aborts any in-flight wait by toggling the cancellation flag, - // tells the daemon to drop whatever it's holding (a stale WaitSSOLogin - // can wedge the daemon for a previous UserCode), and then bumps attempt - // so the effect re-runs with a clean slate. - const restart = useCallback(async () => { - cancelledRef.current = true; - try { - await Connection.Down(); - } catch (e) { - console.error(e); - } - setAttempt((n) => n + 1); - }, []); - - // Cancel must also tell the daemon to abandon the in-flight WaitSSOLogin. - // Without Down(), the daemon stays parked on the OAuth flow's UserCode - // forever; subsequent Login calls re-use the cached flow but the user has - // no way out. Down() triggers the daemon's actCancel(), which unblocks - // WaitSSOLogin with a context-canceled error so our promise settles. - const cancel = useCallback(async () => { - cancelledRef.current = true; - try { - await Connection.Down(); - } catch (e) { - console.error(e); - } - navigate("/", { replace: true }); - }, [navigate]); - - if (phase === "error") { - return ( -
- -

Login failed

-

{errorMsg}

-
- - -
-
- ); - } - - if (phase === "browser") { - return ( -
-

Continue in your browser

-

- A browser tab should have opened. Sign in there — this window will - continue automatically once you're done. -

- {verificationUri && ( - - )} -

- {verificationUri} -

-
- - Waiting for sign-in… -
-
- - -
-
- ); - } - - const message = - phase === "connecting" ? "Bringing the connection up…" : "Starting login…"; - return ( -
- -

{message}

- -
- ); -} diff --git a/client/ui/main.go b/client/ui/main.go index 643cda3d0..5a811a9a7 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -152,22 +152,11 @@ func main() { window.Hide() }) - // Pre-create the settings window AFTER the main window so the OS treats - // the main window as the primary one. The settings window stays hidden - // until the user clicks the Settings icon — preloading it here keeps the - // first-open instant. - windows := services.NewWindows(app) - - app.RegisterService(application.NewService(connection)) - app.RegisterService(application.NewService(settings)) - app.RegisterService(application.NewService(services.NewNetworks(conn))) - app.RegisterService(application.NewService(services.NewForwarding(conn))) - app.RegisterService(application.NewService(profiles)) - app.RegisterService(application.NewService(services.NewDebug(conn))) - app.RegisterService(application.NewService(update)) - app.RegisterService(application.NewService(peers)) - app.RegisterService(application.NewService(windows)) - app.RegisterService(application.NewService(notifier)) + // The settings and browser-login windows are created lazily and + // destroyed on close, so they don't linger as hidden windows that + // Wails's macOS dock-reopen handler would pop back up. + windowManager := services.NewWindowManager(app) + app.RegisterService(application.NewService(windowManager)) // Register an in-process StatusNotifierWatcher so the tray works on // minimal WMs (Fluxbox, OpenBox, i3, dwm, vanilla GNOME without the diff --git a/client/ui/services/window_manager.go b/client/ui/services/window_manager.go new file mode 100644 index 000000000..67ae2c0d9 --- /dev/null +++ b/client/ui/services/window_manager.go @@ -0,0 +1,132 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "net/url" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// EventTriggerLogin asks the frontend's startLogin() orchestrator to begin +// an SSO flow. Emitted by the tray (Login menu item, session expired) since +// the tray can't call JS directly. +const EventTriggerLogin = "trigger-login" + +// EventBrowserLoginCancel is emitted by the BrowserLogin popup window when +// the user clicks Cancel or closes the window. startLogin() listens for it +// and tears down the daemon's pending SSO wait. +const EventBrowserLoginCancel = "browser-login:cancel" + +// WindowManager opens auxiliary application windows on demand from the +// frontend. The main window is created up-front in main.go; this service is +// for secondary, on-demand surfaces (Settings, BrowserLogin). +// +// Secondary windows are created on first open and destroyed on close — +// the Wails-recommended singleton pattern (see Multiple Windows docs: +// "Cleanup on close"). Destroying rather than hiding means the dock-reopen +// handler doesn't find a hidden window to resurrect. +type WindowManager struct { + app *application.App + settings *application.WebviewWindow + browserLogin *application.WebviewWindow + mu sync.Mutex +} + +func NewWindowManager(app *application.App) *WindowManager { + return &WindowManager{app: app} +} + +// OpenSettings shows the settings window, creating it on first use (and +// after the user has closed a previous instance). +func (s *WindowManager) OpenSettings() { + s.mu.Lock() + defer s.mu.Unlock() + if s.settings == nil { + s.settings = s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "settings", + Title: "NetBird Settings", + Width: 900, + Height: 640, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: application.NewRGB(24, 26, 29), + URL: "/#/settings", + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + }, + }) + s.settings.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + s.settings = nil + s.mu.Unlock() + }) + } + s.settings.Show() + s.settings.Focus() +} + +// OpenBrowserLogin shows the SSO popup window, creating it on first use (and +// after the user has closed a previous instance). The URI is encoded into +// the window's start URL so the React page reads it via useSearchParams. +func (s *WindowManager) OpenBrowserLogin(uri string) { + s.mu.Lock() + defer s.mu.Unlock() + if s.browserLogin == nil { + startURL := "/#/browser-login" + if uri != "" { + startURL = "/#/browser-login?uri=" + url.QueryEscape(uri) + } + s.browserLogin = s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: "browser-login", + Title: "NetBird Sign-in", + Width: 460, + Height: 440, + DisableResize: true, + MinimiseButtonState: application.ButtonHidden, + MaximiseButtonState: application.ButtonHidden, + CloseButtonState: application.ButtonEnabled, + BackgroundColour: application.NewRGB(24, 26, 29), + URL: startURL, + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 38, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, + }, + }) + bl := s.browserLogin + // User-initiated close (red X) means cancel. Emit the event so + // startLogin() can tear the SSO wait down, then let the window + // destroy naturally — no hide trickery. + bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.app.Event.Emit(EventBrowserLoginCancel) + s.mu.Lock() + s.browserLogin = nil + s.mu.Unlock() + }) + } else if uri != "" { + s.browserLogin.SetURL("/#/browser-login?uri=" + url.QueryEscape(uri)) + } + s.browserLogin.Show() + s.browserLogin.Focus() +} + +// CloseBrowserLogin destroys the SSO popup window if it exists. Called from +// startLogin() when the flow completes or cancels programmatically. +func (s *WindowManager) CloseBrowserLogin() { + s.mu.Lock() + w := s.browserLogin + s.browserLogin = nil + s.mu.Unlock() + if w != nil { + w.Close() + } +} diff --git a/client/ui/services/windows.go b/client/ui/services/windows.go deleted file mode 100644 index cc2fc8484..000000000 --- a/client/ui/services/windows.go +++ /dev/null @@ -1,66 +0,0 @@ -//go:build !android && !ios && !freebsd && !js - -package services - -import ( - "github.com/wailsapp/wails/v3/pkg/application" - "github.com/wailsapp/wails/v3/pkg/events" -) - -// Windows opens auxiliary application windows on demand from the frontend. -// The main window is created up-front in main.go; this service is for -// secondary, on-demand surfaces (Settings). -// -// The settings window is created hidden at app startup so its React bundle is -// already loaded by the time the user clicks the Settings icon — OpenSettings -// then just shows and focuses the pre-warmed window. Closing the window hides -// it instead of destroying it, so reopening is also instant. -type Windows struct { - app *application.App - settings *application.WebviewWindow -} - -func NewWindows(app *application.App) *Windows { - w := &Windows{app: app} - w.settings = w.buildSettings() - return w -} - -func (s *Windows) buildSettings() *application.WebviewWindow { - w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "NetBird Settings", - Width: 900, - Height: 640, - Hidden: true, - DisableResize: true, - MinimiseButtonState: application.ButtonHidden, - MaximiseButtonState: application.ButtonHidden, - CloseButtonState: application.ButtonEnabled, - BackgroundColour: application.NewRGB(24, 26, 29), - URL: "/#/settings", - Mac: application.MacWindow{ - InvisibleTitleBarHeight: 38, - Backdrop: application.MacBackdropTranslucent, - TitleBar: application.MacTitleBarHiddenInset, - CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, - }, - }) - - // Hide instead of close so the React bundle stays warm and the next - // OpenSettings is instant — same trick the main window uses. - w.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { - e.Cancel() - w.Hide() - }) - - return w -} - -// OpenSettings shows the pre-warmed settings window. -func (s *Windows) OpenSettings() { - if s.settings == nil { - return - } - s.settings.Show() - s.settings.Focus() -}