mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-29 01:59:07 +02:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+538
-339
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user