diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 5b670fb1f..7a55aefce 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -51,7 +51,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr `main.go` registers five typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:profile:changed` (`ProfileRef`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). `netbird:profile:changed` fires from `ProfileSwitcher.SwitchActive` after a successful daemon-side switch — both the React `ProfileContext` and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events: -- `EventTriggerLogin = "trigger-login"` — tray asking the frontend's `startLogin()` to begin an SSO flow. +- `EventTriggerLogin = "trigger-login"` — tray asking the frontend's `startLogin()` to begin an SSO flow. The tray does **not** show the main window when emitting — the hidden webview is alive and subscribed, so `startLogin` runs and the only visible surface is the BrowserLogin popup it opens. - `EventBrowserLoginCancel = "browser-login:cancel"` — the `BrowserLogin` window's Cancel button or red-X close. `startLogin()` listens and tears down the daemon's pending `WaitSSOLogin`. - `preferences.EventPreferencesChanged = "netbird:preferences:changed"` — emitted after every successful `SetLanguage` (payload `{language}`). Both the tray menu rebuild and the React `i18next.changeLanguage` subscribe so a flip from any window paints everywhere. diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index c18f62bc0..ab6d6d83e 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -132,8 +132,8 @@ The SSO flow is centralised in a module-level `startLogin()` with a `loginInFlig 1. `Connection.Login({})` with empty fields — Go fills in active profile + OS user. 2. If the daemon needs SSO (`needsSsoLogin`): - - `Connection.OpenURL(uri)` opens the verification page in the system browser (honors `$BROWSER`). - - `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window. + - `WindowManager.OpenBrowserLogin(uri)` opens the auxiliary "waiting for sign-in" window (Hidden until React mounts and `useAutoSizeWindow` calls `Window.Show`). + - `WaitingForBrowserDialog` mounts, gets shown by `useAutoSizeWindow`, then fires `Connection.OpenURL(uri)` from its mount effect — opens the verification page in the system browser (honors `$BROWSER`). Done from the dialog (not `startLogin`) so the browser doesn't race the still-hidden NetBird popup and land on top. - `Promise.race(WaitSSOLogin, EVENT_BROWSER_LOGIN_CANCEL)` — whichever resolves first. - On cancel: `Connection.Down()` to dislodge the daemon's pending `WaitSSOLogin` so the next Login starts fresh (see `services/connection.go:74`). 3. `Connection.Up({})` to bring the new session up. diff --git a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx index 4a201f086..4e83986ee 100644 --- a/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/layouts/ConnectionStatusSwitch.tsx @@ -62,16 +62,15 @@ async function startLogin(): Promise { if (result.needsSsoLogin) { const uri = result.verificationUriComplete || result.verificationUri; if (uri) { - // Open the in-app sign-in popup first so it's already on - // screen when the system browser steals focus; otherwise - // the browser lands on top and the user has to dig the - // NetBird window back out. + // Open the in-app sign-in popup first; the dialog itself + // fires Connection.OpenURL after it's actually on screen + // (see WaitingForBrowserDialog) so the system browser + // doesn't land on top of a still-hidden NetBird window. try { await WindowManager.OpenBrowserLogin(uri); } catch (e) { console.error(e); } - Connection.OpenURL(uri).catch(console.error); } const cancelPromise = new Promise((resolve) => { diff --git a/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx index b78f7a332..1e1d22282 100644 --- a/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/authentication/WaitingForBrowserDialog.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; import { Events } from "@wailsio/runtime"; @@ -21,6 +21,15 @@ export default function WaitingForBrowserDialog() { const uri = params.get("uri") ?? ""; const contentRef = useAutoSizeWindow(WINDOW_WIDTH); + // Open the system browser only after the dialog has mounted (which + // means useAutoSizeWindow has called Window.Show). startLogin used to + // fire OpenURL itself but the browser typically beat React's mount + // and landed on top of the still-hidden NetBird popup. + useEffect(() => { + if (!uri) return; + Connection.OpenURL(uri).catch(console.error); + }, [uri]); + const tryAgain = useCallback(() => { if (!uri) return; Connection.OpenURL(uri).catch(console.error); diff --git a/client/ui/tray.go b/client/ui/tray.go index 62a75d171..7821a618d 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -426,18 +426,17 @@ func (t *Tray) openRoute(route string) { func (t *Tray) handleConnect() { // NeedsLogin/SessionExpired/LoginFailed mean the daemon won't honor a // plain Up RPC ("up already in progress: current status NeedsLogin") — - // it needs the Login → WaitSSOLogin → Up sequence instead. Hand off - // to the React-side startLogin() (which owns the browser-login window - // and SSO orchestration) by showing the main window and emitting - // EventTriggerLogin. The frontend subscribes in - // layouts/ConnectionStatusSwitch.tsx. + // it needs the Login → WaitSSOLogin → Up sequence instead. Emit + // EventTriggerLogin so the React-side startLogin() (which owns the + // BrowserLogin popup) drives the flow. The main window's webview is + // alive even while hidden, so we don't surface it — only the popup + // appears. t.mu.Lock() needsLogin := strings.EqualFold(t.lastStatus, services.StatusNeedsLogin) || strings.EqualFold(t.lastStatus, services.StatusSessionExpired) || strings.EqualFold(t.lastStatus, services.StatusLoginFailed) t.mu.Unlock() if needsLogin { - t.ShowWindow() t.app.Event.Emit(services.EventTriggerLogin) return } @@ -625,7 +624,6 @@ func (t *Tray) applyStatus(st services.Status) { t.mu.Unlock() if triggerLogin { - t.ShowWindow() t.app.Event.Emit(services.EventTriggerLogin) }