Merge branch 'main' into embedded-vnc

# Conflicts:
#	client/ui/frontend/src/app.tsx
#	client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx
#	client/ui/i18n/locales/uk/common.json
#	go.sum
This commit is contained in:
Viktor Liu
2026-09-22 14:53:34 +02:00
205 changed files with 12118 additions and 1280 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)
}
+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
+350
View File
@@ -0,0 +1,350 @@
//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{},
pendingClose: map[string]windowCloser{},
}
}
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.hiddenForLogin = []application.Window{nil}
return &application.WebviewWindow{}
}, func(*application.WebviewWindow, bool) {})
require.Nil(t, s.browserLogin)
require.Empty(t, s.hiddenForLogin)
require.Empty(t, s.creating)
require.Empty(t, s.pendingClose)
}