From 3967864172036aa0d3911f239a3e8ee7c8234fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Fri, 5 Jun 2026 11:42:03 +0200 Subject: [PATCH] [client/ui] Center windows on show under minimal WMs (XEmbed tray) On minimal window managers (fluxbox et al, the in-process XEmbed-tray path) the WM neither centers small windows nor restores their position across a hide -> show round-trip, so the main, Settings, and dialog windows opened in the top-left corner instead of centered. These windows are created Hidden, so Wails' Linux/GTK4 backend skips its post-Show centering pass (gated on !Hidden) and InitialPosition has no effect on an unrealized window. Re-center from Go after Show, gated on the minimal-WM environment via a recenterOnShow predicate (set to xembedTrayAvailable on Linux, nil on macOS/Windows where the WM handles placement). centerWhenReady polls from a background goroutine until the move actually lands -- Center() moves via raw X11, which no-ops while the GdkSurface is still nil and GTK4 realizes it asynchronously after Show(). Also reorder xembed_host_linux.go so the static helpers (xembedTrayAvailable, goMenuItemClicked) sit at the end, after the constructor and methods. --- client/ui/main.go | 18 ++++-- client/ui/recenter_linux.go | 17 ++++++ client/ui/recenter_other.go | 12 ++++ client/ui/services/windowmanager.go | 87 ++++++++++++++++++++++++++++- client/ui/tray.go | 8 ++- client/ui/xembed_host_linux.go | 72 ++++++++++++------------ 6 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 client/ui/recenter_linux.go create mode 100644 client/ui/recenter_other.go diff --git a/client/ui/main.go b/client/ui/main.go index dd523101a..e4eabc17d 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -149,6 +149,12 @@ func main() { // so they don't linger as hidden windows that Wails's macOS dock-reopen // handler would pop back up. windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow) + // On minimal WMs (the in-process XEmbed-tray path) the WM neither centers + // small windows nor restores their position across a hide -> show, so the + // main/Settings windows would open in the top-left corner. Gate Go-side + // re-centering on that environment; nil (full desktops, macOS, Windows) + // leaves placement to the WM. See WindowManager.SetRecenterOnShow. + windowManager.SetRecenterOnShow(recenterOnShowPredicate()) app.RegisterService(application.NewService(windowManager)) // Welcome / onboarding window. First launch only — the Continue @@ -323,10 +329,14 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat initialWidth = 900 } window := app.Window.NewWithOptions(application.WebviewWindowOptions{ - Name: "main", - Title: "NetBird", - Width: initialWidth, - Height: services.WindowHeight, + Name: "main", + Title: "NetBird", + Width: initialWidth, + Height: services.WindowHeight, + // Center on first show. Full DEs (GNOME/KDE) place small windows + // centered by default, but minimal WMs (fluxbox et al, the XEmbed + // tray path) drop new windows in the top-left corner unless asked. + InitialPosition: application.WindowCentered, Hidden: true, BackgroundColour: services.WindowBackgroundColour, URL: "/", diff --git a/client/ui/recenter_linux.go b/client/ui/recenter_linux.go new file mode 100644 index 000000000..2c574e08f --- /dev/null +++ b/client/ui/recenter_linux.go @@ -0,0 +1,17 @@ +//go:build linux && !(linux && 386) + +package main + +// recenterOnShowPredicate returns the predicate WindowManager uses to decide +// whether to re-center its Go-shown windows (main, Settings) on each show. +// +// On Linux this is xembedTrayAvailable: re-centering is needed only in the +// minimal-WM / in-process-XEmbed-tray environment, where the window manager +// neither centers small windows for us nor restores their position across a +// hide -> show round-trip. The predicate is evaluated per show (not once at +// startup) because the XEmbed tray can appear after the UI starts — the panel +// and the autostarted app race at login — and xembedTrayAvailable is a cheap, +// side-effect-free selection-owner probe, fine to call repeatedly. +func recenterOnShowPredicate() func() bool { + return xembedTrayAvailable +} diff --git a/client/ui/recenter_other.go b/client/ui/recenter_other.go new file mode 100644 index 000000000..62f5702d9 --- /dev/null +++ b/client/ui/recenter_other.go @@ -0,0 +1,12 @@ +//go:build !linux || (linux && 386) + +package main + +// recenterOnShowPredicate returns nil off Linux (and on the cgo-less linux/386 +// build): macOS and Windows window managers center windows and restore their +// position across hide -> show themselves, so the Go-side re-centering that +// the minimal-WM Linux path needs would only fight a window the user moved. +// A nil predicate makes WindowManager.centerWhenReady a no-op. +func recenterOnShowPredicate() func() bool { + return nil +} diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index bb9ca399f..528e1d39b 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -6,6 +6,7 @@ import ( "net/url" "strconv" "sync" + "time" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" @@ -161,6 +162,17 @@ type WindowManager struct { // 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. @@ -286,6 +298,10 @@ func (s *WindowManager) OpenSettings(tab string) { 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 @@ -335,7 +351,9 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { // 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. + // 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 != "" { @@ -343,6 +361,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) { } s.browserLogin.Show() s.browserLogin.Focus() + s.centerWhenReady(s.browserLogin) } // hideOtherWindowsLocked hides every currently visible window except the one @@ -427,10 +446,12 @@ func (s *WindowManager) OpenSessionExpired() { s.sessionExpired = nil s.mu.Unlock() }) + s.centerWhenReady(s.sessionExpired) return } s.sessionExpired.Show() s.sessionExpired.Focus() + s.centerWhenReady(s.sessionExpired) } // CloseSessionExpired destroys the session-expired window if open. @@ -462,11 +483,13 @@ func (s *WindowManager) OpenSessionAboutToExpire(seconds int) { s.sessionAboutToExpire = nil s.mu.Unlock() }) + s.centerWhenReady(s.sessionAboutToExpire) return } s.sessionAboutToExpire.SetURL(startURL) s.sessionAboutToExpire.Show() s.sessionAboutToExpire.Focus() + s.centerWhenReady(s.sessionAboutToExpire) } // CloseSessionAboutToExpire destroys the countdown warning window if open. @@ -509,11 +532,13 @@ func (s *WindowManager) OpenInstallProgress(version string) { 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. @@ -553,10 +578,12 @@ func (s *WindowManager) OpenWelcome() { 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. @@ -574,9 +601,67 @@ func (s *WindowManager) CloseWelcome() { // 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 +} + +// 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) + } + }() } diff --git a/client/ui/tray.go b/client/ui/tray.go index 2febf5226..e038a84c9 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -309,6 +309,13 @@ func (t *Tray) ShowWindow() { if t.window == nil { return } + // Route through WindowManager so the main window is centered on its + // first show (see WindowManager.ShowMain) — minimal WMs (fluxbox, the + // XEmbed tray path) otherwise drop it in the top-left corner. + if t.svc.WindowManager != nil { + t.svc.WindowManager.ShowMain() + return + } t.window.Show() t.window.Focus() } @@ -645,4 +652,3 @@ func (t *Tray) notify(title, body, id string) { func (t *Tray) notifyError(message string) { t.notify(t.loc.T("notify.error.title"), message, notifyIDTrayError) } - diff --git a/client/ui/xembed_host_linux.go b/client/ui/xembed_host_linux.go index 2b66ddb32..f94c96e43 100644 --- a/client/ui/xembed_host_linux.go +++ b/client/ui/xembed_host_linux.go @@ -70,43 +70,7 @@ type xembedHost struct { stopCh chan struct{} } -// goMenuItemClicked is the C callback invoked from the GTK main thread -// when the user activates a popup-menu entry. C callbacks cannot carry -// Go pointers, so the active xembedHost is looked up through the -// activeMenuHost global instead. //export makes this symbol visible to -// the C side; the function must therefore live in package main. -// -//export goMenuItemClicked -func goMenuItemClicked(id C.int) { - activeMenuHostMu.Lock() - h := activeMenuHost - activeMenuHostMu.Unlock() - - if h != nil { - go h.sendMenuEvent(int32(id)) - } -} - // newXembedHost creates an XEmbed tray icon for the given SNI item. -// xembedTrayAvailable reports whether an XEmbed system tray manager -// (_NET_SYSTEM_TRAY_S0) currently owns its selection on the default screen. -// It is a cheap, side-effect-free probe — it only queries the selection -// owner, creating no windows. Used to decide whether the in-process -// StatusNotifierWatcher is needed at all: the watcher exists solely to -// bridge SNI items into an XEmbed tray on minimal WMs, so when no XEmbed -// tray is present (e.g. Wayland compositors with a real SNI host like -// Waybar) we must not claim org.kde.StatusNotifierWatcher and shadow the -// real one. Returns false when there is no X display (pure Wayland). -func xembedTrayAvailable() bool { - dpy := C.XOpenDisplay(nil) - if dpy == nil { - return false - } - defer C.XCloseDisplay(dpy) - screen := C.xembed_default_screen(dpy) - return C.xembed_find_tray(dpy, screen) != 0 -} - // Returns an error if no XEmbed tray manager is available (graceful fallback). func newXembedHost(conn *dbus.Conn, busName string, objPath dbus.ObjectPath) (*xembedHost, error) { dpy := C.XOpenDisplay(nil) @@ -436,6 +400,42 @@ func buildCItems(items []menuItemInfo, allocs *[]unsafe.Pointer) *C.xembed_menu_ return (*C.xembed_menu_item)(arr) } +// xembedTrayAvailable reports whether an XEmbed system tray manager +// (_NET_SYSTEM_TRAY_S0) currently owns its selection on the default screen. +// It is a cheap, side-effect-free probe — it only queries the selection +// owner, creating no windows. Used to decide whether the in-process +// StatusNotifierWatcher is needed at all: the watcher exists solely to +// bridge SNI items into an XEmbed tray on minimal WMs, so when no XEmbed +// tray is present (e.g. Wayland compositors with a real SNI host like +// Waybar) we must not claim org.kde.StatusNotifierWatcher and shadow the +// real one. Returns false when there is no X display (pure Wayland). +func xembedTrayAvailable() bool { + dpy := C.XOpenDisplay(nil) + if dpy == nil { + return false + } + defer C.XCloseDisplay(dpy) + screen := C.xembed_default_screen(dpy) + return C.xembed_find_tray(dpy, screen) != 0 +} + +// goMenuItemClicked is the C callback invoked from the GTK main thread +// when the user activates a popup-menu entry. C callbacks cannot carry +// Go pointers, so the active xembedHost is looked up through the +// activeMenuHost global instead. //export makes this symbol visible to +// the C side; the function must therefore live in package main. +// +//export goMenuItemClicked +func goMenuItemClicked(id C.int) { + activeMenuHostMu.Lock() + h := activeMenuHost + activeMenuHostMu.Unlock() + + if h != nil { + go h.sendMenuEvent(int32(id)) + } +} + // boolToInt converts a Go bool to the C int the dbusmenu C API uses // for boolean flags. func boolToInt(b bool) C.int {