mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-23 15:19:08 +02:00
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:
@@ -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) {}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+496
-267
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user