update session expiration dialog texts, add monitor aware position

This commit is contained in:
Eduard Gert
2026-06-08 18:09:22 +02:00
parent b067544c8a
commit 6b2ae1c34c
19 changed files with 321 additions and 226 deletions
+42
View File
@@ -0,0 +1,42 @@
//go:build darwin
package services
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework AppKit
#import <Cocoa/Cocoa.h>
#import <AppKit/AppKit.h>
typedef struct CursorPoint {
int x;
int y;
int ok;
} CursorPoint;
// NSEvent.mouseLocation is Y-up from primary's bottom-left; flip against
// the primary's frame height so the point matches Wails' Y-down Screen.Bounds.
CursorPoint nbGetCursorPos(void) {
CursorPoint p = {0, 0, 0};
NSArray<NSScreen *> *screens = [NSScreen screens];
if (screens == nil || screens.count == 0) return p;
NSScreen *primary = [screens firstObject];
if (primary == nil) return p;
NSPoint loc = [NSEvent mouseLocation];
p.x = (int)loc.x;
p.y = (int)(primary.frame.size.height - loc.y);
p.ok = 1;
return p;
}
*/
import "C"
import "github.com/wailsapp/wails/v3/pkg/application"
func getCursorPosition(_ *application.App) (application.Point, bool) {
res := C.nbGetCursorPos()
if res.ok == 0 {
return application.Point{}, false
}
return application.Point{X: int(res.x), Y: int(res.y)}, true
}
+54
View File
@@ -0,0 +1,54 @@
//go:build linux
package services
/*
#cgo pkg-config: x11
#cgo LDFLAGS: -lX11
#include <X11/Xlib.h>
#include <stdlib.h>
typedef struct CursorPoint {
int x;
int y;
int ok;
} CursorPoint;
// XQueryPointer hits Xorg directly on X11 sessions and XWayland on
// Wayland sessions (shipped by default on the supported distros). ok=0
// when no X server is reachable — caller falls back gracefully.
CursorPoint nbGetCursorPos(void) {
CursorPoint p = {0, 0, 0};
Display *dpy = XOpenDisplay(NULL);
if (!dpy) return p;
Window root = DefaultRootWindow(dpy);
if (root == 0) { XCloseDisplay(dpy); return p; }
Window root_return = 0, child_return = 0;
int root_x = 0, root_y = 0, win_x = 0, win_y = 0;
unsigned int mask_return = 0;
if (XQueryPointer(dpy, root, &root_return, &child_return,
&root_x, &root_y, &win_x, &win_y, &mask_return)) {
p.x = root_x;
p.y = root_y;
p.ok = 1;
}
XCloseDisplay(dpy);
return p;
}
*/
import "C"
import "github.com/wailsapp/wails/v3/pkg/application"
func getCursorPosition(app *application.App) (application.Point, bool) {
res := C.nbGetCursorPos()
if res.ok == 0 {
return application.Point{}, false
}
p := application.Point{X: int(res.x), Y: int(res.y)}
// X11 root coords are physical pixels; Screen.Bounds is in DIPs.
if app == nil || app.Screen == nil {
return p, true
}
return app.Screen.PhysicalToDipPoint(p), true
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin && !windows && !linux
package services
import "github.com/wailsapp/wails/v3/pkg/application"
func getCursorPosition(_ *application.App) (application.Point, bool) {
return application.Point{}, false
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package services
import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/w32"
)
func getCursorPosition(app *application.App) (application.Point, bool) {
x, y, ok := w32.GetCursorPos()
if !ok || app == nil || app.Screen == nil {
return application.Point{}, false
}
// GetCursorPos is in physical pixels; Screen.Bounds is in DIPs.
return app.Screen.PhysicalToDipPoint(application.Point{X: x, Y: y}), true
}
+1 -1
View File
@@ -20,7 +20,7 @@ type (
// Session is the Wails-bound wrapper around authsession.Session. It only
// re-exposes the subset the React frontend actually calls
// (SessionAboutToExpireDialog.tsx: RequestExtend + WaitExtend). The tray
// (SessionExpirationDialog.tsx: RequestExtend + WaitExtend). The tray
// uses authsession.Session directly, so methods that only the tray needs
// (DismissWarning) are deliberately absent here — keeping the generated
// TS surface minimal.
+87 -67
View File
@@ -105,8 +105,8 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
}
// DialogWindowOptions is the baseline for every auxiliary dialog window
// (BrowserLogin, SessionExpired, SessionAboutToExpire, InstallProgress).
// All four share size (360x320), the no-resize / no-min / no-max chrome,
// (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
@@ -152,8 +152,7 @@ type WindowManager struct {
linuxIcon []byte
settings *application.WebviewWindow
browserLogin *application.WebviewWindow
sessionExpired *application.WebviewWindow
sessionAboutToExpire *application.WebviewWindow
sessionExpiration *application.WebviewWindow
installProgress *application.WebviewWindow
welcome *application.WebviewWindow
errorDialog *application.WebviewWindow
@@ -268,8 +267,7 @@ func (s *WindowManager) retitleAll() {
wins := []pair{
{s.settings, "window.title.settings"},
{s.browserLogin, "window.title.signIn"},
{s.sessionExpired, "window.title.sessionExpired"},
{s.sessionAboutToExpire, "window.title.sessionExpiring"},
{s.sessionExpiration, "window.title.sessionExpiration"},
{s.installProgress, "window.title.updating"},
{s.welcome, "window.title.welcome"},
{s.errorDialog, "window.title.error"},
@@ -428,77 +426,37 @@ func (s *WindowManager) CloseBrowserLogin() {
}
}
// OpenSessionExpired shows the "session expired" prompt window above all
// other application windows. Singleton — destroyed on close.
//
// The window is created Hidden so the React side can measure its content
// and call Window.SetSize + Window.Show before the user sees the chrome —
// otherwise the user would briefly see the 360x320 placeholder snapping to
// the measured height. Re-opens (singleton already alive) Show/Focus
// directly here.
func (s *WindowManager) OpenSessionExpired() {
// 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()
if s.sessionExpired == nil {
s.sessionExpired = s.app.Window.NewWithOptions(
DialogWindowOptions("session-expired", s.title("window.title.sessionExpired"), "/#/dialog/session-expired", s.linuxIcon),
)
s.sessionExpired.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
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.sessionExpired = nil
s.sessionExpiration = nil
s.mu.Unlock()
})
s.centerWhenReady(s.sessionExpired)
s.centerOnCursorScreen(s.sessionExpiration)
return
}
s.sessionExpired.Show()
s.sessionExpired.Focus()
s.centerWhenReady(s.sessionExpired)
s.sessionExpiration.SetURL(startURL)
s.centerOnCursorScreen(s.sessionExpiration)
s.sessionExpiration.Show()
s.sessionExpiration.Focus()
}
// CloseSessionExpired destroys the session-expired window if open.
func (s *WindowManager) CloseSessionExpired() {
// CloseSessionExpiration destroys the countdown warning window if open.
func (s *WindowManager) CloseSessionExpiration() {
s.mu.Lock()
w := s.sessionExpired
s.sessionExpired = nil
s.mu.Unlock()
if w != nil {
w.Close()
}
}
// OpenSessionAboutToExpire shows the countdown warning window above all
// other application windows. `seconds` seeds the initial countdown value
// rendered as mm:ss in the React layer. Singleton — destroyed on close.
// Window is created Hidden so the React side can auto-size before paint
// (see OpenSessionExpired comment).
func (s *WindowManager) OpenSessionAboutToExpire(seconds int) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/dialog/session-about-to-expire?seconds=" + strconv.Itoa(seconds)
if s.sessionAboutToExpire == nil {
s.sessionAboutToExpire = s.app.Window.NewWithOptions(
DialogWindowOptions("session-about-to-expire", s.title("window.title.sessionExpiring"), startURL, s.linuxIcon),
)
s.sessionAboutToExpire.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
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.
func (s *WindowManager) CloseSessionAboutToExpire() {
s.mu.Lock()
w := s.sessionAboutToExpire
s.sessionAboutToExpire = nil
w := s.sessionExpiration
s.sessionExpiration = nil
s.mu.Unlock()
if w != nil {
w.Close()
@@ -698,6 +656,27 @@ 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 ->
@@ -733,3 +712,44 @@ func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) {
}
}()
}
// 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)
}
}()
}