[client] Separate a window being painted from its frontend being mounted

One flag gated both showing a window and emitting to it, so the fallback timer
set it for a frontend that had not subscribed yet: the queued events were
flushed into a window that could not hear them, losing the login trigger and
the settings tab selection.

Showing is now gated on painted and emitting on mounted, and only a real
frontend report sets mounted. The fallback timer also moved to its own helper
so the runtime-ready hook can rearm it, giving the frontend a full budget to
mount rather than sharing one with webview boot.
This commit is contained in:
Zoltán Papp
2026-09-15 14:45:39 +02:00
parent cae5a73cec
commit ec0b4fb03e
+66 -29
View File
@@ -122,7 +122,12 @@ type WindowManager struct {
mu sync.Mutex
createMu sync.Mutex
newMain func(startURL string) *application.WebviewWindow
ready map[uint]bool
// 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
@@ -147,7 +152,8 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
translator: translator,
prefs: prefs,
linuxIcon: linuxIcon,
ready: map[uint]bool{},
painted: map[uint]bool{},
mounted: map[uint]bool{},
showPending: map[uint]bool{},
pendingTab: map[uint]string{},
pendingEmits: map[uint][]string{},
@@ -212,13 +218,13 @@ func (s *WindowManager) OpenSettings(tab string) {
w, _ := s.ensureWindow(&s.settings, s.newSettingsWindow)
s.mu.Lock()
ready := s.ready[w.ID()]
if !ready {
mounted := s.mounted[w.ID()]
if !mounted {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
if ready {
if mounted {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
@@ -559,14 +565,14 @@ func (s *WindowManager) ShowMainAndEmit(event string) {
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
mounted := s.mounted[id]
if !mounted {
s.pendingEmits[id] = append(s.pendingEmits[id], event)
}
s.mu.Unlock()
s.showWhenReady(w)
if ready {
if mounted {
s.app.Event.Emit(event)
}
}
@@ -608,24 +614,41 @@ func (s *WindowManager) ensureWindow(slot **application.WebviewWindow, factory f
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()
ready := s.ready[id]
painted := s.painted[id]
s.mu.Unlock()
if ready {
if painted {
return
}
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
s.markReady(w)
s.markPainted(w)
})
s.mu.Lock()
if s.ready[id] {
if prev := s.fallbackTimers[id]; prev != nil {
prev.Stop()
}
if s.painted[id] {
timer.Stop()
delete(s.fallbackTimers, id)
} else {
s.fallbackTimers[id] = timer
}
@@ -642,7 +665,8 @@ func (s *WindowManager) watchPainted() {
log.Debugf("ignoring stale painted report for window %q", e.Sender)
return
}
s.markReady(w)
s.markPainted(w)
s.markMounted(w)
})
}
@@ -654,7 +678,7 @@ func (s *WindowManager) watchTriggerLogin() {
s.headlessTimer = nil
}
w := s.mainWindow
ready := w != nil && s.ready[w.ID()]
ready := w != nil && s.mounted[w.ID()]
s.mu.Unlock()
if ready {
return
@@ -669,7 +693,7 @@ func (s *WindowManager) watchTriggerLogin() {
if created {
s.headlessMain = true
}
pending := !s.ready[w.ID()]
pending := !s.mounted[w.ID()]
if pending {
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
}
@@ -730,7 +754,8 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
timer.Stop()
}
delete(s.fallbackTimers, id)
delete(s.ready, id)
delete(s.painted, id)
delete(s.mounted, id)
delete(s.showPending, id)
delete(s.pendingTab, id)
delete(s.pendingEmits, id)
@@ -791,19 +816,35 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
}
}
func (s *WindowManager) markReady(w *application.WebviewWindow) {
func (s *WindowManager) markPainted(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
already := s.ready[id]
s.ready[id] = true
already := s.painted[id]
s.painted[id] = true
wanted := s.showPending[id]
tab, hasTab := s.pendingTab[id]
emits := s.pendingEmits[id]
delete(s.showPending, id)
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
delete(s.fallbackTimers, id)
}
delete(s.showPending, 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()
@@ -816,10 +857,6 @@ func (s *WindowManager) markReady(w *application.WebviewWindow) {
s.app.Event.Emit(EventSettingsOpen, tab)
}
if wanted {
s.showNow(w)
}
for _, event := range emits {
s.app.Event.Emit(event)
}
@@ -832,13 +869,13 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
painted := s.painted[id]
if !painted {
s.showPending[id] = true
}
s.mu.Unlock()
if ready {
if painted {
s.showNow(w)
}
}