mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-05 14:31:29 +02:00
[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.
This commit is contained in:
@@ -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: "/",
|
||||
|
||||
17
client/ui/recenter_linux.go
Normal file
17
client/ui/recenter_linux.go
Normal file
@@ -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
|
||||
}
|
||||
12
client/ui/recenter_other.go
Normal file
12
client/ui/recenter_other.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user