Merge origin/main into fix/browser-login-popup-show-from-go

main brought #7449, which rewrote WindowManager around withWindow and
closeWindow so no Wails call runs under mu and creation is serialised per
slot. Both sides changed the same functions, so the resolution takes main's
structure and re-expresses this branch on top of it:

- Dialogs are created with armReady in their factories and shown from Go via
  showWhenReady in the withWindow op; the browser opens from an afterShow
  callback once the login popup is visible. windowByName knows every dialog.
- Hidden windows carry the popup that hid them; restoreHiddenWindows takes
  that owner and the restore-generation guard from #7449 is kept per owner.
  restoreAndClose became restoringCloser(owner), used by CloseBrowserLogin,
  CloseRenewFlow and now CloseInstallProgress, whose WindowClosing hook only
  restores on a user close.
- ready is split into painted and mounted; the fallback timer starts at
  creation and is rearmed from the WindowRuntimeReady hook.
- Painted reports carry the generation stamped into the dialog start URL.
  useAutoSizeWindow, which is what the dialogs actually emit from, now echoes
  it; without that every dialog report would have been dropped as stale.

The withWindow tests from main are kept as is, with the CloseRenewFlow test
seeding an owner-tagged entry.
This commit is contained in:
Zoltán Papp
2026-09-15 15:13:59 +02:00
293 changed files with 15049 additions and 2790 deletions
+99
View File
@@ -0,0 +1,99 @@
//go:build linux && cgo && !android && !ios
package services
import (
"os"
"path/filepath"
"strings"
)
// setAppAppearance points GTK at the light or dark variant of the current theme
// so the decorations match the webview. Without it a forced Light theme keeps
// dark decorations on a dark desktop, and the reverse.
//
// The theme name is switched, not just gtk-application-prefer-dark-theme:
// desktops such as Ubuntu implement dark mode as a separate theme (Yaru-dark),
// which that flag cannot lighten. The flag is still set for themes that do
// carry both variants under one name. Both are per-process settings, so this
// changes only our own decorations; GTK re-reads the desktop value on a change,
// which is why Theme.apply re-asserts. Must run on the main thread.
//
// GTK styling is app-wide, which is why this is separate from
// setWindowAppearance: it must be applied even when no window exists yet, since
// windows created later inherit it rather than carrying it in their options.
func setAppAppearance(dark bool) {
target := baseGtkTheme(gtkThemeName())
if dark {
if variant, ok := darkGtkVariant(target); ok {
target = variant
}
}
// An unknown name would leave GTK with no theme at all, so fall back to
// changing nothing and let the prefer-dark flag do what it can.
if target != "" && !gtkThemeExists(target) {
target = ""
}
applyGtkTheme(target, dark)
}
// baseGtkTheme strips a dark-variant suffix, so "Yaru-dark" becomes "Yaru".
func baseGtkTheme(name string) string {
for _, suffix := range []string{"-dark", "-Dark"} {
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
return name[:len(name)-len(suffix)]
}
}
return name
}
// darkGtkVariant reports the installed dark counterpart of a base theme name.
// Themes that carry both variants under one name have none, and rely on
// gtk-application-prefer-dark-theme instead.
func darkGtkVariant(base string) (string, bool) {
if base == "" {
return "", false
}
for _, suffix := range []string{"-dark", "-Dark"} {
if candidate := base + suffix; gtkThemeExists(candidate) {
return candidate, true
}
}
return "", false
}
// gtkThemeExists reports whether a theme of that name is installed, searching
// the same locations GTK does.
func gtkThemeExists(name string) bool {
if name == "" {
return false
}
for _, dir := range gtkThemeDirs() {
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
return true
}
}
return false
}
func gtkThemeDirs() []string {
var dirs []string
if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".themes"))
}
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
dirs = append(dirs, filepath.Join(dataHome, "themes"))
} else if home, err := os.UserHomeDir(); err == nil {
dirs = append(dirs, filepath.Join(home, ".local", "share", "themes"))
}
dataDirs := os.Getenv("XDG_DATA_DIRS")
if dataDirs == "" {
dataDirs = "/usr/local/share:/usr/share"
}
for _, dir := range strings.Split(dataDirs, ":") {
if dir != "" {
dirs = append(dirs, filepath.Join(dir, "themes"))
}
}
return dirs
}
@@ -0,0 +1,63 @@
//go:build linux && cgo && !android && !ios
package services
/*
// The GTK major version is the only difference between the two Linux builds, so
// it is selected by these two directives rather than by keeping a second copy of
// this file per version: the C below and the Go wrappers under it are identical
// for GTK3 and GTK4, and both resolve <gtk/gtk.h> through pkg-config.
#cgo gtk3 pkg-config: gtk+-3.0
#cgo !gtk3 pkg-config: gtk4
#include <stdlib.h>
#include <gtk/gtk.h>
static char *nbGetGtkThemeName(void) {
GtkSettings *settings = gtk_settings_get_default();
if (settings == NULL) {
return NULL;
}
char *name = NULL;
g_object_get(settings, "gtk-theme-name", &name, NULL);
return name;
}
// name may be NULL to leave the theme name untouched.
static void nbSetGtkTheme(const char *name, int dark) {
GtkSettings *settings = gtk_settings_get_default();
if (settings == NULL) {
return;
}
if (name != NULL && name[0] != '\0') {
g_object_set(settings, "gtk-theme-name", name, NULL);
}
g_object_set(settings, "gtk-application-prefer-dark-theme", dark ? TRUE : FALSE, NULL);
}
static void nbFreeGtkString(char *s) { g_free(s); }
*/
import "C"
import "unsafe"
func gtkThemeName() string {
c := C.nbGetGtkThemeName()
if c == nil {
return ""
}
defer C.nbFreeGtkString(c)
return C.GoString(c)
}
func applyGtkTheme(name string, dark bool) {
var cName *C.char
if name != "" {
cName = C.CString(name)
defer C.free(unsafe.Pointer(cName))
}
var forced C.int
if dark {
forced = 1
}
C.nbSetGtkTheme(cName, forced)
}
@@ -0,0 +1,7 @@
//go:build !(linux && cgo)
package services
// setAppAppearance is a no-op where the platform has no app-wide appearance to
// set; macOS and Windows theme each window instead, via setWindowAppearance.
func setAppAppearance(bool) {}
+4
View File
@@ -31,6 +31,10 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
return s.store.SetViewMode(mode)
}
func (s *Preferences) SetTheme(_ context.Context, theme preferences.Theme) error {
return s.store.SetTheme(theme)
}
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
return s.store.SetOnboardingCompleted(done)
}
+8 -30
View File
@@ -11,37 +11,18 @@ import (
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
type MDMFields struct {
ManagementURL string `json:"managementURL"`
PreSharedKey bool `json:"preSharedKey"`
WireguardPort bool `json:"wireguardPort"`
RosenpassEnabled bool `json:"rosenpassEnabled"`
RosenpassPermissive bool `json:"rosenpassPermissive"`
DisableClientRoutes bool `json:"disableClientRoutes"`
DisableServerRoutes bool `json:"disableServerRoutes"`
AllowServerSSH *bool `json:"allowServerSSH"`
DisableAutoConnect bool `json:"disableAutoConnect"`
DisableAutostart bool `json:"disableAutostart"`
BlockInbound bool `json:"blockInbound"`
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
DisableAdvancedView bool `json:"disableAdvancedView"`
}
// MDMFields is the shared per-key MDM enforcement snapshot; see mdm.Fields.
type MDMFields = mdm.Fields
type Features struct {
DisableProfiles bool `json:"disableProfiles"`
DisableNetworks bool `json:"disableNetworks"`
DisableUpdateSettings bool `json:"disableUpdateSettings"`
}
// Features is the shared feature-gate snapshot; see mdm.Features.
type Features = mdm.Features
type Restrictions struct {
MDM MDMFields `json:"mdm"`
Features Features `json:"features"`
}
// Restrictions is the shared UI enforcement snapshot; see mdm.Restrictions.
type Restrictions = mdm.Restrictions
// Privilege tells the frontend whether this process may perform the changes the
// daemon restricts to root/administrator, whether it can ask the operating
@@ -383,7 +364,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
},
}
applyMDMRestrictions(&r.MDM, cfgResp)
r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView()
r.MDM.DisableAdvancedView = featResp.DisableAdvancedView
return r, nil
}
@@ -411,9 +392,6 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
if v.Field(i).Kind() != reflect.Bool {
continue
}
if t.Field(i).Name == "DisableAdvancedView" {
continue
}
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
v.Field(i).SetBool(true)
}
+194
View File
@@ -0,0 +1,194 @@
//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"sync"
"sync/atomic"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"github.com/netbirdio/netbird/client/ui/preferences"
)
// EventSystemThemeChanged fires when the OS appearance flips, payload SystemTheme.
// The frontend resolves the "system" preference against it.
const EventSystemThemeChanged = "netbird:system-theme:changed"
// SystemTheme is the EventSystemThemeChanged payload.
type SystemTheme struct {
Dark bool `json:"dark"`
}
// Theme keeps native window background colours in step with the persisted
// theme preference so no window flashes the wrong surface before the webview
// paints. The frontend applies the matching .dark class via ThemeContext.
type Theme struct {
app *application.App
store *preferences.Store
// mu serializes apply: concurrent callers could otherwise enqueue a stale
// pref's native updates after a newer one's.
mu sync.Mutex
// started gates the main-thread dispatch in apply: Run installs the platform
// layer InvokeAsync needs, and the store subscription can fire before that.
started atomic.Bool
}
// NewTheme wires the store subscription and OS theme-change listener. Call
// before any window is created so creation-time colours are already themed.
func NewTheme(app *application.App, store *preferences.Store) *Theme {
t := &Theme{app: app, store: store}
pref := store.Get().Theme
setAppearance(pref, resolveDark(pref, app.Env.IsDarkMode()))
// Window creation resolves through this rather than the seed above, which
// is wrong until Run installs the platform layer: Env.IsDarkMode reports
// light before that, so a "system" launch on a dark OS would build the
// first window light. The ApplicationStarted apply below cannot be relied
// on to land first because Wails runs each listener in its own goroutine.
// One store read backs both fields, so the snapshot is always self-consistent.
setAppearanceResolver(func() Appearance {
p := t.store.Get().Theme
return Appearance{Pref: p, Dark: resolveDark(p, t.app.Env.IsDarkMode())}
})
ch, _ := store.Subscribe()
go func() {
var last preferences.Theme
for p := range ch {
if p.Theme == last {
continue
}
last = p.Theme
t.apply()
}
}()
// Re-apply on every OS flip, not just for ThemeSystem: Windows re-evaluates
// process-level theme state on WM_SETTINGCHANGE, so a forced theme has to be
// re-asserted or the native chrome drifts to the OS appearance. The event's
// own IsDarkMode is deliberately unused: Wails runs each application event
// handler in its own goroutine, so two rapid flips race, and apply re-reads
// the appearance under mu instead.
app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(*application.ApplicationEvent) {
t.apply()
})
// Startup is split in two because Wails runs every application-event
// listener in its own goroutine, so a listener cannot be ordered against the
// one that opens the first-launch window. Hooks can: they run sequentially,
// in registration order, and all of them before any listener is spawned.
//
// The app-wide GTK theme goes in the hook because it is the part a window
// must not be created without. On Linux it draws the decorations and
// application.LinuxWindow carries no theme of its own, so a window built
// before it lands shows OS-coloured decorations until it does. It is applied
// synchronously for the same reason -- returning from the hook has to mean
// the theme is live. This relies on the listener below existing: Wails skips
// an event's hooks entirely when it has no listeners.
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
t.started.Store(true)
t.syncAppAppearance()
})
// The rest of the startup apply. Env.IsDarkMode is a stub until the platform
// layer is up, so re-resolve once the app has started or a "system" launch on
// a light OS stays seeded dark.
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
t.apply()
})
return t
}
// syncAppAppearance applies the app-wide appearance and waits for the UI thread
// to have done it. Use it where a window is about to be created and must not be
// built against the OS appearance: apply dispatches its own native work
// asynchronously, so on Linux the GTK theme behind the decorations can otherwise
// land after the window exists.
//
// No-op before the app has started, where InvokeSync has no platform layer to
// dispatch to. Reads the appearance under mu like apply, so the two cannot
// interleave into a torn update.
func (t *Theme) syncAppAppearance() {
if !t.started.Load() {
return
}
t.mu.Lock()
defer t.mu.Unlock()
pref := t.store.Get().Theme
dark := resolveDark(pref, t.app.Env.IsDarkMode())
setAppearance(pref, dark)
application.InvokeSync(func() { setAppAppearance(dark) })
}
// SystemDarkMode reports the OS appearance; bound so the frontend can resolve
// the "system" preference from the same source as the native layer.
func (t *Theme) SystemDarkMode(_ context.Context) (bool, error) {
return t.app.Env.IsDarkMode(), nil
}
// resolveDark maps a preference to an effective appearance against a system
// reading the caller already took.
func resolveDark(pref preferences.Theme, systemDark bool) bool {
switch pref {
case preferences.ThemeDark:
return true
case preferences.ThemeLight:
return false
default:
return systemDark
}
}
// apply recomputes the effective appearance, re-tints every live window
// (including the macOS NSWindow appearance so the frame matches the webview)
// and publishes the system appearance the frontend resolves "system" against.
//
// Everything runs under mu and reads the appearance here rather than taking it
// from a caller, so a later apply always carries the fresher state and the
// frontend event is ordered by the same lock as the native assignments. Emit
// only appends to a FIFO mailbox, so holding mu across it cannot block.
//
// The OS is read exactly once per update and the resolved value is passed on to
// the background and the native chrome, so those cannot land on either side of
// an OS flip that happens mid-apply. The event carries the raw system reading,
// not the resolved one, because the frontend resolves "system" itself.
func (t *Theme) apply() {
t.mu.Lock()
defer t.mu.Unlock()
pref := t.store.Get().Theme
systemDark := t.app.Env.IsDarkMode()
dark := resolveDark(pref, systemDark)
setAppearance(pref, dark)
t.app.Event.Emit(EventSystemThemeChanged, SystemTheme{Dark: systemDark})
// Before Run there is no platform layer for InvokeAsync to dispatch to.
// Windows created later read the globals set above.
if !t.started.Load() {
return
}
colour := windowBackgroundColour(dark)
// Re-tint on the UI thread and resolve each native handle there. Window
// teardown (markAsDestroyed then impl.close) runs as UI-thread work too, so
// a window closed meanwhile is either gone from GetAll or yields a nil
// handle -- never a freed handle the OS may already have reused.
application.InvokeAsync(func() {
// App-wide first, and unconditionally: on Linux this is the GTK theme
// that draws the decorations, and it must be set even with no window
// open because later windows inherit it instead of carrying it.
setAppAppearance(dark)
for _, w := range t.app.Window.GetAll() {
if w == nil {
continue
}
w.SetBackgroundColour(colour)
setWindowAppearance(w.NativeWindow(), pref, dark)
}
})
}
@@ -0,0 +1,53 @@
package services
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework AppKit
#import <AppKit/AppKit.h>
// forced < 0: follow the OS (appearance nil); 0: light; 1: dark.
//
// Assigns directly rather than dispatching: Theme.apply already runs this on
// the main thread. Deferring would outlive the caller's check that the window
// is alive, and the __bridge cast does not retain it, so the block could touch
// a freed NSWindow.
static void nbSetWindowAppearance(void *nsWindow, int forced) {
NSWindow *window = (__bridge NSWindow *)nsWindow;
if (forced < 0) {
window.appearance = nil;
} else {
NSAppearanceName name = forced == 1 ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua;
window.appearance = [NSAppearance appearanceNamed:name];
}
}
*/
import "C"
import (
"unsafe"
"github.com/netbirdio/netbird/client/ui/preferences"
)
// setWindowAppearance pins the NSWindow appearance to the forced theme, or
// hands it back to the OS for ThemeSystem. Without this, a window created
// under one OS appearance keeps its dark/light frame after a manual theme
// flip, leaving a mismatched border around the webview. Must run on the main
// thread, which Theme.apply guarantees.
//
// The resolved appearance is unused: for ThemeSystem a nil NSAppearance lets
// AppKit track the OS itself, which cannot drift from a snapshot we took.
func setWindowAppearance(nsWindow unsafe.Pointer, pref preferences.Theme, _ bool) {
if nsWindow == nil {
return
}
forced := C.int(-1)
switch pref {
case preferences.ThemeLight:
forced = 0
case preferences.ThemeDark:
forced = 1
}
C.nbSetWindowAppearance(nsWindow, forced)
}
@@ -0,0 +1,16 @@
//go:build !darwin && !windows && !android && !ios && !freebsd && !js
package services
import (
"unsafe"
"github.com/netbirdio/netbird/client/ui/preferences"
)
// setWindowAppearance is a no-op wherever there is no per-window appearance to
// set, which is every target this file covers. On Linux the appearance is real
// but app-wide, so setAppAppearance owns it instead; on the remaining Unix
// targets there is no native theming to apply at all and setAppAppearance is
// itself a stub (appappearance_other.go).
func setWindowAppearance(unsafe.Pointer, preferences.Theme, bool) {}
@@ -0,0 +1,54 @@
package services
import (
"unsafe"
"github.com/wailsapp/wails/v3/pkg/w32"
"github.com/netbirdio/netbird/client/ui/preferences"
)
// setWindowAppearance re-themes a live window's chrome; Wails only does this
// itself on OS flips for SystemDefault windows.
//
// Must run on the UI thread, which Theme.apply guarantees: the uxtheme and
// repaint calls behind w32.SetTheme belong to the window's thread, and hwnd is
// only known live while we hold that thread. Re-dispatching here would let the
// window be destroyed first and hand these writes a reused handle.
//
// dark is the appearance Theme.apply already resolved. Re-reading the OS here
// would let the chrome land on the other side of an OS flip from the window
// background and the webview.
func setWindowAppearance(hwnd unsafe.Pointer, _ preferences.Theme, dark bool) {
if hwnd == nil || !w32.SupportsThemes() || w32.IsCurrentlyHighContrastMode() {
return
}
h := uintptr(hwnd)
w32.SetTheme(h, dark)
// After SetTheme, not before: its menu helper regates dark on the
// process-level ShouldAppsUseDarkMode and rewrites the per-window opt-in
// with that gated value, so forcing Dark on a light OS would lose it --
// and builds below 18985 need the opt-in for the pre-20H1 dark frame. The
// gated menu theme name is left alone on purpose: these windows carry no
// native menu, and popup-menu text follows the process policy, so forcing
// it dark gives dark text on dark.
if w32.AllowDarkModeForWindow != nil {
w32.AllowDarkModeForWindow(h, dark)
}
chrome := microsoftWindowsLightTheme
if dark {
chrome = microsoftWindowsDarkTheme
}
if chrome.TitleBarColour != nil {
w32.SetTitleBarColour(h, *chrome.TitleBarColour)
}
if chrome.TitleTextColour != nil {
w32.SetTitleTextColour(h, *chrome.TitleTextColour)
}
if chrome.BorderColour != nil {
w32.SetBorderColour(h, *chrome.BorderColour)
}
}
File diff suppressed because it is too large Load Diff
+467 -72
View File
@@ -3,13 +3,25 @@
package services
import (
"fmt"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"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{},
pendingClose: map[string]windowCloser{},
restoreGen: map[string]uint64{},
generation: map[string]uint64{},
}
}
type fakeWindow struct {
name string
visible bool
@@ -37,20 +49,22 @@ func (f *fakeWindow) IsVisible() bool { return f.visible }
func (f *fakeWindow) Name() string { return f.name }
func newTestWindowManager(windows ...*fakeWindow) (*WindowManager, *int) {
raised := 0
s := &WindowManager{
generation: map[string]uint64{},
allWindows: func() []hideableWindow {
all := make([]hideableWindow, 0, len(windows))
for _, w := range windows {
all = append(all, w)
}
return all
},
raiseMain: func() { raised++ },
type fakeDesktop struct {
windows []*fakeWindow
raised int
}
func newFakeDesktop(s *WindowManager, windows ...*fakeWindow) *fakeDesktop {
d := &fakeDesktop{windows: windows}
s.allWindows = func() []hideableWindow {
all := make([]hideableWindow, 0, len(d.windows))
for _, w := range d.windows {
all = append(all, w)
}
return all
}
return s, &raised
s.raiseMain = func() { d.raised++ }
return d
}
func ownersOf(hidden []hiddenWindow) []string {
@@ -61,93 +75,474 @@ func ownersOf(hidden []hiddenWindow) []string {
return owners
}
func TestHideOtherWindowsLockedSkipsKeepNameAndInvisible(t *testing.T) {
main := newFakeWindow("main")
settings := newFakeWindow("settings")
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)
}
func TestWithWindowOpsQueuedDuringCreationRunInArrivalOrder(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
var order []string
record := func(label string) windowOp {
return func(_ *application.WebviewWindow, created bool) {
order = append(order, fmt.Sprintf("%s:%v", label, created))
}
}
var factory func() *application.WebviewWindow
factory = func() *application.WebviewWindow {
s.withWindow(windowMain, &slot, factory, func(w *application.WebviewWindow, created bool) {
record("a")(w, created)
// Arrives while the creator is still draining the queue: it must not
// jump ahead of "b" through the existing-window fast path.
s.withWindow(windowMain, &slot, factory, record("c"))
})
s.withWindow(windowMain, &slot, factory, record("b"))
return &application.WebviewWindow{}
}
done := make(chan struct{})
go func() {
defer close(done)
s.withWindow(windowMain, &slot, factory, record("outer"))
}()
waitDone(t, done, "withWindow deadlocked while draining queued operations")
require.Equal(t, []string{"outer:true", "a:false", "b:false", "c:false"}, order)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
}
func TestWithWindowFactoryPanicReleasesCreation(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
func() {
defer func() { require.NotNil(t, recover()) }()
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
panic("factory failed")
}, func(*application.WebviewWindow, bool) {})
}()
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
require.Nil(t, slot)
created := false
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
return &application.WebviewWindow{}
}, func(_ *application.WebviewWindow, c bool) {
created = c
})
require.True(t, created)
require.NotNil(t, slot)
}
func TestWithWindowNilFromFactoryReleasesCreation(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
opCalls := 0
s.withWindow(windowMain, &slot, func() *application.WebviewWindow {
return nil
}, func(*application.WebviewWindow, bool) {
opCalls++
})
require.Equal(t, 0, opCalls)
require.Empty(t, s.creating)
require.Nil(t, slot)
}
func TestCloseWindowDuringCreationDefersCloseAndSkipsOps(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
created := &application.WebviewWindow{}
opCalls, closeCalls := 0, 0
var closed *application.WebviewWindow
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(w *application.WebviewWindow) {
closeCalls++
closed = w
})
require.Equal(t, 0, closeCalls)
return created
}, func(*application.WebviewWindow, bool) {
opCalls++
})
require.Equal(t, 0, opCalls)
require.Equal(t, 1, closeCalls)
require.Same(t, created, closed)
require.Nil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
require.Empty(t, s.pendingClose)
factoryCalls := 0
reopened := false
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
factoryCalls++
return &application.WebviewWindow{}
}, func(_ *application.WebviewWindow, c bool) {
reopened = c
})
require.Equal(t, 1, factoryCalls)
require.True(t, reopened)
require.NotNil(t, slot)
}
func TestCloseWindowDuringDrainStopsRemainingOps(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
var order []string
closeCalls := 0
var factory func() *application.WebviewWindow
factory = func() *application.WebviewWindow {
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "a")
s.closeWindow(windowWelcome, &slot, func(*application.WebviewWindow) { closeCalls++ })
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "c")
})
})
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "b")
})
return &application.WebviewWindow{}
}
s.withWindow(windowWelcome, &slot, factory, func(*application.WebviewWindow, bool) {
order = append(order, "outer")
})
// "b" was queued before the close and "c" after it; a close supersedes both
// rather than showing a window that is about to be destroyed.
require.Equal(t, []string{"outer", "a"}, order)
require.Equal(t, 1, closeCalls)
require.Nil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingOps)
require.Empty(t, s.pendingClose)
}
func TestCloseWindowWithoutWindowSkipsCloser(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
calls := 0
s.closeWindow(windowBrowserLogin, &slot, func(*application.WebviewWindow) { calls++ })
require.Equal(t, 0, calls)
require.Nil(t, slot)
require.Empty(t, s.pendingClose)
}
func TestCloseWindowWithExistingWindowRunsCloser(t *testing.T) {
s := newTestWindowManager()
existing := &application.WebviewWindow{}
slot := existing
var got *application.WebviewWindow
s.closeWindow(windowError, &slot, func(w *application.WebviewWindow) { got = w })
require.Same(t, existing, got)
require.Nil(t, slot)
require.Empty(t, s.pendingClose)
}
func TestWithWindowNilFromFactoryDropsPendingClose(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
closeCalls := 0
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { closeCalls++ })
return nil
}, func(*application.WebviewWindow, bool) {})
require.Equal(t, 0, closeCalls)
require.Nil(t, slot)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestWithWindowFactoryPanicDropsPendingClose(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
closeCalls := 0
func() {
defer func() { require.NotNil(t, recover()) }()
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { closeCalls++ })
panic("factory failed")
}, func(*application.WebviewWindow, bool) {})
}()
require.Equal(t, 0, closeCalls)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestCloseWindowKeepsFirstDeferredCloser(t *testing.T) {
s := newTestWindowManager()
var slot *application.WebviewWindow
var ran []string
s.withWindow(windowError, &slot, func() *application.WebviewWindow {
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { ran = append(ran, "first") })
s.closeWindow(windowError, &slot, func(*application.WebviewWindow) { ran = append(ran, "second") })
return &application.WebviewWindow{}
}, func(*application.WebviewWindow, bool) {})
require.Equal(t, []string{"first"}, ran)
require.Nil(t, slot)
require.Empty(t, s.pendingClose)
}
func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testing.T) {
s := newTestWindowManager()
s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow {
s.CloseRenewFlow()
// Seeded after the call so the deferred closer, not CloseRenewFlow's own
// immediate restore, is what has to drain it. A nil entry is skipped by
// restoreHiddenWindows, so no Wails window is needed.
s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}}
return &application.WebviewWindow{}
}, func(*application.WebviewWindow, bool) {})
require.Nil(t, s.browserLogin)
require.Empty(t, s.hiddenWindows)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}
func TestHideOtherWindowsSkipsKeepNameAndInvisible(t *testing.T) {
main := newFakeWindow(windowMain)
settings := newFakeWindow(windowSettings)
settings.visible = false
popup := newFakeWindow("browser-login")
s, _ := newTestWindowManager(main, settings, popup)
popup := newFakeWindow(windowBrowserLogin)
s := newTestWindowManager()
newFakeDesktop(s, main, settings, popup)
s.hideOtherWindowsLocked("browser-login")
s.hideOtherWindows(windowBrowserLogin)
assert.False(t, main.visible)
assert.Equal(t, 1, main.hidden)
assert.Equal(t, 0, settings.hidden, "an already hidden window must not be recorded")
assert.Equal(t, 0, popup.hidden, "the popup itself must stay visible")
assert.Equal(t, []string{"browser-login"}, ownersOf(s.hiddenWindows))
require.False(t, main.visible)
require.Equal(t, 1, main.hidden)
require.Equal(t, 0, settings.hidden, "an already hidden window must not be recorded")
require.Equal(t, 0, popup.hidden, "the popup itself must stay visible")
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
}
func TestInstallDuringLoginKeepsMainHiddenUntilLoginCloses(t *testing.T) {
main := newFakeWindow("main")
login := newFakeWindow("browser-login")
install := newFakeWindow("install-progress")
s, raised := newTestWindowManager(main, login, install)
main := newFakeWindow(windowMain)
login := newFakeWindow(windowBrowserLogin)
install := newFakeWindow(windowInstallProgress)
install.visible = false
s := newTestWindowManager()
d := newFakeDesktop(s, main, login, install)
s.hideOtherWindowsLocked("browser-login")
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
require.False(t, install.visible)
install.visible = true
s.hideOtherWindowsLocked("install-progress")
assert.False(t, login.visible, "the login popup is hidden by the install popup")
s.hideOtherWindows(windowInstallProgress)
require.False(t, login.visible, "the install popup hides the login popup")
s.restoreHiddenWindowsLocked("install-progress")
assert.True(t, login.visible, "the install popup restores the login popup it hid")
assert.False(t, main.visible, "the main window stays hidden for the login popup")
assert.Equal(t, 0, *raised)
assert.Equal(t, []string{"browser-login", "browser-login"}, ownersOf(s.hiddenWindows))
s.restoreHiddenWindows(windowInstallProgress)
require.True(t, login.visible, "the install popup restores the login popup it hid")
require.False(t, main.visible, "the main window stays hidden for the login popup")
require.Equal(t, 0, d.raised)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
s.restoreHiddenWindowsLocked("browser-login")
assert.True(t, main.visible)
assert.Equal(t, 1, *raised, "restoring the main window raises it above the SSO browser")
assert.Empty(t, s.hiddenWindows)
s.restoreHiddenWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, d.raised, "restoring the main window raises it above the SSO browser")
require.Empty(t, s.hiddenWindows)
}
func TestRestoreHiddenWindowsLockedUnknownOwnerKeepsEverything(t *testing.T) {
main := newFakeWindow("main")
s, raised := newTestWindowManager(main)
s.hideOtherWindowsLocked("browser-login")
func TestRestoreHiddenWindowsUnknownOwnerKeepsEverything(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
d := newFakeDesktop(s, main)
s.hideOtherWindows(windowBrowserLogin)
s.restoreHiddenWindowsLocked("welcome")
s.restoreHiddenWindows(windowWelcome)
assert.False(t, main.visible)
assert.Equal(t, []string{"browser-login"}, ownersOf(s.hiddenWindows))
assert.Equal(t, 0, *raised)
require.False(t, main.visible)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
require.Equal(t, 0, d.raised)
}
func TestRestoreHiddenWindowsLockedWithoutMainDoesNotRaise(t *testing.T) {
settings := newFakeWindow("settings")
s, raised := newTestWindowManager(settings)
s.hideOtherWindowsLocked("browser-login")
func TestRestoreHiddenWindowsWithoutMainDoesNotRaise(t *testing.T) {
settings := newFakeWindow(windowSettings)
s := newTestWindowManager()
d := newFakeDesktop(s, settings)
s.hideOtherWindows(windowBrowserLogin)
s.restoreHiddenWindowsLocked("browser-login")
s.restoreHiddenWindows(windowBrowserLogin)
assert.True(t, settings.visible)
assert.Equal(t, 0, *raised)
require.True(t, settings.visible)
require.Equal(t, 0, d.raised)
}
func TestRestoreHiddenWindowsLockedEmptyIsNoop(t *testing.T) {
s, _ := newTestWindowManager()
require.NotPanics(t, func() { s.restoreHiddenWindowsLocked("browser-login") })
assert.Empty(t, s.hiddenWindows)
func TestRestoreHiddenWindowsEmptyIsNoop(t *testing.T) {
s := newTestWindowManager()
require.NotPanics(t, func() { s.restoreHiddenWindows(windowBrowserLogin) })
require.Empty(t, s.hiddenWindows)
}
func TestHideOtherWindowsRacingOwnRestoreReshowsWhatItHid(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
d := newFakeDesktop(s, main)
enumerate := s.allWindows
// A restore for the same owner lands between the generation snapshot and the record.
s.allWindows = func() []hideableWindow {
s.restoreHiddenWindows(windowBrowserLogin)
return enumerate()
}
s.hideOtherWindows(windowBrowserLogin)
require.True(t, main.visible)
require.Equal(t, 1, main.hidden)
require.Empty(t, s.hiddenWindows)
require.Equal(t, 0, d.raised)
}
func TestHideOtherWindowsIgnoresRestoreOfAnotherOwner(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
newFakeDesktop(s, main)
enumerate := s.allWindows
s.allWindows = func() []hideableWindow {
s.restoreHiddenWindows(windowInstallProgress)
return enumerate()
}
s.hideOtherWindows(windowBrowserLogin)
require.False(t, main.visible)
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
}
func TestRestoringCloserRestoresOnlyItsOwner(t *testing.T) {
main := newFakeWindow(windowMain)
s := newTestWindowManager()
newFakeDesktop(s, main)
s.hideOtherWindows(windowBrowserLogin)
s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{owner: windowInstallProgress})
s.restoringCloser(windowBrowserLogin)(&application.WebviewWindow{})
require.True(t, main.visible)
require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows))
}
func TestStampGenerationTracksLatestPerWindow(t *testing.T) {
s, _ := newTestWindowManager()
s := newTestWindowManager()
first := s.stampGeneration("browser-login", "/#/dialog/browser-login")
assert.Equal(t, "/#/dialog/browser-login?gen=1", first)
assert.True(t, s.matchesGeneration("browser-login", 1))
first := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login")
require.Equal(t, "/#/dialog/browser-login?gen=1", first)
require.True(t, s.matchesGeneration(windowBrowserLogin, 1))
second := s.stampGeneration("browser-login", "/#/dialog/browser-login?uri=x")
assert.Equal(t, "/#/dialog/browser-login?uri=x&gen=2", second)
assert.False(t, s.matchesGeneration("browser-login", 1))
assert.True(t, s.matchesGeneration("browser-login", 2))
second := s.stampGeneration(windowBrowserLogin, "/#/dialog/browser-login?uri=x")
require.Equal(t, "/#/dialog/browser-login?uri=x&gen=2", second)
require.False(t, s.matchesGeneration(windowBrowserLogin, 1))
require.True(t, s.matchesGeneration(windowBrowserLogin, 2))
}
func TestMatchesGenerationUntrackedWindowAccepts(t *testing.T) {
s, _ := newTestWindowManager()
assert.True(t, s.matchesGeneration("main", 0))
s := newTestWindowManager()
require.True(t, s.matchesGeneration(windowMain, 0))
}
func TestPaintedGeneration(t *testing.T) {
@@ -165,7 +560,7 @@ func TestPaintedGeneration(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, paintedGeneration(tc.data))
require.Equal(t, tc.want, paintedGeneration(tc.data))
})
}
}