mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-29 01:59:07 +02:00
Windows hidden while a popup owned the screen went into one untagged list, so whichever popup closed first restored all of them and emptied the list. An install started during SSO login re-showed the main window the login popup had deliberately hidden, and left the login popup with nothing to restore. Each entry now records the popup that hid it, and a restore releases only that popup's own entries. This also subsumes the manual filtering CloseRenewFlow did to keep its own session-expiration window from being re-shown.
1147 lines
32 KiB
Go
1147 lines
32 KiB
Go
//go:build !android && !ios && !freebsd && !js
|
|
|
|
package services
|
|
|
|
import (
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"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 so window titles follow the language.
|
|
type LanguageSubscriber interface {
|
|
Subscribe() (<-chan preferences.UIPreferences, func())
|
|
}
|
|
|
|
// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow.
|
|
const EventTriggerLogin = "trigger-login"
|
|
|
|
// EventBrowserLoginCancel signals the user dismissed the BrowserLogin popup.
|
|
const EventBrowserLoginCancel = "browser-login:cancel"
|
|
|
|
// EventSettingsOpen tells the mounted settings window which tab to show.
|
|
const EventSettingsOpen = "netbird:settings:open"
|
|
|
|
const EventWindowPainted = "netbird:window-painted"
|
|
|
|
// generationParam carries the painted-report token in each dialog's start URL.
|
|
const generationParam = "gen"
|
|
|
|
const paintedFallback = 3 * time.Second
|
|
|
|
const headlessTeardownDelay = 2 * time.Second
|
|
|
|
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
|
|
|
// WindowHeight is shared by the main and Settings windows.
|
|
const WindowHeight = 660
|
|
|
|
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
|
var microsoftWindowsTheme = &application.WindowTheme{
|
|
BorderColour: u32ptr(0x00211E1C),
|
|
TitleBarColour: u32ptr(0x00211E1C),
|
|
TitleTextColour: u32ptr(0x00E9E7E4),
|
|
}
|
|
|
|
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar).
|
|
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 shared macOS chrome; FullScreenNone keeps the fixed-size layout.
|
|
func AppleMacOSAppearanceOptions() application.MacWindow {
|
|
return application.MacWindow{
|
|
InvisibleTitleBarHeight: 38,
|
|
Backdrop: application.MacBackdropNormal,
|
|
TitleBar: application.MacTitleBarHiddenInset,
|
|
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
|
}
|
|
}
|
|
|
|
// LinuxAppearanceOptions is the shared Linux chrome; opaque so fake-translucency compositors paint it.
|
|
func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
|
return application.LinuxWindow{
|
|
Icon: icon,
|
|
WindowIsTranslucent: false,
|
|
}
|
|
}
|
|
|
|
// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog.
|
|
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),
|
|
}
|
|
}
|
|
|
|
// hiddenWindow records a window hidden by owner, the name of the popup that hid it.
|
|
type hiddenWindow struct {
|
|
win application.Window
|
|
owner string
|
|
}
|
|
|
|
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
|
|
// hiddenWindows holds windows hidden while a popup owns the screen, each tagged with
|
|
// the popup that hid it so closing one popup cannot restore what another still hides.
|
|
hiddenWindows []hiddenWindow
|
|
mu sync.Mutex
|
|
createMu sync.Mutex
|
|
newMain func(startURL string) *application.WebviewWindow
|
|
// painted gates showing a window: set by the frontend's first render, or by the
|
|
// fallback timer so a webview that never wakes up still becomes visible.
|
|
painted map[uint]bool
|
|
// mounted gates emitting to a window: set only by a real frontend report, since an
|
|
// event emitted to a frontend that has not subscribed yet is dropped, not queued.
|
|
mounted map[uint]bool
|
|
showPending map[uint]bool
|
|
pendingTab map[uint]string
|
|
pendingEmits map[uint][]string
|
|
fallbackTimers map[uint]*time.Timer
|
|
afterShow map[uint]func()
|
|
// generation maps a window name to the token stamped into its current start URL, so a
|
|
// painted report from a replaced window can be told apart from the live one's. Keyed by
|
|
// name, not ID, because the URL is built before the window (and its ID) exists.
|
|
generation map[string]uint64
|
|
lastGeneration uint64
|
|
headlessMain bool
|
|
headlessTimer *time.Timer
|
|
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
|
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
|
|
recenterOnShow func() bool
|
|
}
|
|
|
|
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,
|
|
painted: map[uint]bool{},
|
|
mounted: map[uint]bool{},
|
|
showPending: map[uint]bool{},
|
|
pendingTab: map[uint]string{},
|
|
pendingEmits: map[uint][]string{},
|
|
fallbackTimers: map[uint]*time.Timer{},
|
|
afterShow: map[uint]func(){},
|
|
generation: map[string]uint64{},
|
|
}
|
|
s.watchPainted()
|
|
s.watchTriggerLogin()
|
|
// Re-title live windows on language flip. Wired internally so the binding generator
|
|
// doesn't try to expose the interface param.
|
|
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()
|
|
}
|
|
}()
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
|
w := s.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(s.linuxIcon),
|
|
})
|
|
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
|
s.mu.Lock()
|
|
s.settings = nil
|
|
s.forgetWindowLocked(w)
|
|
s.mu.Unlock()
|
|
})
|
|
return w
|
|
}
|
|
|
|
// OpenSettings shows the settings window on tab (empty → General), switching tab via
|
|
// EventSettingsOpen rather than SetURL (which would remount the provider tree).
|
|
func (s *WindowManager) OpenSettings(tab string) {
|
|
target := tab
|
|
if target == "" {
|
|
target = "general"
|
|
}
|
|
|
|
w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow)
|
|
|
|
s.mu.Lock()
|
|
mounted := s.mounted[w.ID()]
|
|
if !mounted {
|
|
s.pendingTab[w.ID()] = target
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if mounted {
|
|
s.app.Event.Emit(EventSettingsOpen, target)
|
|
}
|
|
s.showWhenReady(w)
|
|
}
|
|
|
|
// OpenBrowserLogin shows the SSO popup, creating it on first use.
|
|
func (s *WindowManager) OpenBrowserLogin(uri string) {
|
|
s.mu.Lock()
|
|
if s.browserLogin == nil {
|
|
startURL := "/#/dialog/browser-login"
|
|
if uri != "" {
|
|
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
|
}
|
|
s.hideOtherWindowsLocked("browser-login")
|
|
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), s.stampGenerationLocked("browser-login", startURL), s.linuxIcon)
|
|
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
|
opts.AlwaysOnTop = false
|
|
opts.InitialPosition = application.WindowCentered
|
|
// Open on the active (where users cursor is) display, like the session-expiration dialog.
|
|
opts.Screen = s.getScreenBasedOnCursorPosition()
|
|
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
|
|
// nil s.browserLogin first and clean up themselves. Guarding here stops a
|
|
// stale close event from wiping a replacement popup's state.
|
|
userClosed := s.browserLogin == bl
|
|
if userClosed {
|
|
s.browserLogin = nil
|
|
s.restoreHiddenWindowsLocked("browser-login")
|
|
}
|
|
s.forgetWindowLocked(bl)
|
|
s.mu.Unlock()
|
|
if userClosed {
|
|
s.app.Event.Emit(EventBrowserLoginCancel)
|
|
}
|
|
})
|
|
s.mu.Unlock()
|
|
s.armReady(bl)
|
|
s.centerOnCursorScreen(bl)
|
|
log.Debugf("browser-login popup created")
|
|
s.showThenOpenBrowser(bl, uri)
|
|
return
|
|
}
|
|
bl := s.browserLogin
|
|
s.mu.Unlock()
|
|
if uri != "" {
|
|
bl.SetURL(s.stampGeneration("browser-login", "/#/dialog/browser-login?uri="+url.QueryEscape(uri)))
|
|
}
|
|
s.centerOnCursorScreen(bl)
|
|
log.Debugf("browser-login popup reused")
|
|
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) {
|
|
if uri == "" {
|
|
return
|
|
}
|
|
go func() {
|
|
if err := openURL(uri); err != nil {
|
|
log.Errorf("open browser for SSO login: %v", err)
|
|
s.OpenError(s.title("browserLogin.openFailedTitle"), err.Error(), "")
|
|
}
|
|
}()
|
|
}
|
|
|
|
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
|
// app's focal window: tray "Open" and dock activation hand off to it, not the 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. Same focal-window
|
|
// contract as BrowserLoginWindow; install supersedes everything, so check this first.
|
|
func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.installProgress
|
|
}
|
|
|
|
func (s *WindowManager) CloseBrowserLogin() {
|
|
s.mu.Lock()
|
|
w := s.browserLogin
|
|
s.browserLogin = nil
|
|
// The WindowClosing hook no-ops on a programmatic close, so restore here. Restoring
|
|
// by owner is already a no-op when no popup was open, which the frontend does call
|
|
// (resetDialog() after an early RequestExtend failure, or connection.ts's catch path).
|
|
s.restoreHiddenWindowsLocked("browser-login")
|
|
s.mu.Unlock()
|
|
if w != nil {
|
|
w.Close()
|
|
}
|
|
}
|
|
|
|
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
|
|
// 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) {
|
|
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"), s.stampGenerationLocked("session-expiration", startURL), s.linuxIcon)
|
|
opts.Screen = s.getScreenBasedOnCursorPosition()
|
|
opts.InitialPosition = application.WindowCentered
|
|
w := s.app.Window.NewWithOptions(opts)
|
|
s.sessionExpiration = w
|
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
|
s.mu.Lock()
|
|
if s.sessionExpiration == w {
|
|
s.sessionExpiration = nil
|
|
}
|
|
s.forgetWindowLocked(w)
|
|
s.mu.Unlock()
|
|
})
|
|
s.mu.Unlock()
|
|
s.armReady(w)
|
|
s.centerOnCursorScreen(w)
|
|
s.showWhenReady(w)
|
|
return
|
|
}
|
|
w := s.sessionExpiration
|
|
s.mu.Unlock()
|
|
w.SetURL(s.stampGeneration("session-expiration", startURL))
|
|
s.centerOnCursorScreen(w)
|
|
s.showWhenReady(w)
|
|
}
|
|
|
|
func (s *WindowManager) CloseSessionExpiration() {
|
|
s.mu.Lock()
|
|
w := s.sessionExpiration
|
|
s.sessionExpiration = nil
|
|
s.mu.Unlock()
|
|
if w != nil {
|
|
w.Close()
|
|
}
|
|
}
|
|
|
|
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
|
|
// closes the browser-login popup and the session-expiration window together.
|
|
func (s *WindowManager) CloseRenewFlow() {
|
|
s.mu.Lock()
|
|
bl := s.browserLogin
|
|
se := s.sessionExpiration
|
|
s.browserLogin = nil
|
|
s.sessionExpiration = nil
|
|
// Both popups go away here, so drop the session-expiration window from the restore
|
|
// set rather than re-showing the window this call is closing.
|
|
if se != nil {
|
|
kept := s.hiddenWindows[:0]
|
|
for _, hidden := range s.hiddenWindows {
|
|
if hidden.win != application.Window(se) {
|
|
kept = append(kept, hidden)
|
|
}
|
|
}
|
|
s.hiddenWindows = kept
|
|
}
|
|
s.restoreHiddenWindowsLocked("browser-login")
|
|
s.mu.Unlock()
|
|
|
|
// Close after unlock so the re-entrant handlers can take s.mu.
|
|
if bl != nil {
|
|
bl.Close()
|
|
}
|
|
if se != nil {
|
|
se.Close()
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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")
|
|
w := s.app.Window.NewWithOptions(
|
|
DialogWindowOptions("install-progress", s.title("window.title.updating"), s.stampGenerationLocked("install-progress", startURL), s.linuxIcon),
|
|
)
|
|
s.installProgress = w
|
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
|
s.mu.Lock()
|
|
// Only a live user close still has this registered; CloseInstallProgress
|
|
// nils s.installProgress first and restores itself. Guarding here stops a
|
|
// stale close event from re-showing windows a replacement popup hides.
|
|
if s.installProgress == w {
|
|
s.installProgress = nil
|
|
s.restoreHiddenWindowsLocked("install-progress")
|
|
}
|
|
s.forgetWindowLocked(w)
|
|
s.mu.Unlock()
|
|
})
|
|
s.mu.Unlock()
|
|
s.armReady(w)
|
|
s.showWhenReady(w)
|
|
return
|
|
}
|
|
w := s.installProgress
|
|
s.mu.Unlock()
|
|
w.SetURL(s.stampGeneration("install-progress", startURL))
|
|
s.showWhenReady(w)
|
|
}
|
|
|
|
func (s *WindowManager) CloseInstallProgress() {
|
|
s.mu.Lock()
|
|
w := s.installProgress
|
|
s.installProgress = nil
|
|
// The guarded WindowClosing handler no-ops on a programmatic close, so restore here.
|
|
s.restoreHiddenWindowsLocked("install-progress")
|
|
s.mu.Unlock()
|
|
if w != nil {
|
|
w.Close()
|
|
}
|
|
}
|
|
|
|
// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close.
|
|
func (s *WindowManager) OpenWelcome() {
|
|
s.mu.Lock()
|
|
if s.welcome == nil {
|
|
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), s.stampGenerationLocked("welcome", "/#/dialog/welcome"), s.linuxIcon)
|
|
opts.Width = 420
|
|
opts.InitialPosition = application.WindowCentered
|
|
w := s.app.Window.NewWithOptions(opts)
|
|
s.welcome = w
|
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
|
s.mu.Lock()
|
|
if s.welcome == w {
|
|
s.welcome = nil
|
|
}
|
|
s.forgetWindowLocked(w)
|
|
s.mu.Unlock()
|
|
})
|
|
s.mu.Unlock()
|
|
s.armReady(w)
|
|
s.showWhenReady(w)
|
|
return
|
|
}
|
|
w := s.welcome
|
|
s.mu.Unlock()
|
|
s.showWhenReady(w)
|
|
}
|
|
|
|
func (s *WindowManager) CloseWelcome() {
|
|
s.mu.Lock()
|
|
w := s.welcome
|
|
s.welcome = nil
|
|
s.mu.Unlock()
|
|
if w != nil {
|
|
w.Close()
|
|
}
|
|
}
|
|
|
|
// OpenError shows the custom error dialog; title/message/command are pre-localised
|
|
// and ride in the start URL. command is optional and, when set, is offered for
|
|
// copying so the user can run the operation the daemon refused. A second error
|
|
// replaces the open one via SetURL. Singleton, destroyed on close.
|
|
func (s *WindowManager) OpenError(title, message, command string) {
|
|
if ShuttingDown() {
|
|
return
|
|
}
|
|
startURL := errorDialogURL(title, message, command)
|
|
s.mu.Lock()
|
|
if s.errorDialog == nil {
|
|
w := s.app.Window.NewWithOptions(
|
|
DialogWindowOptions("error", s.title("window.title.error"), s.stampGenerationLocked("error", startURL), s.linuxIcon),
|
|
)
|
|
s.errorDialog = w
|
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
|
s.mu.Lock()
|
|
if s.errorDialog == w {
|
|
s.errorDialog = nil
|
|
}
|
|
s.forgetWindowLocked(w)
|
|
s.mu.Unlock()
|
|
})
|
|
s.mu.Unlock()
|
|
s.armReady(w)
|
|
s.showWhenReady(w)
|
|
return
|
|
}
|
|
w := s.errorDialog
|
|
s.mu.Unlock()
|
|
w.SetURL(s.stampGeneration("error", startURL))
|
|
s.showWhenReady(w)
|
|
}
|
|
|
|
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; the welcome handoff uses it instead of the tray.
|
|
func (s *WindowManager) OpenMain() {
|
|
s.ShowMain()
|
|
}
|
|
|
|
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
|
|
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
|
|
func (s *WindowManager) ShowMain() {
|
|
s.showWhenReady(s.MainWindow())
|
|
}
|
|
|
|
// ShowMainAndEmit brings the main window forward and emits event once its frontend is ready.
|
|
func (s *WindowManager) ShowMainAndEmit(event string) {
|
|
w := s.MainWindow()
|
|
if w == nil {
|
|
return
|
|
}
|
|
|
|
id := w.ID()
|
|
s.mu.Lock()
|
|
mounted := s.mounted[id]
|
|
if !mounted {
|
|
s.pendingEmits[id] = append(s.pendingEmits[id], event)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.showWhenReady(w)
|
|
if mounted {
|
|
s.app.Event.Emit(event)
|
|
}
|
|
}
|
|
|
|
func (s *WindowManager) MainWindow() *application.WebviewWindow {
|
|
w, _ := s.ensureMain("/")
|
|
return w
|
|
}
|
|
|
|
func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) {
|
|
s.mu.Lock()
|
|
factory := s.newMain
|
|
s.mu.Unlock()
|
|
if factory == nil {
|
|
return s.ensureWindow(&s.mainWindow, nil)
|
|
}
|
|
return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow {
|
|
return factory(startURL)
|
|
})
|
|
}
|
|
|
|
func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) {
|
|
s.createMu.Lock()
|
|
defer s.createMu.Unlock()
|
|
|
|
s.mu.Lock()
|
|
w := *slot
|
|
s.mu.Unlock()
|
|
if w != nil || factory == nil {
|
|
return w, false
|
|
}
|
|
|
|
w = factory()
|
|
s.armReady(w)
|
|
|
|
s.mu.Lock()
|
|
*slot = w
|
|
s.mu.Unlock()
|
|
return w, true
|
|
}
|
|
|
|
// armReady starts the fallback that shows w even if its frontend never reports a first
|
|
// render. The timer starts at creation, because a hidden webview can be suspended before
|
|
// it reaches WindowRuntimeReady — the very case this fallback covers. That makes the first
|
|
// budget cover webview boot as well, so the runtime-ready hook rearms it to give the
|
|
// frontend its own full budget to mount and paint.
|
|
func (s *WindowManager) armReady(w *application.WebviewWindow) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
s.armPaintedFallback(w)
|
|
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
|
|
s.armPaintedFallback(w)
|
|
})
|
|
}
|
|
|
|
func (s *WindowManager) armPaintedFallback(w *application.WebviewWindow) {
|
|
id := w.ID()
|
|
timer := time.AfterFunc(paintedFallback, func() {
|
|
s.mu.Lock()
|
|
painted := s.painted[id]
|
|
s.mu.Unlock()
|
|
if painted {
|
|
return
|
|
}
|
|
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
|
|
s.markPainted(w)
|
|
})
|
|
|
|
s.mu.Lock()
|
|
if prev := s.fallbackTimers[id]; prev != nil {
|
|
prev.Stop()
|
|
}
|
|
if s.painted[id] {
|
|
timer.Stop()
|
|
delete(s.fallbackTimers, id)
|
|
} else {
|
|
s.fallbackTimers[id] = timer
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *WindowManager) watchPainted() {
|
|
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
|
w := s.windowByName(e.Sender)
|
|
if w == nil {
|
|
return
|
|
}
|
|
if !s.matchesGeneration(e.Sender, paintedGeneration(e.Data)) {
|
|
log.Debugf("ignoring stale painted report for window %q", e.Sender)
|
|
return
|
|
}
|
|
s.markPainted(w)
|
|
s.markMounted(w)
|
|
})
|
|
}
|
|
|
|
func (s *WindowManager) watchTriggerLogin() {
|
|
s.app.Event.On(EventTriggerLogin, func(_ *application.CustomEvent) {
|
|
s.mu.Lock()
|
|
if s.headlessTimer != nil {
|
|
s.headlessTimer.Stop()
|
|
s.headlessTimer = nil
|
|
}
|
|
w := s.mainWindow
|
|
ready := w != nil && s.mounted[w.ID()]
|
|
s.mu.Unlock()
|
|
if ready {
|
|
return
|
|
}
|
|
|
|
w, created := s.ensureMain("/")
|
|
if w == nil {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
if created {
|
|
s.headlessMain = true
|
|
}
|
|
pending := !s.mounted[w.ID()]
|
|
if pending {
|
|
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if !pending {
|
|
s.app.Event.Emit(EventTriggerLogin)
|
|
}
|
|
})
|
|
|
|
s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) {
|
|
s.scheduleHeadlessTeardown()
|
|
})
|
|
|
|
s.app.Event.On(EventStatusSnapshot, func(e *application.CustomEvent) {
|
|
st, ok := e.Data.(Status)
|
|
if !ok {
|
|
return
|
|
}
|
|
switch st.Status {
|
|
case StatusConnected, StatusLoginFailed, StatusDaemonUnavailable:
|
|
s.scheduleHeadlessTeardown()
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *WindowManager) scheduleHeadlessTeardown() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.headlessMain || s.mainWindow == nil {
|
|
return
|
|
}
|
|
if s.headlessTimer != nil {
|
|
s.headlessTimer.Stop()
|
|
}
|
|
s.headlessTimer = time.AfterFunc(headlessTeardownDelay, s.closeHeadlessMain)
|
|
}
|
|
|
|
func (s *WindowManager) closeHeadlessMain() {
|
|
s.mu.Lock()
|
|
w := s.mainWindow
|
|
headless := s.headlessMain
|
|
s.headlessTimer = nil
|
|
s.mu.Unlock()
|
|
if !headless || w == nil {
|
|
return
|
|
}
|
|
w.Close()
|
|
}
|
|
|
|
func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
|
|
id := w.ID()
|
|
if timer := s.fallbackTimers[id]; timer != nil {
|
|
timer.Stop()
|
|
}
|
|
delete(s.fallbackTimers, id)
|
|
delete(s.painted, id)
|
|
delete(s.mounted, id)
|
|
delete(s.showPending, id)
|
|
delete(s.pendingTab, id)
|
|
delete(s.pendingEmits, id)
|
|
delete(s.afterShow, id)
|
|
delete(s.generation, w.Name())
|
|
|
|
kept := s.hiddenWindows[:0]
|
|
for _, hidden := range s.hiddenWindows {
|
|
if hidden.win != application.Window(w) {
|
|
kept = append(kept, hidden)
|
|
}
|
|
}
|
|
s.hiddenWindows = kept
|
|
}
|
|
|
|
func (s *WindowManager) stampGeneration(name, startURL string) string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.stampGenerationLocked(name, startURL)
|
|
}
|
|
|
|
func (s *WindowManager) stampGenerationLocked(name, startURL string) string {
|
|
s.lastGeneration++
|
|
s.generation[name] = s.lastGeneration
|
|
return appendGeneration(startURL, s.lastGeneration)
|
|
}
|
|
|
|
func (s *WindowManager) matchesGeneration(name string, gen uint64) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
want, tracked := s.generation[name]
|
|
if !tracked {
|
|
return true
|
|
}
|
|
return want == gen
|
|
}
|
|
|
|
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
switch name {
|
|
case "main":
|
|
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
|
|
}
|
|
}
|
|
|
|
func (s *WindowManager) markPainted(w *application.WebviewWindow) {
|
|
id := w.ID()
|
|
s.mu.Lock()
|
|
already := s.painted[id]
|
|
s.painted[id] = true
|
|
wanted := s.showPending[id]
|
|
delete(s.showPending, id)
|
|
if timer := s.fallbackTimers[id]; timer != nil {
|
|
timer.Stop()
|
|
delete(s.fallbackTimers, id)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if already || !wanted {
|
|
return
|
|
}
|
|
s.showNow(w)
|
|
}
|
|
|
|
// markMounted records that the window's frontend is subscribed, and flushes the events
|
|
// held back for it. The fallback timer never calls this: showing a blank window is
|
|
// recoverable, emitting into a frontend that cannot hear it is not.
|
|
func (s *WindowManager) markMounted(w *application.WebviewWindow) {
|
|
id := w.ID()
|
|
s.mu.Lock()
|
|
already := s.mounted[id]
|
|
s.mounted[id] = true
|
|
tab, hasTab := s.pendingTab[id]
|
|
emits := s.pendingEmits[id]
|
|
delete(s.pendingTab, id)
|
|
delete(s.pendingEmits, id)
|
|
s.mu.Unlock()
|
|
|
|
if already {
|
|
return
|
|
}
|
|
|
|
if hasTab {
|
|
s.app.Event.Emit(EventSettingsOpen, tab)
|
|
}
|
|
|
|
for _, event := range emits {
|
|
s.app.Event.Emit(event)
|
|
}
|
|
}
|
|
|
|
func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
|
|
id := w.ID()
|
|
s.mu.Lock()
|
|
painted := s.painted[id]
|
|
if !painted {
|
|
s.showPending[id] = true
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
if painted {
|
|
s.showNow(w)
|
|
}
|
|
}
|
|
|
|
func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
|
id := w.ID()
|
|
s.mu.Lock()
|
|
if w == s.mainWindow {
|
|
s.headlessMain = false
|
|
if s.headlessTimer != nil {
|
|
s.headlessTimer.Stop()
|
|
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) {
|
|
w, created := s.ensureMain(url)
|
|
if w == nil {
|
|
return
|
|
}
|
|
if !created {
|
|
w.SetURL(url)
|
|
}
|
|
s.showWhenReady(w)
|
|
}
|
|
|
|
func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.newMain = f
|
|
}
|
|
|
|
func (s *WindowManager) ForgetMain() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.forgetWindowLocked(s.mainWindow)
|
|
s.mainWindow = nil
|
|
s.headlessMain = false
|
|
if s.headlessTimer != nil {
|
|
s.headlessTimer.Stop()
|
|
s.headlessTimer = nil
|
|
}
|
|
}
|
|
|
|
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).
|
|
func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
|
|
s.recenterOnShow = pred
|
|
}
|
|
|
|
// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it
|
|
// returns so it never fights a user-moved window. On GTK4 an inline Center()
|
|
// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync
|
|
// would deadlock, so a background goroutine retries until Position is non-zero,
|
|
// bounded so a window genuinely at the origin 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 // surface realized
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// centerOnCursorScreen centers w on the cursor's display; guards no-op on headless sessions.
|
|
// On minimal WMs it uses the same realize-detection retry loop as centerWhenReady.
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// title resolves a window-title i18n key in the current language, or the raw key if unavailable.
|
|
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)
|
|
}
|
|
|
|
// retitleAll re-applies the localised title to every live auxiliary window. Pointers are
|
|
// snapshotted under s.mu; SetTitle is then safe to call after releasing the lock.
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// hideOtherWindowsLocked hides every visible window except keepName, recording them
|
|
// against keepName so only its own restore brings them back. A window already hidden by
|
|
// an earlier popup is skipped, leaving it tagged to the popup that actually hid it.
|
|
// 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.hiddenWindows = append(s.hiddenWindows, hiddenWindow{win: w, owner: keepName})
|
|
}
|
|
}
|
|
|
|
// restoreHiddenWindowsLocked re-shows the windows owner hid, leaving those another popup
|
|
// still hides untouched (caller holds s.mu). If the main window was among them,
|
|
// raiseToForeground lifts it above the SSO browser, which still owns the foreground — a
|
|
// plain Show/Focus would be demoted to a taskbar flash and leave it stranded behind.
|
|
func (s *WindowManager) restoreHiddenWindowsLocked(owner string) {
|
|
mainRestored := false
|
|
kept := s.hiddenWindows[:0]
|
|
for _, hidden := range s.hiddenWindows {
|
|
if hidden.owner != owner {
|
|
kept = append(kept, hidden)
|
|
continue
|
|
}
|
|
if hidden.win == nil {
|
|
continue
|
|
}
|
|
hidden.win.Show()
|
|
if hidden.win == s.mainWindow {
|
|
mainRestored = true
|
|
}
|
|
}
|
|
s.hiddenWindows = kept
|
|
if mainRestored && s.mainWindow != nil {
|
|
raiseToForeground(s.mainWindow)
|
|
}
|
|
}
|
|
|
|
// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the
|
|
// main-window screen, then nil (OS-default placement).
|
|
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
|
|
}
|
|
|
|
// errorDialogURL builds the error window's start URL with title/message/command as escaped query params.
|
|
func errorDialogURL(title, message, command string) string {
|
|
q := url.Values{}
|
|
if title != "" {
|
|
q.Set("title", title)
|
|
}
|
|
if message != "" {
|
|
q.Set("message", message)
|
|
}
|
|
if command != "" {
|
|
q.Set("command", command)
|
|
}
|
|
startURL := "/#/dialog/error"
|
|
if enc := q.Encode(); enc != "" {
|
|
startURL += "?" + enc
|
|
}
|
|
return startURL
|
|
}
|
|
|
|
// appendGeneration adds the painted-report token to a dialog start URL, keeping any
|
|
// existing query params intact across the "/#/path?params" hash-router form.
|
|
func appendGeneration(startURL string, gen uint64) string {
|
|
sep := "?"
|
|
if strings.Contains(startURL, "?") {
|
|
sep = "&"
|
|
}
|
|
return startURL + sep + generationParam + "=" + strconv.FormatUint(gen, 10)
|
|
}
|
|
|
|
// paintedGeneration reads the token a painted report carries back, returning 0 when the
|
|
// frontend sent none (an older bundle, or the main window, which is never re-stamped).
|
|
func paintedGeneration(data any) uint64 {
|
|
switch v := data.(type) {
|
|
case string:
|
|
gen, err := strconv.ParseUint(v, 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return gen
|
|
case float64:
|
|
return uint64(v)
|
|
case []any:
|
|
if len(v) == 0 {
|
|
return 0
|
|
}
|
|
return paintedGeneration(v[0])
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
|
|
func u32ptr(v uint32) *uint32 { return &v }
|