//go:build !android && !ios && !freebsd && !js package services import ( "net/url" "strconv" "sync" "time" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" "github.com/netbirdio/netbird/client/ui/i18n" "github.com/netbirdio/netbird/client/ui/preferences" ) // LanguageSubscriber delivers UI preference changes (currently only the // language flip; reusing preferences.UIPreferences keeps the channel // payload identical to preferences.Store.Subscribe). The runtime // implementation is *preferences.Store. WindowManager uses this to keep // the long-lived Settings window title in the active language. type LanguageSubscriber interface { Subscribe() (<-chan preferences.UIPreferences, func()) } // 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" // EventSettingsOpen tells the (already-mounted, currently-hidden) settings // window which tab to land on, then drives Window.Show()/Focus() from the // React side. Routing the open through the React layer avoids the // SetURL-on-every-open path that re-mounted the entire provider tree and // flashed the SettingsSkeleton between opens. const EventSettingsOpen = "netbird:settings:open" // WindowBackgroundColour is the shared in-window background for every // NetBird webview (matches the bg-nb-gray utility in the Tailwind config // at #181A1D / nb-gray-950, used by AppLayout's background). var WindowBackgroundColour = application.NewRGB(24, 26, 29) // WindowHeight is the shared frame height for the main window and the // Settings window so the right panel inside both ends up the same size. const WindowHeight = 660 // Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed). // Border + title bar match AppRightPanel's bg-nb-gray-940 (#1C1E21); // title text matches text-nb-gray-100 (#E4E7E9). u32ptr exists only // because WindowTheme fields are *uint32 and Go has no literal address-of. func u32ptr(v uint32) *uint32 { return &v } var microsoftWindowsTheme = &application.WindowTheme{ BorderColour: u32ptr(0x00211E1C), TitleBarColour: u32ptr(0x00211E1C), TitleTextColour: u32ptr(0x00E9E7E4), } // MicrosoftWindowsAppearanceOptions is the per-window Microsoft Windows OS // chrome shared by every NetBird webview window. Mica backdrop (no-op on // pre-22621), dark theme, custom title bar/border colours so the chrome // reads as an extension of the in-window AppRightPanel. func MicrosoftWindowsAppearanceOptions() application.WindowsWindow { return application.WindowsWindow{ BackdropType: application.Mica, Theme: application.Dark, CustomTheme: application.ThemeSettings{ DarkModeActive: microsoftWindowsTheme, DarkModeInactive: microsoftWindowsTheme, LightModeActive: microsoftWindowsTheme, LightModeInactive: microsoftWindowsTheme, }, } } // AppleMacOSAppearanceOptions is the per-window macOS chrome shared by // every NetBird webview window. The hidden title bar inset clears space // for the traffic-light buttons; the FullScreenNone collection behavior // keeps the green button from offering a full-screen mode that breaks // our fixed-size layouts. func AppleMacOSAppearanceOptions() application.MacWindow { return application.MacWindow{ InvisibleTitleBarHeight: 38, Backdrop: application.MacBackdropNormal, TitleBar: application.MacTitleBarHiddenInset, CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone, } } // LinuxAppearanceOptions is the per-window Linux chrome shared by every // NetBird webview window. Icon shows up in the WM task list / minimised // state; WindowIsTranslucent is left off so the opaque background colour // paints reliably on compositors that fake translucency badly. func LinuxAppearanceOptions(icon []byte) application.LinuxWindow { return application.LinuxWindow{ Icon: icon, WindowIsTranslucent: false, } } // DialogWindowOptions is the baseline for every auxiliary dialog window // (BrowserLogin, SessionExpiration, InstallProgress). // All share size (360x320), the no-resize / no-min / no-max chrome, // Hidden-on-create (so the React side can auto-size before first paint), // AlwaysOnTop (the dialogs interrupt the user, the SSO popup overrides // this), and the shared background/Mac/Windows appearance. Callers fill // in per-dialog overrides (URL params, screen targeting, etc.) on the // returned value before passing it to Window.NewWithOptions. func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions { return application.WebviewWindowOptions{ Name: name, Title: title, Width: 360, Height: 320, DisableResize: true, AlwaysOnTop: true, Hidden: true, MinimiseButtonState: application.ButtonHidden, MaximiseButtonState: application.ButtonHidden, CloseButtonState: application.ButtonEnabled, BackgroundColour: WindowBackgroundColour, URL: url, Mac: AppleMacOSAppearanceOptions(), Windows: MicrosoftWindowsAppearanceOptions(), Linux: LinuxAppearanceOptions(linuxIcon), } } // 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 surfaces (Settings, BrowserLogin, Session*, InstallProgress). // // Settings is created eagerly (hidden) at construction and hides — rather // than destroys — on close, so reopens are instant and the React side keeps // whatever in-window state the user left behind (selected tab, scroll // position, unsaved form fields). All other auxiliary 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 macOS dock-reopen handler doesn't find a hidden // window to resurrect. type WindowManager struct { app *application.App mainWindow *application.WebviewWindow translator ErrorTranslator prefs LanguagePreference linuxIcon []byte settings *application.WebviewWindow browserLogin *application.WebviewWindow sessionExpiration *application.WebviewWindow installProgress *application.WebviewWindow welcome *application.WebviewWindow errorDialog *application.WebviewWindow // hiddenForLogin remembers windows that were visible when the // BrowserLogin popup opened. They were Hide()n to keep focus on the // SSO flow without resorting to AlwaysOnTop, and are restored when // the BrowserLogin window closes (success or cancel). hiddenForLogin []application.Window mu sync.Mutex // recenterOnShow reports whether Go should re-center the Go-shown // windows (main, Settings) on each show. Only true in the minimal-WM / // in-process XEmbed-tray environment, where the WM neither centers small // windows for us nor restores their position across a hide -> show // round-trip. On full desktops (GNOME/KDE) the WM handles placement, so // re-centering is unnecessary and would fight a window the user moved — // there this stays nil and centerWhenReady is a no-op. Set by the Linux // startup path via SetRecenterOnShow; nil on macOS/Windows and in tests. // A predicate (not a bool) because the XEmbed tray can appear after the // UI starts (panel/app login race), so the answer is evaluated per show. recenterOnShow func() bool } // title resolves a window-title i18n key in the user's current language. // Falls back to the raw key when the translator or prefs are missing // (mirrors services.Connection.translateShort) — a deliberate fail-loud // signal that a key is missing from the bundle. func (s *WindowManager) title(key string) string { if s.translator == nil { return key } lang := i18n.DefaultLanguage if s.prefs != nil { if pref := s.prefs.Get().Language; pref != "" { lang = pref } } return s.translator.Translate(lang, key) } // NewWindowManager wires the manager to the main app. `mainWindow` is the // up-front-created webview the user interacts with from the tray — used to // pick the BrowserLogin window's display so the sign-in popup follows the // user onto the screen they're already looking at. `translator` + `prefs` // resolve the user-facing window titles in the active UI language; both // may be nil (callers in tests can omit them), in which case title() falls // back to the raw i18n key. // // The Settings window is created here, hidden, so the first OpenSettings // call paints instantly instead of paying webview construction + asset load // at click time. 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} // If the prefs implementation also exposes Subscribe (the runtime // *preferences.Store does), wire up a goroutine that re-titles every // live auxiliary window on language flip. Done here — instead of via // an exported WatchLanguage method on the service — so the Wails // binding generator doesn't try to expose a LanguageSubscriber-taking // method to the frontend (interface params can't round-trip through // JSON and would emit a generator warning). if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil { ch, _ := sub.Subscribe() go func() { var last i18n.LanguageCode for p := range ch { if p.Language == "" || p.Language == last { continue } last = p.Language s.retitleAll() } }() } s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", Title: s.title("window.title.settings"), Width: 900, Height: WindowHeight, Hidden: true, DisableResize: true, MinimiseButtonState: application.ButtonHidden, MaximiseButtonState: application.ButtonHidden, CloseButtonState: application.ButtonEnabled, BackgroundColour: WindowBackgroundColour, URL: "/#/settings", Mac: AppleMacOSAppearanceOptions(), Windows: MicrosoftWindowsAppearanceOptions(), Linux: LinuxAppearanceOptions(linuxIcon), }) // Hide on close instead of destroying — preserves in-window React state // across reopens. Mirrors the main window's close behaviour. Resetting // the active tab to General on hide means the *next* OpenSettings("") // finds the window already on General, so showing it is a single Show() // with nothing to update first — no flash. s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel() s.app.Event.Emit(EventSettingsOpen, "general") s.settings.Hide() }) return s } // retitleAll re-applies the localised title to every currently-alive // auxiliary window. Reads the window pointers under s.mu so a concurrent // Open*/Close* can't observe a torn slice. SetTitle itself dispatches to // the OS UI thread, so calling it from this goroutine is safe. func (s *WindowManager) retitleAll() { s.mu.Lock() type pair struct { win *application.WebviewWindow key string } wins := []pair{ {s.settings, "window.title.settings"}, {s.browserLogin, "window.title.signIn"}, {s.sessionExpiration, "window.title.sessionExpiration"}, {s.installProgress, "window.title.updating"}, {s.welcome, "window.title.welcome"}, {s.errorDialog, "window.title.error"}, } s.mu.Unlock() for _, p := range wins { if p.win != nil { p.win.SetTitle(s.title(p.key)) } } } // OpenSettings asks the (already-mounted, currently-hidden) settings window // to land on `tab` and bring itself to front. Empty `tab` lands on General. // // The window stays at a single URL (`/#/settings`) for its entire lifetime: // calling SetURL on every open re-loaded the WKWebView, which re-mounted the // `AppLayout` provider stack and visibly flashed the `SettingsSkeleton` while // `SettingsContext` re-fetched config. Instead, the React side keeps tab in // local state and listens for `EventSettingsOpen` to switch it. The close // hook (above) already resets state to "general", so the common-case // reopen-on-gear path has nothing to update — Show is a no-op repaint. func (s *WindowManager) OpenSettings(tab string) { target := tab if target == "" { target = "general" } s.app.Event.Emit(EventSettingsOpen, target) s.settings.Show() s.settings.Focus() // Re-center on every open (minimal-WM only): like the main window, // Settings is hidden (not destroyed) on close, and a hide -> show // round-trip lands it back in the corner there unless re-centered. s.centerWhenReady(s.settings) } // 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 := "/#/dialog/browser-login" if uri != "" { startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } s.hideOtherWindowsLocked("browser-login") // Prefer the screen the main window is on so the sign-in popup // shows up where the user is already looking on multi-monitor // setups. Falls back to OS-default centering if the main window // has no resolvable screen yet. var screen *application.Screen if s.mainWindow != nil { if sc, err := s.mainWindow.GetScreen(); err == nil { screen = sc } } opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon) // SSO popup deliberately is NOT always-on-top — the user moves // between the browser tab and our popup; pinning it would obscure // the browser at the moment they need to interact with it. opts.AlwaysOnTop = false // WindowCentered + Screen centers on the chosen display's // WorkArea (see WebviewWindowOptions.Screen docs) so the popup // follows the user onto the screen they're already looking at. opts.InitialPosition = application.WindowCentered opts.Screen = screen s.browserLogin = s.app.Window.NewWithOptions(opts) 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.restoreHiddenWindowsLocked() s.mu.Unlock() }) // First open: window is Hidden, the React side auto-sizes via // useAutoSizeWindow and calls Window.Show/Focus once content is // measured. Returning here avoids the snap from placeholder to // measured height. centerWhenReady polls for that JS-driven show, // so it centers (minimal-WM only) whoever ends up calling Show. s.centerWhenReady(s.browserLogin) return } if uri != "" { s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) } s.browserLogin.Show() s.browserLogin.Focus() s.centerWhenReady(s.browserLogin) } // hideOtherWindowsLocked hides every currently visible window except the one // named `keepName` and remembers them in hiddenForLogin so they can be // restored when the BrowserLogin flow ends. Caller must hold s.mu. func (s *WindowManager) hideOtherWindowsLocked(keepName string) { for _, w := range s.app.Window.GetAll() { if w == nil || w.Name() == keepName { continue } if !w.IsVisible() { continue } w.Hide() s.hiddenForLogin = append(s.hiddenForLogin, w) } } // restoreHiddenWindowsLocked re-shows every window that was hidden by // hideOtherWindowsLocked. Caller must hold s.mu. func (s *WindowManager) restoreHiddenWindowsLocked() { for _, w := range s.hiddenForLogin { if w == nil { continue } w.Show() } s.hiddenForLogin = nil } // BrowserLoginWindow returns the live SSO popup window, or nil if no SSO // flow is in progress. While it is non-nil it should be treated as the // app's focal window — tray "Open" and dock/taskbar activation hand off // to it instead of the (currently hidden) main window. func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow { s.mu.Lock() defer s.mu.Unlock() return s.browserLogin } // InstallProgressWindow returns the live install-progress window, or nil // if no install is in progress. Same contract as BrowserLoginWindow: while // it is non-nil it is the app's focal window — tray "Open" and dock / // taskbar activation route to it instead of the (currently hidden) main // window. Install supersedes every other surface, so callers should check // this before BrowserLoginWindow. func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow { s.mu.Lock() defer s.mu.Unlock() return s.installProgress } // 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() } } // OpenSessionExpiration shows the countdown warning above all other // windows on the display the cursor is currently on. `seconds` seeds the // mm:ss countdown rendered React-side. Singleton, destroyed on close. func (s *WindowManager) OpenSessionExpiration(seconds int) { s.mu.Lock() defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) 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) { s.mu.Lock() s.sessionExpiration = nil s.mu.Unlock() }) s.centerOnCursorScreen(s.sessionExpiration) return } s.sessionExpiration.SetURL(startURL) s.centerOnCursorScreen(s.sessionExpiration) s.sessionExpiration.Show() s.sessionExpiration.Focus() } // CloseSessionExpiration destroys the countdown warning window if open. func (s *WindowManager) CloseSessionExpiration() { s.mu.Lock() w := s.sessionExpiration s.sessionExpiration = nil s.mu.Unlock() if w != nil { w.Close() } } // OpenInstallProgress shows the install-progress window above all other // application windows for the duration of the auto-update install. The // daemon is unreliable mid-install (it gets restarted by the installer), // so this window owns its own polling loop against Update.GetInstallerResult // and treats a sustained gRPC failure as success. // // All other visible windows are hidden while the install runs — the ticket // requires that the user can't reach other menus during install — and are // restored when the window closes (cancel, error dismissal, success-quit // race). Singleton, destroyed on close. Created Hidden so the React side // can auto-size before paint. 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) } if s.installProgress == nil { s.hideOtherWindowsLocked("install-progress") s.installProgress = 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.mu.Lock() s.installProgress = nil s.restoreHiddenWindowsLocked() s.mu.Unlock() }) s.centerWhenReady(s.installProgress) return } s.installProgress.SetURL(startURL) s.installProgress.Show() s.installProgress.Focus() s.centerWhenReady(s.installProgress) } // CloseInstallProgress destroys the install-progress window if open. func (s *WindowManager) CloseInstallProgress() { s.mu.Lock() w := s.installProgress s.installProgress = nil s.mu.Unlock() if w != nil { w.Close() } } // OpenWelcome shows the first-launch onboarding window. The React side // auto-sizes the window height to its content; the Continue button calls // Preferences.SetOnboardingCompleted(true) before closing so the flow // doesn't re-run. Singleton, destroyed on close. Created Hidden so the // React side can auto-size before paint. 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 // Onboarding stays AlwaysOnTop (inherited from DialogWindowOptions) // so the user can't accidentally bury the first-launch flow behind // another window and lose track of how to finish setup. // Land in the middle of the user's primary display — the welcome // flow is identity-defining and shouldn't read as an incidental // dialog floating in a corner. WindowCentered + nil Screen // resolves against the primary display (see WebviewWindowOptions). opts.InitialPosition = application.WindowCentered s.welcome = s.app.Window.NewWithOptions(opts) w := s.welcome w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() s.welcome = nil s.mu.Unlock() }) s.centerWhenReady(s.welcome) return } s.welcome.Show() s.welcome.Focus() s.centerWhenReady(s.welcome) } // CloseWelcome destroys the welcome window if open. func (s *WindowManager) CloseWelcome() { s.mu.Lock() w := s.welcome s.welcome = nil s.mu.Unlock() if w != nil { w.Close() } } // OpenError shows a custom error dialog window above all other application // windows. The window's chrome title is always the generic localised "Error"; // `title` is the error's name (e.g. a login failure passes the translated // "Login Failed") and is rendered as the dialog heading in the body, while // `message` is the body text below it. The caller is responsible for localising // both. title + message are carried in the window's start URL so the page reads // them via useSearchParams; if the window is already open it is steered to the // new content via SetURL so a second error replaces the first instead of // stacking another window. Singleton — destroyed on close. Created Hidden so // the React side can auto-size to the (variable-length) message before paint. // // This is the in-window alternative to the native errorDialog wrapper: it // keeps the frameless NetBird chrome and survives the Windows-MessageBox // parent-disable race that the native path has to detach around. func (s *WindowManager) OpenError(title, message string) { s.mu.Lock() defer s.mu.Unlock() startURL := errorDialogURL(title, message) if s.errorDialog == nil { s.errorDialog = s.app.Window.NewWithOptions( DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon), ) s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { s.mu.Lock() s.errorDialog = nil s.mu.Unlock() }) s.centerWhenReady(s.errorDialog) return } s.errorDialog.SetURL(startURL) s.errorDialog.Show() s.errorDialog.Focus() s.centerWhenReady(s.errorDialog) } // errorDialogURL builds the hash-route start URL for the error window with the // title (rendered as the body heading) and message carried as query params. // Both are query-escaped so newlines, ampersands, and other characters common // in formatted daemon errors survive the round-trip into useSearchParams. func errorDialogURL(title, message string) string { q := url.Values{} if title != "" { q.Set("title", title) } if message != "" { q.Set("message", message) } startURL := "/#/dialog/error" if enc := q.Encode(); enc != "" { startURL += "?" + enc } return startURL } // CloseError destroys the error dialog window if open. func (s *WindowManager) CloseError() { s.mu.Lock() w := s.errorDialog s.errorDialog = nil s.mu.Unlock() if w != nil { w.Close() } } // OpenMain brings the main window forward. Used by the welcome Continue // button to hand off from onboarding to the regular UI without depending // on the tray. func (s *WindowManager) OpenMain() { s.ShowMain() } // ShowMain brings the main window forward, centering it on each show (see // centerWhenReady). The single entry point every surface — tray, SIGUSR1, // welcome handoff — should use so the centering fix applies uniformly. func (s *WindowManager) ShowMain() { if s.mainWindow == nil { return } s.mainWindow.Show() s.mainWindow.Focus() // Re-center on every show (minimal-WM only — see centerWhenReady). The // window is hidden (not destroyed) on close, and on a hide -> show // round-trip minimal WMs (the XEmbed tray path) re-place it in the // top-left corner rather than restoring its prior position, so // re-opening from the tray lands it in the corner again otherwise. s.centerWhenReady(s.mainWindow) } // SetRecenterOnShow installs the predicate that gates Go-side re-centering of // the main and Settings windows (see the recenterOnShow field). The Linux // startup path passes xembedTrayAvailable so re-centering happens only in the // minimal-WM / in-process-XEmbed-tray environment; macOS/Windows and tests // leave it unset, making centerWhenReady a no-op. func (s *WindowManager) SetRecenterOnShow(pred func() bool) { s.recenterOnShow = pred } // getScreenBasedOnCursorPosition returns the display the OS cursor is // on, falling back through main-window screen → nil (Wails treats nil // as OS-default placement). Linux uses XQueryPointer via XWayland on // Wayland sessions, which ships by default on the supported distros. func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { if s.app == nil || s.app.Screen == nil { return nil } if p, ok := getCursorPosition(s.app); ok { if sc := s.app.Screen.ScreenNearestDipPoint(p); sc != nil { return sc } } if s.mainWindow != nil { if sc, err := s.mainWindow.GetScreen(); err == nil { return sc } } return nil } // centerWhenReady centers w once its native window actually exists — but only // in environments where the WM won't do it for us (recenterOnShow). On full // desktops the WM centers small windows and restores position across hide -> // show, so this returns immediately and never fights a user-moved window. // // Why it can't be a simple inline Center() after Show(): on Linux/GTK4 (Wails' // linux_cgo backend) Center() moves the window via raw X11 (window_move_x11), // which silently no-ops while the GdkSurface is still nil — and GTK4 realizes // the surface asynchronously on the main loop, *after* Show() returns. So an // immediate Center() races realization and lands in the top-left corner; the // minimal WMs this targets don't re-center for us, so it sticks. // // It also can't be deferred via InvokeAsync(w.Center): Center() itself hops to // the main thread with InvokeSync, so running it *on* the main thread would // deadlock. So we drive it from a background goroutine (Center() and Position() // are main-thread-safe off-thread for exactly that reason) and retry until the // move actually takes effect, which is the unambiguous signal that the surface // now exists: position() goes through X11 (window_get_position_x11) and reports // (0,0) while the surface is nil — so a non-zero post-Center position means the // centering landed. Bounded so a window that legitimately centers at the origin // (e.g. fills the monitor) can't spin forever. func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) { if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() { return } go func() { for i := 0; i < 50; i++ { // ~1s budget at 20ms steps w.Center() if x, y := w.Position(); x != 0 || y != 0 { return // move took effect -> surface is realized } time.Sleep(20 * time.Millisecond) } }() } // centerOnCursorScreen centers w in the work area of the display the // cursor is on. Each guard is a no-op (nil window, no cursor screen, // zero size, zero work area) so a headless / no-monitor session is safe. // On minimal WMs (recenterOnShow → Fluxbox/XEmbed) the same retry loop // centerWhenReady uses kicks in: Linux SetPosition silently no-ops while // the GdkSurface is nil, and a non-zero post-move Position is the // signal that it landed. func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) { if w == nil { return } place := func() { screen := s.getScreenBasedOnCursorPosition() if screen == nil { return } width, height := w.Size() if width <= 0 || height <= 0 { return } wa := screen.WorkArea if wa.Width <= 0 || wa.Height <= 0 { return } w.SetPosition(wa.X+(wa.Width-width)/2, wa.Y+(wa.Height-height)/2) } place() if s.recenterOnShow == nil || !s.recenterOnShow() { return } go func() { for i := 0; i < 50; i++ { place() if x, y := w.Position(); x != 0 || y != 0 { return } time.Sleep(20 * time.Millisecond) } }() }