From 6f6e9db35d4115ee7d5c14405b936be23d05dbf6 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Thu, 3 Sep 2026 13:31:38 +0200 Subject: [PATCH] [client] Show every dialog window from Go once its frontend has painted Dialog windows (browser-login, session-expiration, install-progress, welcome, error) were created hidden and made visible only by their own webview's Show call after sizing. A hidden WKWebView on macOS can be throttled or suspended before that code runs, which left the window hidden forever. The main and settings windows already avoided this with the painted event plus a fallback timer, but that timer was armed on WindowRuntimeReady, which a frozen webview never reaches either. Route all dialogs through the same mechanism: the auto-size hook emits the painted event instead of showing the window, Go shows and focuses it on that event, and a fallback timer armed at creation shows it after 3 s regardless. The browser-login popup opens the browser in an after-show callback so the browser still lands in front of the popup, also on the fallback path. --- .../frontend/src/hooks/useAutoSizeWindow.ts | 27 ++- .../login/LoginWaitingForBrowserDialog.tsx | 2 +- client/ui/services/windowmanager.go | 172 ++++++++++++------ 3 files changed, 128 insertions(+), 73 deletions(-) diff --git a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts index 693f5725f..fd4b8e0bb 100644 --- a/client/ui/frontend/src/hooks/useAutoSizeWindow.ts +++ b/client/ui/frontend/src/hooks/useAutoSizeWindow.ts @@ -1,27 +1,24 @@ import { useLayoutEffect, useRef } from "react"; -import { Window } from "@wailsio/runtime"; +import { Events, Window } from "@wailsio/runtime"; import i18next from "@/lib/i18n"; import { isLinux } from "@/lib/platform"; +const EVENT_WINDOW_PAINTED = "netbird:window-painted"; + // Sizes the current Wails window to the measured content height (keeping `width`), -// then shows it. Re-applies on content resize and language change. -export function useAutoSizeWindow( - width: number, - ready: boolean = true, - showWhenSized: boolean = true, -) { +// then reports it as painted so Go shows it. Re-applies on content resize and language change. +export function useAutoSizeWindow(width: number, ready: boolean = true) { const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; if (!el) return; - let shown = false; + let painted = false; let raf1 = 0; let raf2 = 0; - const showOnce = () => { - if (shown) return; - shown = true; - Window.Show().catch(() => {}); - Window.Focus().catch(() => {}); + const paintedOnce = () => { + if (painted) return; + painted = true; + Events.Emit(EVENT_WINDOW_PAINTED).catch(() => {}); }; const apply = async () => { if (!ready) return; @@ -37,7 +34,7 @@ export function useAutoSizeWindow( await Window.SetMaxSize(width, targetH); } await Window.SetSize(width, targetH); - if (showWhenSized) showOnce(); + paintedOnce(); } catch { // window gone / not ready — ignore } @@ -59,6 +56,6 @@ export function useAutoSizeWindow( cancelAnimationFrame(raf2); i18next.off("languageChanged", scheduleApply); }; - }, [width, ready, showWhenSized]); + }, [width, ready]); return ref; } diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index d1a5da1d0..f03751c4d 100644 --- a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -20,7 +20,7 @@ export default function LoginWaitingForBrowserDialog() { const { t } = useTranslation(); const [params] = useSearchParams(); const uri = params.get("uri") ?? ""; - const contentRef = useAutoSizeWindow(WINDOW_WIDTH, true, false); + const contentRef = useAutoSizeWindow(WINDOW_WIDTH); const reportOpenFailure = useCallback( (e: unknown) => { diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 1a707df17..1d18331b7 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -32,7 +32,7 @@ const EventSettingsOpen = "netbird:settings:open" const EventWindowPainted = "netbird:window-painted" -const paintedFallback = 2 * time.Second +const paintedFallback = 3 * time.Second const headlessTeardownDelay = 2 * time.Second @@ -123,6 +123,7 @@ type WindowManager struct { pendingTab map[uint]string pendingEmits map[uint][]string fallbackTimers map[uint]*time.Timer + afterShow map[uint]func() headlessMain bool headlessTimer *time.Timer // recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor @@ -142,6 +143,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo pendingTab: map[uint]string{}, pendingEmits: map[uint][]string{}, fallbackTimers: map[uint]*time.Timer{}, + afterShow: map[uint]func(){}, } s.watchPainted() s.watchTriggerLogin() @@ -215,7 +217,6 @@ func (s *WindowManager) OpenSettings(tab string) { // OpenBrowserLogin shows the SSO popup, creating it on first use. func (s *WindowManager) OpenBrowserLogin(uri string) { s.mu.Lock() - defer s.mu.Unlock() if s.browserLogin == nil { startURL := "/#/dialog/browser-login" if uri != "" { @@ -228,8 +229,8 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { opts.InitialPosition = application.WindowCentered // Open on the active (where users cursor is) display, like the session-expiration dialog. opts.Screen = s.getScreenBasedOnCursorPosition() - s.browserLogin = s.app.Window.NewWithOptions(opts) - bl := s.browserLogin + bl := s.app.Window.NewWithOptions(opts) + s.browserLogin = bl bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() // Only a live user red-X still has this registered; programmatic closers @@ -240,26 +241,36 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { s.browserLogin = nil s.restoreHiddenWindowsLocked() } + s.forgetWindowLocked(bl) s.mu.Unlock() if userClosed { s.app.Event.Emit(EventBrowserLoginCancel) } }) + s.mu.Unlock() + s.armReady(bl) s.centerOnCursorScreen(bl) - bl.Show() - bl.Focus() - log.Debugf("browser-login popup created and shown") - s.openBrowser(uri) + log.Debugf("browser-login popup created") + s.showThenOpenBrowser(bl, uri) return } + bl := s.browserLogin + s.mu.Unlock() if uri != "" { - s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) + bl.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) } - s.centerOnCursorScreen(s.browserLogin) - s.browserLogin.Show() - s.browserLogin.Focus() + s.centerOnCursorScreen(bl) log.Debugf("browser-login popup reused") - s.openBrowser(uri) + s.showThenOpenBrowser(bl, uri) +} + +func (s *WindowManager) showThenOpenBrowser(w *application.WebviewWindow, uri string) { + if uri != "" { + s.mu.Lock() + s.afterShow[w.ID()] = func() { s.openBrowser(uri) } + s.mu.Unlock() + } + s.showWhenReady(w) } func (s *WindowManager) openBrowser(uri string) { @@ -313,29 +324,36 @@ func (s *WindowManager) CloseBrowserLogin() { // the countdown and deadlineUnixMilli (0 when unknown) is the absolute deadline the dialog // compares renewal snapshots against. Singleton, destroyed on close. func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int64) { - s.mu.Lock() - defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) if deadlineUnixMilli > 0 { startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) } + s.mu.Lock() if s.sessionExpiration == nil { opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) opts.Screen = s.getScreenBasedOnCursorPosition() opts.InitialPosition = application.WindowCentered - s.sessionExpiration = s.app.Window.NewWithOptions(opts) - s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + w := s.app.Window.NewWithOptions(opts) + s.sessionExpiration = w + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - s.sessionExpiration = nil + if s.sessionExpiration == w { + s.sessionExpiration = nil + } + s.forgetWindowLocked(w) s.mu.Unlock() }) - s.centerOnCursorScreen(s.sessionExpiration) + s.mu.Unlock() + s.armReady(w) + s.centerOnCursorScreen(w) + s.showWhenReady(w) return } - s.sessionExpiration.SetURL(startURL) - s.centerOnCursorScreen(s.sessionExpiration) - s.sessionExpiration.Show() - s.sessionExpiration.Focus() + w := s.sessionExpiration + s.mu.Unlock() + w.SetURL(startURL) + s.centerOnCursorScreen(w) + s.showWhenReady(w) } func (s *WindowManager) CloseSessionExpiration() { @@ -380,30 +398,35 @@ func (s *WindowManager) CloseRenewFlow() { // OpenInstallProgress shows the install-progress window and hides the rest for the duration // (restored on close). It owns its own result polling since the daemon restarts mid-install. func (s *WindowManager) OpenInstallProgress(version string) { - s.mu.Lock() - defer s.mu.Unlock() startURL := "/#/dialog/install-progress" if version != "" { startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version) } + s.mu.Lock() if s.installProgress == nil { s.hideOtherWindowsLocked("install-progress") - s.installProgress = s.app.Window.NewWithOptions( + w := s.app.Window.NewWithOptions( DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon), ) - s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.installProgress = w + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - s.installProgress = nil + if s.installProgress == w { + s.installProgress = nil + } s.restoreHiddenWindowsLocked() + s.forgetWindowLocked(w) s.mu.Unlock() }) - s.centerWhenReady(s.installProgress) + s.mu.Unlock() + s.armReady(w) + s.showWhenReady(w) return } - s.installProgress.SetURL(startURL) - s.installProgress.Show() - s.installProgress.Focus() - s.centerWhenReady(s.installProgress) + w := s.installProgress + s.mu.Unlock() + w.SetURL(startURL) + s.showWhenReady(w) } func (s *WindowManager) CloseInstallProgress() { @@ -419,24 +442,28 @@ func (s *WindowManager) CloseInstallProgress() { // OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. func (s *WindowManager) OpenWelcome() { s.mu.Lock() - defer s.mu.Unlock() if s.welcome == nil { opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) opts.Width = 420 opts.InitialPosition = application.WindowCentered - s.welcome = s.app.Window.NewWithOptions(opts) - w := s.welcome + w := s.app.Window.NewWithOptions(opts) + s.welcome = w w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - s.welcome = nil + if s.welcome == w { + s.welcome = nil + } + s.forgetWindowLocked(w) s.mu.Unlock() }) - s.centerWhenReady(s.welcome) + s.mu.Unlock() + s.armReady(w) + s.showWhenReady(w) return } - s.welcome.Show() - s.welcome.Focus() - s.centerWhenReady(s.welcome) + w := s.welcome + s.mu.Unlock() + s.showWhenReady(w) } func (s *WindowManager) CloseWelcome() { @@ -457,25 +484,30 @@ func (s *WindowManager) OpenError(title, message, command string) { if ShuttingDown() { return } - s.mu.Lock() - defer s.mu.Unlock() startURL := errorDialogURL(title, message, command) + s.mu.Lock() if s.errorDialog == nil { - s.errorDialog = s.app.Window.NewWithOptions( + w := s.app.Window.NewWithOptions( DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), ) - s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.errorDialog = w + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() - s.errorDialog = nil + if s.errorDialog == w { + s.errorDialog = nil + } + s.forgetWindowLocked(w) s.mu.Unlock() }) - s.centerWhenReady(s.errorDialog) + s.mu.Unlock() + s.armReady(w) + s.showWhenReady(w) return } - s.errorDialog.SetURL(startURL) - s.errorDialog.Show() - s.errorDialog.Focus() - s.centerWhenReady(s.errorDialog) + w := s.errorDialog + s.mu.Unlock() + w.SetURL(startURL) + s.showWhenReady(w) } func (s *WindowManager) CloseError() { @@ -561,15 +593,24 @@ func (s *WindowManager) armReady(w *application.WebviewWindow) { if w == nil { return } - 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) - }) + id := w.ID() + timer := time.AfterFunc(paintedFallback, func() { s.mu.Lock() - s.fallbackTimers[w.ID()] = timer + ready := s.ready[id] s.mu.Unlock() + if ready { + return + } + log.Warnf("window %q never reported a first render, showing it anyway", w.Name()) + s.markReady(w) }) + s.mu.Lock() + if s.ready[id] { + timer.Stop() + } else { + s.fallbackTimers[id] = timer + } + s.mu.Unlock() } func (s *WindowManager) watchPainted() { @@ -668,6 +709,7 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) { delete(s.showPending, id) delete(s.pendingTab, id) delete(s.pendingEmits, id) + delete(s.afterShow, id) kept := s.hiddenForLogin[:0] for _, hidden := range s.hiddenForLogin { @@ -686,6 +728,16 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow { return s.mainWindow case "settings": return s.settings + case "browser-login": + return s.browserLogin + case "session-expiration": + return s.sessionExpiration + case "install-progress": + return s.installProgress + case "welcome": + return s.welcome + case "error": + return s.errorDialog default: return nil } @@ -744,6 +796,7 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) { } func (s *WindowManager) showNow(w *application.WebviewWindow) { + id := w.ID() s.mu.Lock() if w == s.mainWindow { s.headlessMain = false @@ -752,10 +805,15 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) { s.headlessTimer = nil } } + after := s.afterShow[id] + delete(s.afterShow, id) s.mu.Unlock() w.Show() w.Focus() s.centerWhenReady(w) + if after != nil { + after() + } } func (s *WindowManager) ShowMainAt(url string) {