From e42402c49ec873d2d1c8f8b131c33379fd87e3b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Papp?= Date: Mon, 7 Sep 2026 11:53:25 +0200 Subject: [PATCH] [client] Fix the Windows tray deadlock on re-entrant window creation The Wails systray runs the left-click handler synchronously inside the tray window procedure, and creating a window on a running app pumps a nested Win32 message loop while WebView2 initialises. ensureWindow held the non-reentrant createMu across that creation, so the second button-up of a double click re-entered ShowWindow from the pump and blocked the main thread on its own lock. A goroutine holding createMu while the main thread pumped, and the Open* dialogs holding mu across NewWithOptions, Show, Hide and InvokeSync, exposed the same inversion. WindowManager now serialises creation with a per-slot creating flag and queues the callers' operations until the window exists, and no Wails call runs while mu is held. The tray click and second-instance handlers call ShowWindow off the message loop. --- client/ui/main.go | 2 +- client/ui/services/windowmanager.go | 494 +++++++++++++---------- client/ui/services/windowmanager_test.go | 127 ++++++ client/ui/tray_click_windows.go | 2 +- 4 files changed, 410 insertions(+), 215 deletions(-) create mode 100644 client/ui/services/windowmanager_test.go diff --git a/client/ui/main.go b/client/ui/main.go index 5652efcf2..1a866e3a5 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -100,7 +100,7 @@ func main() { var tray *Tray app := newApplication(func() { if tray != nil { - tray.ShowWindow() + go tray.ShowWindow() } }) diff --git a/client/ui/services/windowmanager.go b/client/ui/services/windowmanager.go index 94dba6038..68542ae53 100644 --- a/client/ui/services/windowmanager.go +++ b/client/ui/services/windowmanager.go @@ -21,6 +21,8 @@ type LanguageSubscriber interface { Subscribe() (<-chan preferences.UIPreferences, func()) } +type windowOp func(w *application.WebviewWindow, created bool) + // EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow. const EventTriggerLogin = "trigger-login" @@ -36,6 +38,16 @@ const paintedFallback = 2 * time.Second const headlessTeardownDelay = 2 * time.Second +const ( + windowMain = "main" + windowSettings = "settings" + windowBrowserLogin = "browser-login" + windowSessionExpiration = "session-expiration" + windowInstallProgress = "install-progress" + windowWelcome = "welcome" + windowError = "error" +) + var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950 // WindowHeight is shared by the main and Settings windows. @@ -116,8 +128,9 @@ type WindowManager struct { // hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close. hiddenForLogin []application.Window mu sync.Mutex - createMu sync.Mutex newMain func(startURL string) *application.WebviewWindow + creating map[string]bool + pendingOps map[string][]windowOp ready map[uint]bool showPending map[uint]bool pendingTab map[uint]string @@ -137,6 +150,8 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo translator: translator, prefs: prefs, linuxIcon: linuxIcon, + creating: map[string]bool{}, + pendingOps: map[string][]windowOp{}, ready: map[uint]bool{}, showPending: map[uint]bool{}, pendingTab: map[uint]string{}, @@ -165,7 +180,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo func (s *WindowManager) newSettingsWindow() *application.WebviewWindow { w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ - Name: "settings", + Name: windowSettings, Title: s.title("window.title.settings"), Width: 900, Height: WindowHeight, @@ -186,6 +201,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow { s.forgetWindowLocked(w) s.mu.Unlock() }) + s.armReady(w) return w } @@ -197,63 +213,68 @@ func (s *WindowManager) OpenSettings(tab string) { target = "general" } - w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow) + s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) { + s.mu.Lock() + ready := s.ready[w.ID()] + if !ready { + s.pendingTab[w.ID()] = target + } + s.mu.Unlock() - s.mu.Lock() - ready := s.ready[w.ID()] - if !ready { - s.pendingTab[w.ID()] = target - } - s.mu.Unlock() - - if ready { - s.app.Event.Emit(EventSettingsOpen, target) - } - s.showWhenReady(w) + if ready { + 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() - 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") - opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), 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() - s.browserLogin = s.app.Window.NewWithOptions(opts) - bl := s.browserLogin - 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() - } - s.mu.Unlock() - if userClosed { - s.app.Event.Emit(EventBrowserLoginCancel) - } - }) - s.centerOnCursorScreen(s.browserLogin) - return - } + startURL := "/#/dialog/browser-login" if uri != "" { - s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri)) + startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri) } - s.centerOnCursorScreen(s.browserLogin) - s.browserLogin.Show() - s.browserLogin.Focus() + s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow { + return s.newBrowserLoginWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if created { + s.centerOnCursorScreen(w) + return + } + if uri != "" { + w.SetURL(startURL) + } + s.centerOnCursorScreen(w) + w.Show() + w.Focus() + }) +} + +func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow { + s.hideOtherWindows(windowBrowserLogin) + opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), 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() + w := s.app.Window.NewWithOptions(opts) + w.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 == w + if userClosed { + s.browserLogin = nil + } + s.mu.Unlock() + if userClosed { + s.restoreHiddenWindows() + s.app.Event.Emit(EventBrowserLoginCancel) + } + }) + return w } // BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the @@ -276,48 +297,55 @@ func (s *WindowManager) CloseBrowserLogin() { s.mu.Lock() w := s.browserLogin s.browserLogin = nil + s.mu.Unlock() // The WindowClosing hook no-ops on a programmatic close, so restore here — // but only if a popup was actually open. The frontend calls this even when no // popup was ever shown (e.g. resetDialog() after an early RequestExtend failure, // or connection.ts's catch path), and hiddenForLogin is shared with // OpenInstallProgress, so an unconditional restore could re-show windows a // still-running install-progress is hiding. - if w != nil { - s.restoreHiddenWindowsLocked() - } - s.mu.Unlock() - if w != nil { - w.Close() + if w == nil { + return } + s.restoreHiddenWindows() + 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) { - s.mu.Lock() - defer s.mu.Unlock() startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds) if deadlineUnixMilli > 0 { startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10) } - 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.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow { + return s.newSessionExpirationWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if created { + s.centerOnCursorScreen(w) + return + } + w.SetURL(startURL) + s.centerOnCursorScreen(w) + w.Show() + w.Focus() + }) +} + +func (s *WindowManager) newSessionExpirationWindow(startURL string) *application.WebviewWindow { + opts := DialogWindowOptions(windowSessionExpiration, s.title("window.title.sessionExpiration"), startURL, s.linuxIcon) + opts.Screen = s.getScreenBasedOnCursorPosition() + opts.InitialPosition = application.WindowCentered + w := s.app.Window.NewWithOptions(opts) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.sessionExpiration == w { 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() + } + s.mu.Unlock() + }) + return w } func (s *WindowManager) CloseSessionExpiration() { @@ -347,9 +375,9 @@ func (s *WindowManager) CloseRenewFlow() { } s.hiddenForLogin = kept } - s.restoreHiddenWindowsLocked() s.mu.Unlock() + s.restoreHiddenWindows() // Close after unlock so the re-entrant handlers can take s.mu. if bl != nil { bl.Close() @@ -362,30 +390,36 @@ func (s *WindowManager) CloseRenewFlow() { // 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) { - 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.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow { + return s.newInstallProgressWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if !created { + w.SetURL(startURL) + w.Show() + w.Focus() + } + s.centerWhenReady(w) + }) +} + +func (s *WindowManager) newInstallProgressWindow(startURL string) *application.WebviewWindow { + s.hideOtherWindows(windowInstallProgress) + w := s.app.Window.NewWithOptions( + DialogWindowOptions(windowInstallProgress, s.title("window.title.updating"), startURL, s.linuxIcon), + ) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.installProgress == w { 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) + } + s.mu.Unlock() + s.restoreHiddenWindows() + }) + return w } func (s *WindowManager) CloseInstallProgress() { @@ -400,25 +434,28 @@ func (s *WindowManager) CloseInstallProgress() { // OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close. 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 - 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.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) { + if !created { + w.Show() + w.Focus() + } + s.centerWhenReady(w) + }) +} + +func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow { + opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon) + opts.Width = 420 + opts.InitialPosition = application.WindowCentered + w := s.app.Window.NewWithOptions(opts) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.welcome == w { s.welcome = nil - s.mu.Unlock() - }) - s.centerWhenReady(s.welcome) - return - } - s.welcome.Show() - s.welcome.Focus() - s.centerWhenReady(s.welcome) + } + s.mu.Unlock() + }) + return w } func (s *WindowManager) CloseWelcome() { @@ -439,25 +476,31 @@ func (s *WindowManager) OpenError(title, message, command string) { if ShuttingDown() { return } - s.mu.Lock() - defer s.mu.Unlock() startURL := errorDialogURL(title, message, command) - 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.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow { + return s.newErrorWindow(startURL) + }, func(w *application.WebviewWindow, created bool) { + if !created { + w.SetURL(startURL) + w.Show() + w.Focus() + } + s.centerWhenReady(w) + }) +} + +func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWindow { + w := s.app.Window.NewWithOptions( + DialogWindowOptions(windowError, s.title("window.title.error"), startURL, s.linuxIcon), + ) + w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) { + s.mu.Lock() + if s.errorDialog == w { 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) + } + s.mu.Unlock() + }) + return w } func (s *WindowManager) CloseError() { @@ -478,65 +521,85 @@ func (s *WindowManager) OpenMain() { // 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()) + s.ensureMain("/", func(w *application.WebviewWindow, _ bool) { + s.showWhenReady(w) + }) } // 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 - } + s.ensureMain("/", func(w *application.WebviewWindow, _ bool) { + id := w.ID() + s.mu.Lock() + ready := s.ready[id] + if !ready { + s.pendingEmits[id] = append(s.pendingEmits[id], event) + } + s.mu.Unlock() - id := w.ID() - s.mu.Lock() - ready := s.ready[id] - if !ready { - s.pendingEmits[id] = append(s.pendingEmits[id], event) - } - s.mu.Unlock() - - s.showWhenReady(w) - if ready { - s.app.Event.Emit(event) - } + s.showWhenReady(w) + if ready { + s.app.Event.Emit(event) + } + }) } func (s *WindowManager) MainWindow() *application.WebviewWindow { - w, _ := s.ensureMain("/") - return w + s.mu.Lock() + defer s.mu.Unlock() + return s.mainWindow } -func (s *WindowManager) ensureMain(startURL string) (*application.WebviewWindow, bool) { +func (s *WindowManager) ensureMain(startURL string, op windowOp) { s.mu.Lock() factory := s.newMain s.mu.Unlock() if factory == nil { - return s.ensureWindow(&s.mainWindow, nil) + s.withWindow(windowMain, &s.mainWindow, nil, op) + return } - return s.ensureWindow(&s.mainWindow, func() *application.WebviewWindow { - return factory(startURL) - }) + s.withWindow(windowMain, &s.mainWindow, func() *application.WebviewWindow { + w := factory(startURL) + s.armReady(w) + return w + }, op) } -func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory func() *application.WebviewWindow) (*application.WebviewWindow, bool) { - s.createMu.Lock() - defer s.createMu.Unlock() - +func (s *WindowManager) withWindow(name string, slot **application.WebviewWindow, factory func() *application.WebviewWindow, op windowOp) { s.mu.Lock() - w := *slot - s.mu.Unlock() - if w != nil || factory == nil { - return w, false + if w := *slot; w != nil { + s.mu.Unlock() + op(w, false) + return } + if factory == nil { + s.mu.Unlock() + return + } + if s.creating[name] { + s.pendingOps[name] = append(s.pendingOps[name], op) + s.mu.Unlock() + return + } + s.creating[name] = true + s.mu.Unlock() - w = factory() - s.armReady(w) + w := factory() s.mu.Lock() *slot = w + delete(s.creating, name) + queued := s.pendingOps[name] + delete(s.pendingOps, name) s.mu.Unlock() - return w, true + + if w == nil { + return + } + op(w, true) + for _, queuedOp := range queued { + queuedOp(w, false) + } } func (s *WindowManager) armReady(w *application.WebviewWindow) { @@ -576,24 +639,21 @@ func (s *WindowManager) watchTriggerLogin() { return } - w, created := s.ensureMain("/") - if w == nil { - return - } + s.ensureMain("/", func(w *application.WebviewWindow, created bool) { + s.mu.Lock() + if created { + s.headlessMain = true + } + pending := !s.ready[w.ID()] + if pending { + s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) + } + s.mu.Unlock() - s.mu.Lock() - if created { - s.headlessMain = true - } - pending := !s.ready[w.ID()] - if pending { - s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin) - } - s.mu.Unlock() - - if !pending { - s.app.Event.Emit(EventTriggerLogin) - } + if !pending { + s.app.Event.Emit(EventTriggerLogin) + } + }) }) s.app.Event.On(EventBrowserLoginCancel, func(_ *application.CustomEvent) { @@ -664,9 +724,9 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow { s.mu.Lock() defer s.mu.Unlock() switch name { - case "main": + case windowMain: return s.mainWindow - case "settings": + case windowSettings: return s.settings default: return nil @@ -741,14 +801,12 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) { } func (s *WindowManager) ShowMainAt(url string) { - w, created := s.ensureMain(url) - if w == nil { - return - } - if !created { - w.SetURL(url) - } - s.showWhenReady(w) + s.ensureMain(url, func(w *application.WebviewWindow, created bool) { + if !created { + w.SetURL(url) + } + s.showWhenReady(w) + }) } func (s *WindowManager) SetMainFactory(f func(startURL string) *application.WebviewWindow) { @@ -868,39 +926,46 @@ func (s *WindowManager) retitleAll() { } } -// hideOtherWindowsLocked hides every visible window except keepName, recording -// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu. -func (s *WindowManager) hideOtherWindowsLocked(keepName string) { +func (s *WindowManager) hideOtherWindows(keepName string) { + var hidden []application.Window for _, w := range s.app.Window.GetAll() { - if w == nil || w.Name() == keepName { - continue - } - if !w.IsVisible() { + if w == nil || w.Name() == keepName || !w.IsVisible() { continue } w.Hide() - s.hiddenForLogin = append(s.hiddenForLogin, w) + hidden = append(hidden, w) } + if len(hidden) == 0 { + return + } + s.mu.Lock() + s.hiddenForLogin = append(s.hiddenForLogin, hidden...) + s.mu.Unlock() } -// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked -// (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() { +// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. 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) restoreHiddenWindows() { + s.mu.Lock() + hidden := s.hiddenForLogin + s.hiddenForLogin = nil + mainWindow := s.mainWindow + s.mu.Unlock() + mainRestored := false - for _, w := range s.hiddenForLogin { + for _, w := range hidden { if w == nil { continue } w.Show() - if w == s.mainWindow { + if w == mainWindow { mainRestored = true } } - s.hiddenForLogin = nil - if mainRestored && s.mainWindow != nil { - raiseToForeground(s.mainWindow) + if mainRestored && mainWindow != nil { + raiseToForeground(mainWindow) } } @@ -915,8 +980,11 @@ func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen { return sc } } - if s.mainWindow != nil { - if sc, err := s.mainWindow.GetScreen(); err == nil { + s.mu.Lock() + mainWindow := s.mainWindow + s.mu.Unlock() + if mainWindow != nil { + if sc, err := mainWindow.GetScreen(); err == nil { return sc } } diff --git a/client/ui/services/windowmanager_test.go b/client/ui/services/windowmanager_test.go new file mode 100644 index 000000000..c98d2db2a --- /dev/null +++ b/client/ui/services/windowmanager_test.go @@ -0,0 +1,127 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/wailsapp/wails/v3/pkg/application" +) + +func newTestWindowManager() *WindowManager { + return &WindowManager{ + creating: map[string]bool{}, + pendingOps: map[string][]windowOp{}, + } +} + +func waitDone(t *testing.T, done <-chan struct{}, msg string) { + t.Helper() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal(msg) + } +} + +func TestWithWindowReusesExistingWindow(t *testing.T) { + s := newTestWindowManager() + existing := &application.WebviewWindow{} + slot := existing + factoryCalls := 0 + var got *application.WebviewWindow + created := true + s.withWindow(windowMain, &slot, func() *application.WebviewWindow { + factoryCalls++ + return &application.WebviewWindow{} + }, func(w *application.WebviewWindow, c bool) { + got, created = w, c + }) + require.Equal(t, 0, factoryCalls) + require.Same(t, existing, got) + require.False(t, created) +} + +func TestWithWindowNilFactoryWithoutWindowSkipsOp(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + opCalls := 0 + s.withWindow(windowMain, &slot, nil, func(*application.WebviewWindow, bool) { + opCalls++ + }) + require.Equal(t, 0, opCalls) + require.Nil(t, slot) +} + +func TestWithWindowReentrantCallDuringCreationIsQueued(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + factoryCalls := 0 + var order []string + var factory func() *application.WebviewWindow + factory = func() *application.WebviewWindow { + factoryCalls++ + // Simulates the Windows message pump re-entering the tray click handler + // while WebView2 is still initialising the window being created. + s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) { + order = append(order, fmt.Sprintf("reentrant:%v", created)) + }) + return &application.WebviewWindow{} + } + + done := make(chan struct{}) + go func() { + defer close(done) + s.withWindow(windowMain, &slot, factory, func(_ *application.WebviewWindow, created bool) { + order = append(order, fmt.Sprintf("outer:%v", created)) + }) + }() + waitDone(t, done, "withWindow deadlocked on a re-entrant call during creation") + + require.Equal(t, 1, factoryCalls) + require.Equal(t, []string{"outer:true", "reentrant:false"}, order) + require.NotNil(t, slot) + require.Empty(t, s.creating) + require.Empty(t, s.pendingOps) +} + +func TestWithWindowConcurrentCallersShareOneCreation(t *testing.T) { + s := newTestWindowManager() + var slot *application.WebviewWindow + factoryEntered := make(chan struct{}) + release := make(chan struct{}) + var factoryCalls, opCalls atomic.Int32 + factory := func() *application.WebviewWindow { + factoryCalls.Add(1) + close(factoryEntered) + <-release + return &application.WebviewWindow{} + } + op := func(*application.WebviewWindow, bool) { opCalls.Add(1) } + + first := make(chan struct{}) + go func() { + defer close(first) + s.withWindow(windowSettings, &slot, factory, op) + }() + <-factoryEntered + + second := make(chan struct{}) + go func() { + defer close(second) + s.withWindow(windowSettings, &slot, factory, op) + }() + waitDone(t, second, "second caller blocked while the window was being created") + require.Equal(t, int32(0), opCalls.Load()) + + close(release) + waitDone(t, first, "creator did not finish") + + require.Equal(t, int32(1), factoryCalls.Load()) + require.Equal(t, int32(2), opCalls.Load()) + require.NotNil(t, slot) +} diff --git a/client/ui/tray_click_windows.go b/client/ui/tray_click_windows.go index 17a6dc5df..04c66dd8a 100644 --- a/client/ui/tray_click_windows.go +++ b/client/ui/tray_click_windows.go @@ -4,5 +4,5 @@ package main // Open application window on left click, right click opens the tray menu func bindTrayClick(t *Tray) { - t.tray.OnClick(func() { t.ShowWindow() }) + t.tray.OnClick(func() { go t.ShowWindow() }) }