[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.
This commit is contained in:
Zoltan Papp
2026-09-03 13:31:38 +02:00
parent 954c90f9e5
commit 6f6e9db35d
3 changed files with 128 additions and 73 deletions
@@ -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<T extends HTMLElement>(
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<T extends HTMLElement>(width: number, ready: boolean = true) {
const ref = useRef<T | null>(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<T extends HTMLElement>(
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<T extends HTMLElement>(
cancelAnimationFrame(raf2);
i18next.off("languageChanged", scheduleApply);
};
}, [width, ready, showWhenSized]);
}, [width, ready]);
return ref;
}
@@ -20,7 +20,7 @@ export default function LoginWaitingForBrowserDialog() {
const { t } = useTranslation();
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH, true, false);
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const reportOpenFailure = useCallback(
(e: unknown) => {
+115 -57
View File
@@ -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) {