mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 06:49:07 +01:00
[client] Correlate painted reports with the window generation that sent them
A painted report carried only the window name, so a late report from a popup that was already closed and replaced marked its replacement ready. The replacement was then shown before its own frontend had rendered, which is the blank-dialog case this flow exists to prevent. Each dialog start URL now carries a monotonic generation token, echoed back by ReadySignal, and a report whose token no longer matches the live window is dropped.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Events } from "@wailsio/runtime";
|
import { Events } from "@wailsio/runtime";
|
||||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||||
|
|
||||||
@@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted";
|
|||||||
|
|
||||||
export const ReadySignal = () => {
|
export const ReadySignal = () => {
|
||||||
const { isReady } = useStatus();
|
const { isReady } = useStatus();
|
||||||
const sent = useRef(false);
|
const [params] = useSearchParams();
|
||||||
|
const generation = params.get("gen") ?? "";
|
||||||
|
const sent = useRef("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReady || sent.current) return;
|
if (!isReady || sent.current === generation) return;
|
||||||
sent.current = true;
|
sent.current = generation;
|
||||||
void Events.Emit(EVENT_WINDOW_PAINTED);
|
void Events.Emit(EVENT_WINDOW_PAINTED, generation);
|
||||||
}, [isReady]);
|
}, [isReady, generation]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ const EventSettingsOpen = "netbird:settings:open"
|
|||||||
|
|
||||||
const EventWindowPainted = "netbird:window-painted"
|
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 paintedFallback = 3 * time.Second
|
||||||
|
|
||||||
const headlessTeardownDelay = 2 * time.Second
|
const headlessTeardownDelay = 2 * time.Second
|
||||||
@@ -124,6 +128,11 @@ type WindowManager struct {
|
|||||||
pendingEmits map[uint][]string
|
pendingEmits map[uint][]string
|
||||||
fallbackTimers map[uint]*time.Timer
|
fallbackTimers map[uint]*time.Timer
|
||||||
afterShow map[uint]func()
|
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
|
headlessMain bool
|
||||||
headlessTimer *time.Timer
|
headlessTimer *time.Timer
|
||||||
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
||||||
@@ -144,6 +153,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
|||||||
pendingEmits: map[uint][]string{},
|
pendingEmits: map[uint][]string{},
|
||||||
fallbackTimers: map[uint]*time.Timer{},
|
fallbackTimers: map[uint]*time.Timer{},
|
||||||
afterShow: map[uint]func(){},
|
afterShow: map[uint]func(){},
|
||||||
|
generation: map[string]uint64{},
|
||||||
}
|
}
|
||||||
s.watchPainted()
|
s.watchPainted()
|
||||||
s.watchTriggerLogin()
|
s.watchTriggerLogin()
|
||||||
@@ -223,7 +233,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
|||||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||||
}
|
}
|
||||||
s.hideOtherWindowsLocked("browser-login")
|
s.hideOtherWindowsLocked("browser-login")
|
||||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
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.
|
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
||||||
opts.AlwaysOnTop = false
|
opts.AlwaysOnTop = false
|
||||||
opts.InitialPosition = application.WindowCentered
|
opts.InitialPosition = application.WindowCentered
|
||||||
@@ -257,7 +267,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
|||||||
bl := s.browserLogin
|
bl := s.browserLogin
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if uri != "" {
|
if uri != "" {
|
||||||
bl.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
bl.SetURL(s.stampGeneration("browser-login", "/#/dialog/browser-login?uri="+url.QueryEscape(uri)))
|
||||||
}
|
}
|
||||||
s.centerOnCursorScreen(bl)
|
s.centerOnCursorScreen(bl)
|
||||||
log.Debugf("browser-login popup reused")
|
log.Debugf("browser-login popup reused")
|
||||||
@@ -330,7 +340,7 @@ func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.sessionExpiration == nil {
|
if s.sessionExpiration == nil {
|
||||||
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
|
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), s.stampGenerationLocked("session-expiration", startURL), s.linuxIcon)
|
||||||
opts.Screen = s.getScreenBasedOnCursorPosition()
|
opts.Screen = s.getScreenBasedOnCursorPosition()
|
||||||
opts.InitialPosition = application.WindowCentered
|
opts.InitialPosition = application.WindowCentered
|
||||||
w := s.app.Window.NewWithOptions(opts)
|
w := s.app.Window.NewWithOptions(opts)
|
||||||
@@ -351,7 +361,7 @@ func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int
|
|||||||
}
|
}
|
||||||
w := s.sessionExpiration
|
w := s.sessionExpiration
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
w.SetURL(startURL)
|
w.SetURL(s.stampGeneration("session-expiration", startURL))
|
||||||
s.centerOnCursorScreen(w)
|
s.centerOnCursorScreen(w)
|
||||||
s.showWhenReady(w)
|
s.showWhenReady(w)
|
||||||
}
|
}
|
||||||
@@ -406,7 +416,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
|||||||
if s.installProgress == nil {
|
if s.installProgress == nil {
|
||||||
s.hideOtherWindowsLocked("install-progress")
|
s.hideOtherWindowsLocked("install-progress")
|
||||||
w := s.app.Window.NewWithOptions(
|
w := s.app.Window.NewWithOptions(
|
||||||
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon),
|
DialogWindowOptions("install-progress", s.title("window.title.updating"), s.stampGenerationLocked("install-progress", startURL), s.linuxIcon),
|
||||||
)
|
)
|
||||||
s.installProgress = w
|
s.installProgress = w
|
||||||
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||||
@@ -428,7 +438,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
|||||||
}
|
}
|
||||||
w := s.installProgress
|
w := s.installProgress
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
w.SetURL(startURL)
|
w.SetURL(s.stampGeneration("install-progress", startURL))
|
||||||
s.showWhenReady(w)
|
s.showWhenReady(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +462,7 @@ func (s *WindowManager) CloseInstallProgress() {
|
|||||||
func (s *WindowManager) OpenWelcome() {
|
func (s *WindowManager) OpenWelcome() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.welcome == nil {
|
if s.welcome == nil {
|
||||||
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
|
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), s.stampGenerationLocked("welcome", "/#/dialog/welcome"), s.linuxIcon)
|
||||||
opts.Width = 420
|
opts.Width = 420
|
||||||
opts.InitialPosition = application.WindowCentered
|
opts.InitialPosition = application.WindowCentered
|
||||||
w := s.app.Window.NewWithOptions(opts)
|
w := s.app.Window.NewWithOptions(opts)
|
||||||
@@ -497,7 +507,7 @@ func (s *WindowManager) OpenError(title, message, command string) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.errorDialog == nil {
|
if s.errorDialog == nil {
|
||||||
w := s.app.Window.NewWithOptions(
|
w := s.app.Window.NewWithOptions(
|
||||||
DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon),
|
DialogWindowOptions("error", s.title("window.title.error"), s.stampGenerationLocked("error", startURL), s.linuxIcon),
|
||||||
)
|
)
|
||||||
s.errorDialog = w
|
s.errorDialog = w
|
||||||
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||||
@@ -515,7 +525,7 @@ func (s *WindowManager) OpenError(title, message, command string) {
|
|||||||
}
|
}
|
||||||
w := s.errorDialog
|
w := s.errorDialog
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
w.SetURL(startURL)
|
w.SetURL(s.stampGeneration("error", startURL))
|
||||||
s.showWhenReady(w)
|
s.showWhenReady(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,9 +634,15 @@ func (s *WindowManager) armReady(w *application.WebviewWindow) {
|
|||||||
|
|
||||||
func (s *WindowManager) watchPainted() {
|
func (s *WindowManager) watchPainted() {
|
||||||
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
||||||
if w := s.windowByName(e.Sender); w != nil {
|
w := s.windowByName(e.Sender)
|
||||||
s.markReady(w)
|
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.markReady(w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +735,7 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
|||||||
delete(s.pendingTab, id)
|
delete(s.pendingTab, id)
|
||||||
delete(s.pendingEmits, id)
|
delete(s.pendingEmits, id)
|
||||||
delete(s.afterShow, id)
|
delete(s.afterShow, id)
|
||||||
|
delete(s.generation, w.Name())
|
||||||
|
|
||||||
kept := s.hiddenForLogin[:0]
|
kept := s.hiddenForLogin[:0]
|
||||||
for _, hidden := range s.hiddenForLogin {
|
for _, hidden := range s.hiddenForLogin {
|
||||||
@@ -729,6 +746,28 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
|||||||
s.hiddenForLogin = kept
|
s.hiddenForLogin = 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 {
|
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -1027,5 +1066,37 @@ func errorDialogURL(title, message, command string) string {
|
|||||||
return startURL
|
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.
|
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
|
||||||
func u32ptr(v uint32) *uint32 { return &v }
|
func u32ptr(v uint32) *uint32 { return &v }
|
||||||
|
|||||||
Reference in New Issue
Block a user