mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 00:49:07 +02:00
Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt
# Conflicts: # client/proto/daemon.pb.go # management/internals/controllers/network_map/controller/controller.go # management/internals/controllers/network_map/nmaptest/runner.go # management/internals/shared/grpc/server.go # management/server/account.go # shared/management/proto/management.pb.go # upload-server/server/server.go
This commit is contained in:
@@ -205,19 +205,7 @@ func (s *Connection) Down(ctx context.Context) error {
|
||||
// window.open, so the SSO verification page can't pop inline. Honors $BROWSER
|
||||
// before the platform default.
|
||||
func (s *Connection) OpenURL(url string) error {
|
||||
if browser := os.Getenv("BROWSER"); browser != "" {
|
||||
return exec.Command(browser, url).Start()
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
return openURL(url)
|
||||
}
|
||||
|
||||
func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
@@ -288,3 +276,19 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
|
||||
func openURL(url string) error {
|
||||
if browser := os.Getenv("BROWSER"); browser != "" {
|
||||
return exec.Command(browser, url).Start()
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,8 +134,19 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
strings.Contains(lower, "connection refused"),
|
||||
strings.Contains(lower, "context deadline exceeded"):
|
||||
code = "daemon_unreachable"
|
||||
case strings.Contains(lower, "update settings are disabled"):
|
||||
code = "settings_locked"
|
||||
case strings.Contains(lower, "managed by mdm"):
|
||||
code = "settings_managed_by_mdm"
|
||||
}
|
||||
|
||||
// Deliberately no blanket mapping for FailedPrecondition below: the daemon
|
||||
// returns it for two dozen states that are not settings refusals at all —
|
||||
// "not logged in", "client is not running", "session can no longer be
|
||||
// extended" — and this classifier is shared with the session and connection
|
||||
// services. Only the two refusals the daemon composes are named, by their
|
||||
// message.
|
||||
|
||||
// Fall back to the gRPC status code when the message didn't match a known
|
||||
// substring — the daemon now forwards the innermost code with a clean desc
|
||||
// that no longer contains the English marker text.
|
||||
|
||||
@@ -34,6 +34,29 @@ func TestErrorClassifier_Classify(t *testing.T) {
|
||||
require.Equal(t, "session_expired", ce.Code)
|
||||
})
|
||||
|
||||
t.Run("the update-settings kill switch is a refusal, not a failure", func(t *testing.T) {
|
||||
err := gstatus.Error(gcodes.FailedPrecondition,
|
||||
"update settings are disabled, you cannot use this feature without update settings enabled")
|
||||
|
||||
ce := c.classify(err)
|
||||
require.NotNil(t, ce)
|
||||
require.Equal(t, "settings_locked", ce.Code)
|
||||
})
|
||||
|
||||
t.Run("an MDM-managed field is named as such", func(t *testing.T) {
|
||||
err := gstatus.Error(gcodes.FailedPrecondition,
|
||||
"fields managed by MDM cannot be modified: [managementURL]")
|
||||
|
||||
require.Equal(t, "settings_managed_by_mdm", c.classify(err).Code)
|
||||
})
|
||||
|
||||
t.Run("an unrelated FailedPrecondition is not called a refusal", func(t *testing.T) {
|
||||
// The daemon uses this code for states that are not settings refusals,
|
||||
// and this classifier is shared with the session and connection
|
||||
// services, so only the two refusals it composes are named.
|
||||
require.Equal(t, "unknown", c.classify(gstatus.Error(gcodes.FailedPrecondition, "not logged in")).Code)
|
||||
})
|
||||
|
||||
t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) {
|
||||
ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing"))
|
||||
require.Equal(t, "daemon_unreachable", ce.Code)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// PortRange is a port range; both ends are inclusive.
|
||||
type PortRange struct {
|
||||
Start uint32 `json:"start"`
|
||||
End uint32 `json:"end"`
|
||||
}
|
||||
|
||||
// PortInfo holds exactly one of Port or Range (the daemon's oneof).
|
||||
type PortInfo struct {
|
||||
Port *uint32 `json:"port,omitempty"`
|
||||
Range *PortRange `json:"range,omitempty"`
|
||||
}
|
||||
|
||||
// ForwardingRule is one entry from the daemon's reverse-proxy table.
|
||||
type ForwardingRule struct {
|
||||
Protocol string `json:"protocol"`
|
||||
DestinationPort PortInfo `json:"destinationPort"`
|
||||
TranslatedAddress string `json:"translatedAddress"`
|
||||
TranslatedHostname string `json:"translatedHostname"`
|
||||
TranslatedPort PortInfo `json:"translatedPort"`
|
||||
}
|
||||
|
||||
// Forwarding groups the daemon RPCs that surface exposed/forwarded services.
|
||||
type Forwarding struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewForwarding(conn DaemonConn) *Forwarding {
|
||||
return &Forwarding{conn: conn}
|
||||
}
|
||||
|
||||
func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ForwardingRule, 0, len(resp.GetRules()))
|
||||
for _, r := range resp.GetRules() {
|
||||
out = append(out, forwardingRuleFromProto(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule {
|
||||
return ForwardingRule{
|
||||
Protocol: r.GetProtocol(),
|
||||
DestinationPort: portInfoFromProto(r.GetDestinationPort()),
|
||||
TranslatedAddress: r.GetTranslatedAddress(),
|
||||
TranslatedHostname: r.GetTranslatedHostname(),
|
||||
TranslatedPort: portInfoFromProto(r.GetTranslatedPort()),
|
||||
}
|
||||
}
|
||||
|
||||
func portInfoFromProto(p *proto.PortInfo) PortInfo {
|
||||
if p == nil {
|
||||
return PortInfo{}
|
||||
}
|
||||
switch sel := p.GetPortSelection().(type) {
|
||||
case *proto.PortInfo_Port:
|
||||
port := sel.Port
|
||||
return PortInfo{Port: &port}
|
||||
case *proto.PortInfo_Range_:
|
||||
r := sel.Range
|
||||
if r == nil {
|
||||
return PortInfo{}
|
||||
}
|
||||
return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}}
|
||||
}
|
||||
return PortInfo{}
|
||||
}
|
||||
+364
-120
@@ -5,6 +5,7 @@ package services
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -26,6 +27,16 @@ type windowOp func(w *application.WebviewWindow, created bool)
|
||||
|
||||
type windowCloser func(w *application.WebviewWindow)
|
||||
|
||||
// hideableWindow is the slice of application.Window the hide/restore bookkeeping needs.
|
||||
// Narrow enough to fake in tests, which application.Window itself is not: it carries
|
||||
// unexported methods.
|
||||
type hideableWindow interface {
|
||||
Show() application.Window
|
||||
Hide() application.Window
|
||||
IsVisible() bool
|
||||
Name() string
|
||||
}
|
||||
|
||||
// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow.
|
||||
const EventTriggerLogin = "trigger-login"
|
||||
|
||||
@@ -37,7 +48,10 @@ const EventSettingsOpen = "netbird:settings:open"
|
||||
|
||||
const EventWindowPainted = "netbird:window-painted"
|
||||
|
||||
const paintedFallback = 2 * time.Second
|
||||
// generationParam carries the painted-report token in each dialog's start URL.
|
||||
const generationParam = "gen"
|
||||
|
||||
const paintedFallback = 3 * time.Second
|
||||
|
||||
const headlessTeardownDelay = 2 * time.Second
|
||||
|
||||
@@ -201,6 +215,12 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
|
||||
}
|
||||
}
|
||||
|
||||
// hiddenWindow records a window hidden by owner, the name of the popup that hid it.
|
||||
type hiddenWindow struct {
|
||||
win hideableWindow
|
||||
owner string
|
||||
}
|
||||
|
||||
type WindowManager struct {
|
||||
app *application.App
|
||||
mainWindow *application.WebviewWindow
|
||||
@@ -213,19 +233,35 @@ type WindowManager struct {
|
||||
installProgress *application.WebviewWindow
|
||||
welcome *application.WebviewWindow
|
||||
errorDialog *application.WebviewWindow
|
||||
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
|
||||
hiddenForLogin []application.Window
|
||||
mu sync.Mutex
|
||||
newMain func(startURL string) *application.WebviewWindow
|
||||
creating map[string]bool
|
||||
pendingOps map[string][]windowOp
|
||||
pendingClose map[string]windowCloser
|
||||
restoreGen uint64
|
||||
ready map[uint]bool
|
||||
// hiddenWindows holds windows hidden while a popup owns the screen, each tagged with
|
||||
// the popup that hid it so closing one popup cannot restore what another still hides.
|
||||
hiddenWindows []hiddenWindow
|
||||
hiding map[string]bool
|
||||
// allWindows and raiseMain are the seams the hide/restore tests replace; both are nil
|
||||
// in production, where the Wails app and the platform helper are used directly.
|
||||
allWindows func() []hideableWindow
|
||||
raiseMain func()
|
||||
mu sync.Mutex
|
||||
newMain func(startURL string) *application.WebviewWindow
|
||||
creating map[string]bool
|
||||
pendingOps map[string][]windowOp
|
||||
pendingClose map[string]windowCloser
|
||||
restoreGen map[string]uint64
|
||||
// painted gates showing a window: set by the frontend's first render, or by the
|
||||
// fallback timer so a webview that never wakes up still becomes visible.
|
||||
painted map[uint]bool
|
||||
// mounted gates emitting to a window: set only by a real frontend report, since an
|
||||
// event emitted to a frontend that has not subscribed yet is dropped, not queued.
|
||||
mounted map[uint]bool
|
||||
showPending map[uint]bool
|
||||
pendingTab map[uint]string
|
||||
pendingEmits map[uint][]string
|
||||
fallbackTimers map[uint]*time.Timer
|
||||
afterShow map[uint]func()
|
||||
// generation maps a window name to the token stamped into its current start URL, so a
|
||||
// painted report from a replaced window can be told apart from the live one's.
|
||||
generation map[string]uint64
|
||||
lastGeneration uint64
|
||||
headlessMain bool
|
||||
headlessTimer *time.Timer
|
||||
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
||||
@@ -243,11 +279,16 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
creating: map[string]bool{},
|
||||
pendingOps: map[string][]windowOp{},
|
||||
pendingClose: map[string]windowCloser{},
|
||||
ready: map[uint]bool{},
|
||||
restoreGen: map[string]uint64{},
|
||||
hiding: map[string]bool{},
|
||||
painted: map[uint]bool{},
|
||||
mounted: map[uint]bool{},
|
||||
showPending: map[uint]bool{},
|
||||
pendingTab: map[uint]string{},
|
||||
pendingEmits: map[uint][]string{},
|
||||
fallbackTimers: map[uint]*time.Timer{},
|
||||
afterShow: map[uint]func(){},
|
||||
generation: map[string]uint64{},
|
||||
}
|
||||
s.watchPainted()
|
||||
s.watchTriggerLogin()
|
||||
@@ -307,13 +348,13 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
|
||||
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
|
||||
s.mu.Lock()
|
||||
ready := s.ready[w.ID()]
|
||||
if !ready {
|
||||
mounted := s.mounted[w.ID()]
|
||||
if !mounted {
|
||||
s.pendingTab[w.ID()] = target
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ready {
|
||||
if mounted {
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
}
|
||||
s.showWhenReady(w)
|
||||
@@ -327,21 +368,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||
}
|
||||
s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow {
|
||||
return s.newBrowserLoginWindow(startURL)
|
||||
return s.newBrowserLoginWindow(s.stampGeneration(windowBrowserLogin, startURL))
|
||||
}, func(w *application.WebviewWindow, created bool) {
|
||||
if created {
|
||||
s.centerOnCursorScreen(w)
|
||||
return
|
||||
}
|
||||
if uri != "" {
|
||||
w.SetURL(startURL)
|
||||
if !created && uri != "" {
|
||||
w.SetURL(s.stampGeneration(windowBrowserLogin, startURL))
|
||||
}
|
||||
s.centerOnCursorScreen(w)
|
||||
w.Show()
|
||||
w.Focus()
|
||||
s.showThenOpenBrowser(w, uri)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) showThenOpenBrowser(w *application.WebviewWindow, uri string) {
|
||||
if uri != "" {
|
||||
s.mu.Lock()
|
||||
s.afterShow[w.ID()] = func() { s.openBrowser(uri) }
|
||||
s.mu.Unlock()
|
||||
}
|
||||
s.showWhenReady(w)
|
||||
}
|
||||
|
||||
func (s *WindowManager) openBrowser(uri string) {
|
||||
if uri == "" {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := openURL(uri); err != nil {
|
||||
log.Errorf("open browser for SSO login: %v", err)
|
||||
s.OpenError(s.title("browserLogin.openFailedTitle"), err.Error(), "")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow {
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
@@ -360,12 +417,14 @@ func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.Webv
|
||||
if userClosed {
|
||||
s.browserLogin = nil
|
||||
}
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
if userClosed {
|
||||
s.restoreHiddenWindows()
|
||||
s.restoreHiddenWindows(windowBrowserLogin)
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
}
|
||||
})
|
||||
s.armReady(w)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -386,13 +445,11 @@ func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow {
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseBrowserLogin() {
|
||||
// The WindowClosing hook no-ops on a programmatic close, so restore here —
|
||||
// but only if a popup was actually open. The frontend calls this even when no
|
||||
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
|
||||
// or connection.ts's catch path), and hiddenForLogin is shared with
|
||||
// OpenInstallProgress, so an unconditional restore could re-show windows a
|
||||
// still-running install-progress is hiding.
|
||||
s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoreAndClose)
|
||||
// The WindowClosing hook no-ops on a programmatic close, so the closer restores.
|
||||
// The frontend calls this even when no popup was ever shown (resetDialog() after an
|
||||
// early RequestExtend failure, or connection.ts's catch path); closeWindow skips the
|
||||
// closer then, and an owner-scoped restore cannot touch what install-progress hides.
|
||||
s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin))
|
||||
}
|
||||
|
||||
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
|
||||
@@ -404,16 +461,13 @@ func (s *WindowManager) OpenSessionExpiration(seconds int, deadlineUnixMilli int
|
||||
startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
|
||||
}
|
||||
s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow {
|
||||
return s.newSessionExpirationWindow(startURL)
|
||||
return s.newSessionExpirationWindow(s.stampGeneration(windowSessionExpiration, startURL))
|
||||
}, func(w *application.WebviewWindow, created bool) {
|
||||
if created {
|
||||
s.centerOnCursorScreen(w)
|
||||
return
|
||||
if !created {
|
||||
w.SetURL(s.stampGeneration(windowSessionExpiration, startURL))
|
||||
}
|
||||
w.SetURL(startURL)
|
||||
s.centerOnCursorScreen(w)
|
||||
w.Show()
|
||||
w.Focus()
|
||||
s.showWhenReady(w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -427,8 +481,10 @@ func (s *WindowManager) newSessionExpirationWindow(startURL string) *application
|
||||
if s.sessionExpiration == w {
|
||||
s.sessionExpiration = nil
|
||||
}
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.armReady(w)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -440,20 +496,20 @@ func (s *WindowManager) CloseSessionExpiration() {
|
||||
// closes the browser-login popup and the session-expiration window together.
|
||||
func (s *WindowManager) CloseRenewFlow() {
|
||||
s.mu.Lock()
|
||||
bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoreAndClose)
|
||||
bl := s.takeWindowLocked(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin))
|
||||
se := s.takeWindowLocked(windowSessionExpiration, &s.sessionExpiration, closeOnly)
|
||||
if se != nil {
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, w := range s.hiddenForLogin {
|
||||
if w != se {
|
||||
kept = append(kept, w)
|
||||
kept := s.hiddenWindows[:0]
|
||||
for _, hidden := range s.hiddenWindows {
|
||||
if !sameWindow(hidden.win, se) {
|
||||
kept = append(kept, hidden)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
s.hiddenWindows = kept
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.restoreHiddenWindows()
|
||||
s.restoreHiddenWindows(windowBrowserLogin)
|
||||
// Close after unlock so the re-entrant handlers can take s.mu.
|
||||
if bl != nil {
|
||||
bl.Close()
|
||||
@@ -471,14 +527,12 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
|
||||
}
|
||||
s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow {
|
||||
return s.newInstallProgressWindow(startURL)
|
||||
return s.newInstallProgressWindow(s.stampGeneration(windowInstallProgress, startURL))
|
||||
}, func(w *application.WebviewWindow, created bool) {
|
||||
if !created {
|
||||
w.SetURL(startURL)
|
||||
w.Show()
|
||||
w.Focus()
|
||||
w.SetURL(s.stampGeneration(windowInstallProgress, startURL))
|
||||
}
|
||||
s.centerWhenReady(w)
|
||||
s.showWhenReady(w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -489,32 +543,33 @@ func (s *WindowManager) newInstallProgressWindow(startURL string) *application.W
|
||||
)
|
||||
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
if s.installProgress == w {
|
||||
userClosed := s.installProgress == w
|
||||
if userClosed {
|
||||
s.installProgress = nil
|
||||
}
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
s.restoreHiddenWindows()
|
||||
if userClosed {
|
||||
s.restoreHiddenWindows(windowInstallProgress)
|
||||
}
|
||||
})
|
||||
s.armReady(w)
|
||||
return w
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseInstallProgress() {
|
||||
s.closeWindow(windowInstallProgress, &s.installProgress, closeOnly)
|
||||
s.closeWindow(windowInstallProgress, &s.installProgress, s.restoringCloser(windowInstallProgress))
|
||||
}
|
||||
|
||||
// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenWelcome() {
|
||||
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) {
|
||||
if !created {
|
||||
w.Show()
|
||||
w.Focus()
|
||||
}
|
||||
s.centerWhenReady(w)
|
||||
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, _ bool) {
|
||||
s.showWhenReady(w)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
|
||||
opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
|
||||
opts := DialogWindowOptions(windowWelcome, s.title("window.title.welcome"), s.stampGeneration(windowWelcome, "/#/dialog/welcome"), s.linuxIcon)
|
||||
opts.Width = 420
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
w := s.app.Window.NewWithOptions(opts)
|
||||
@@ -523,8 +578,10 @@ func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
|
||||
if s.welcome == w {
|
||||
s.welcome = nil
|
||||
}
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.armReady(w)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -542,14 +599,12 @@ func (s *WindowManager) OpenError(title, message, command string) {
|
||||
}
|
||||
startURL := errorDialogURL(title, message, command)
|
||||
s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow {
|
||||
return s.newErrorWindow(startURL)
|
||||
return s.newErrorWindow(s.stampGeneration(windowError, startURL))
|
||||
}, func(w *application.WebviewWindow, created bool) {
|
||||
if !created {
|
||||
w.SetURL(startURL)
|
||||
w.Show()
|
||||
w.Focus()
|
||||
w.SetURL(s.stampGeneration(windowError, startURL))
|
||||
}
|
||||
s.centerWhenReady(w)
|
||||
s.showWhenReady(w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -562,8 +617,10 @@ func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWind
|
||||
if s.errorDialog == w {
|
||||
s.errorDialog = nil
|
||||
}
|
||||
s.forgetWindowLocked(w)
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.armReady(w)
|
||||
return w
|
||||
}
|
||||
|
||||
@@ -589,14 +646,14 @@ func (s *WindowManager) ShowMainAndEmit(event string) {
|
||||
s.ensureMain("/", func(w *application.WebviewWindow, _ bool) {
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
ready := s.ready[id]
|
||||
if !ready {
|
||||
mounted := s.mounted[id]
|
||||
if !mounted {
|
||||
s.pendingEmits[id] = append(s.pendingEmits[id], event)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.showWhenReady(w)
|
||||
if ready {
|
||||
if mounted {
|
||||
s.app.Event.Emit(event)
|
||||
}
|
||||
})
|
||||
@@ -741,31 +798,66 @@ func (s *WindowManager) releaseCreationLocked(name string) {
|
||||
delete(s.pendingClose, name)
|
||||
}
|
||||
|
||||
func (s *WindowManager) restoreAndClose(w *application.WebviewWindow) {
|
||||
s.restoreHiddenWindows()
|
||||
w.Close()
|
||||
func (s *WindowManager) restoringCloser(owner string) windowCloser {
|
||||
return func(w *application.WebviewWindow) {
|
||||
s.restoreHiddenWindows(owner)
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// armReady starts the fallback that shows w even if its frontend never reports a first
|
||||
// render. The timer starts at creation, because a hidden webview can be suspended before
|
||||
// it reaches WindowRuntimeReady — the very case this fallback covers. That makes the first
|
||||
// budget cover webview boot as well, so the runtime-ready hook rearms it to give the
|
||||
// frontend its own full budget to mount and paint.
|
||||
func (s *WindowManager) armReady(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
s.armPaintedFallback(w)
|
||||
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
|
||||
timer := time.AfterFunc(paintedFallback, func() {
|
||||
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
|
||||
s.markReady(w)
|
||||
})
|
||||
s.mu.Lock()
|
||||
s.fallbackTimers[w.ID()] = timer
|
||||
s.mu.Unlock()
|
||||
s.armPaintedFallback(w)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *WindowManager) armPaintedFallback(w *application.WebviewWindow) {
|
||||
id := w.ID()
|
||||
timer := time.AfterFunc(paintedFallback, func() {
|
||||
s.mu.Lock()
|
||||
painted := s.painted[id]
|
||||
s.mu.Unlock()
|
||||
if painted {
|
||||
return
|
||||
}
|
||||
log.Warnf("window %q never reported a first render, showing it anyway", w.Name())
|
||||
s.markPainted(w)
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
if prev := s.fallbackTimers[id]; prev != nil {
|
||||
prev.Stop()
|
||||
}
|
||||
if s.painted[id] {
|
||||
timer.Stop()
|
||||
delete(s.fallbackTimers, id)
|
||||
} else {
|
||||
s.fallbackTimers[id] = timer
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *WindowManager) watchPainted() {
|
||||
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
||||
if w := s.windowByName(e.Sender); w != nil {
|
||||
s.markReady(w)
|
||||
w := s.windowByName(e.Sender)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if !s.matchesGeneration(e.Sender, paintedGeneration(e.Data)) {
|
||||
log.Debugf("ignoring stale painted report for window %q", e.Sender)
|
||||
return
|
||||
}
|
||||
s.markPainted(w)
|
||||
s.markMounted(w)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -777,7 +869,7 @@ func (s *WindowManager) watchTriggerLogin() {
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
w := s.mainWindow
|
||||
ready := w != nil && s.ready[w.ID()]
|
||||
ready := w != nil && s.mounted[w.ID()]
|
||||
s.mu.Unlock()
|
||||
if ready {
|
||||
return
|
||||
@@ -788,7 +880,7 @@ func (s *WindowManager) watchTriggerLogin() {
|
||||
if created {
|
||||
s.headlessMain = true
|
||||
}
|
||||
pending := !s.ready[w.ID()]
|
||||
pending := !s.mounted[w.ID()]
|
||||
if pending {
|
||||
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
|
||||
}
|
||||
@@ -850,18 +942,67 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
||||
timer.Stop()
|
||||
}
|
||||
delete(s.fallbackTimers, id)
|
||||
delete(s.ready, id)
|
||||
delete(s.painted, id)
|
||||
delete(s.mounted, id)
|
||||
delete(s.showPending, id)
|
||||
delete(s.pendingTab, id)
|
||||
delete(s.pendingEmits, id)
|
||||
delete(s.afterShow, id)
|
||||
|
||||
kept := s.hiddenForLogin[:0]
|
||||
for _, hidden := range s.hiddenForLogin {
|
||||
if hidden != application.Window(w) {
|
||||
kept := s.hiddenWindows[:0]
|
||||
for _, hidden := range s.hiddenWindows {
|
||||
if !sameWindow(hidden.win, w) {
|
||||
kept = append(kept, hidden)
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = kept
|
||||
s.hiddenWindows = kept
|
||||
}
|
||||
|
||||
func (s *WindowManager) stampGeneration(name, startURL string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastGeneration++
|
||||
s.generation[name] = s.lastGeneration
|
||||
return appendGeneration(startURL, s.lastGeneration)
|
||||
}
|
||||
|
||||
func (s *WindowManager) matchesGeneration(name string, gen uint64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
want, tracked := s.generation[name]
|
||||
if !tracked {
|
||||
return true
|
||||
}
|
||||
return want == gen
|
||||
}
|
||||
|
||||
func (s *WindowManager) hideableWindows() []hideableWindow {
|
||||
if s.allWindows != nil {
|
||||
return s.allWindows()
|
||||
}
|
||||
all := s.app.Window.GetAll()
|
||||
windows := make([]hideableWindow, 0, len(all))
|
||||
for _, w := range all {
|
||||
windows = append(windows, w)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
func (s *WindowManager) isMainWindow(w hideableWindow, mainWindow *application.WebviewWindow) bool {
|
||||
if s.allWindows != nil {
|
||||
return w != nil && w.Name() == windowMain
|
||||
}
|
||||
return sameWindow(w, mainWindow)
|
||||
}
|
||||
|
||||
func (s *WindowManager) raiseMainWindow(mainWindow *application.WebviewWindow) {
|
||||
if s.raiseMain != nil {
|
||||
s.raiseMain()
|
||||
return
|
||||
}
|
||||
if mainWindow != nil {
|
||||
raiseToForeground(mainWindow)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
||||
@@ -872,24 +1013,50 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
||||
return s.mainWindow
|
||||
case windowSettings:
|
||||
return s.settings
|
||||
case windowBrowserLogin:
|
||||
return s.browserLogin
|
||||
case windowSessionExpiration:
|
||||
return s.sessionExpiration
|
||||
case windowInstallProgress:
|
||||
return s.installProgress
|
||||
case windowWelcome:
|
||||
return s.welcome
|
||||
case windowError:
|
||||
return s.errorDialog
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) markReady(w *application.WebviewWindow) {
|
||||
func (s *WindowManager) markPainted(w *application.WebviewWindow) {
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
already := s.ready[id]
|
||||
s.ready[id] = true
|
||||
already := s.painted[id]
|
||||
s.painted[id] = true
|
||||
wanted := s.showPending[id]
|
||||
tab, hasTab := s.pendingTab[id]
|
||||
emits := s.pendingEmits[id]
|
||||
delete(s.showPending, id)
|
||||
if timer := s.fallbackTimers[id]; timer != nil {
|
||||
timer.Stop()
|
||||
delete(s.fallbackTimers, id)
|
||||
}
|
||||
delete(s.showPending, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
if already || !wanted {
|
||||
return
|
||||
}
|
||||
s.showNow(w)
|
||||
}
|
||||
|
||||
// markMounted records that the window's frontend is subscribed, and flushes the events
|
||||
// held back for it. The fallback timer never calls this: showing a blank window is
|
||||
// recoverable, emitting into a frontend that cannot hear it is not.
|
||||
func (s *WindowManager) markMounted(w *application.WebviewWindow) {
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
already := s.mounted[id]
|
||||
s.mounted[id] = true
|
||||
tab, hasTab := s.pendingTab[id]
|
||||
emits := s.pendingEmits[id]
|
||||
delete(s.pendingTab, id)
|
||||
delete(s.pendingEmits, id)
|
||||
s.mu.Unlock()
|
||||
@@ -902,10 +1069,6 @@ func (s *WindowManager) markReady(w *application.WebviewWindow) {
|
||||
s.app.Event.Emit(EventSettingsOpen, tab)
|
||||
}
|
||||
|
||||
if wanted {
|
||||
s.showNow(w)
|
||||
}
|
||||
|
||||
for _, event := range emits {
|
||||
s.app.Event.Emit(event)
|
||||
}
|
||||
@@ -918,18 +1081,19 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
|
||||
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
ready := s.ready[id]
|
||||
if !ready {
|
||||
painted := s.painted[id]
|
||||
if !painted {
|
||||
s.showPending[id] = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ready {
|
||||
if painted {
|
||||
s.showNow(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
||||
id := w.ID()
|
||||
s.mu.Lock()
|
||||
if w == s.mainWindow {
|
||||
s.headlessMain = false
|
||||
@@ -938,10 +1102,15 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
||||
s.headlessTimer = nil
|
||||
}
|
||||
}
|
||||
after := s.afterShow[id]
|
||||
delete(s.afterShow, id)
|
||||
s.mu.Unlock()
|
||||
w.Show()
|
||||
w.Focus()
|
||||
s.centerWhenReady(w)
|
||||
if after != nil {
|
||||
after()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) ShowMainAt(url string) {
|
||||
@@ -1070,13 +1239,19 @@ func (s *WindowManager) retitleAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// hideOtherWindows hides every visible window except keepName, recording them against
|
||||
// keepName so only its own restore brings them back. A window already hidden by an
|
||||
// earlier popup is skipped, leaving it tagged to the popup that actually hid it. The
|
||||
// per-owner generation catches a restore for keepName that ran between the snapshot and
|
||||
// the record, in which case the windows are re-shown rather than stranded.
|
||||
func (s *WindowManager) hideOtherWindows(keepName string) {
|
||||
s.mu.Lock()
|
||||
gen := s.restoreGen
|
||||
s.hiding[keepName] = true
|
||||
gen := s.restoreGen[keepName]
|
||||
s.mu.Unlock()
|
||||
|
||||
var hidden []application.Window
|
||||
for _, w := range s.app.Window.GetAll() {
|
||||
var hidden []hideableWindow
|
||||
for _, w := range s.hideableWindows() {
|
||||
if w == nil || w.Name() == keepName || !w.IsVisible() {
|
||||
continue
|
||||
}
|
||||
@@ -1088,9 +1263,11 @@ func (s *WindowManager) hideOtherWindows(keepName string) {
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
restored := s.restoreGen != gen
|
||||
restored := s.restoreGen[keepName] != gen
|
||||
if !restored {
|
||||
s.hiddenForLogin = append(s.hiddenForLogin, hidden...)
|
||||
for _, w := range hidden {
|
||||
s.hiddenWindows = append(s.hiddenWindows, hiddenWindow{win: w, owner: keepName})
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if !restored {
|
||||
@@ -1101,33 +1278,58 @@ func (s *WindowManager) hideOtherWindows(keepName string) {
|
||||
}
|
||||
}
|
||||
|
||||
// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main
|
||||
// window was among them, raiseToForeground lifts it above the SSO browser, which
|
||||
// still owns the foreground — a plain Show/Focus would be demoted to a taskbar
|
||||
// flash and leave it stranded behind.
|
||||
func (s *WindowManager) restoreHiddenWindows() {
|
||||
// restoreHiddenWindows re-shows the windows owner hid, unless another popup still covers
|
||||
// them, in which case they are handed to that popup. If the main window was among them,
|
||||
// raiseToForeground lifts it above the SSO browser, which still owns the foreground — a
|
||||
// plain Show/Focus would be demoted to a taskbar flash and leave it stranded behind.
|
||||
func (s *WindowManager) restoreHiddenWindows(owner string) {
|
||||
s.mu.Lock()
|
||||
hidden := s.hiddenForLogin
|
||||
s.hiddenForLogin = nil
|
||||
s.restoreGen++
|
||||
mainWindow := s.mainWindow
|
||||
delete(s.hiding, owner)
|
||||
var restore []hideableWindow
|
||||
kept := s.hiddenWindows[:0]
|
||||
for _, hidden := range s.hiddenWindows {
|
||||
if hidden.owner != owner {
|
||||
kept = append(kept, hidden)
|
||||
continue
|
||||
}
|
||||
if coverer, covered := s.coveringPopupLocked(hidden.win); covered {
|
||||
hidden.owner = coverer
|
||||
kept = append(kept, hidden)
|
||||
continue
|
||||
}
|
||||
if hidden.win != nil {
|
||||
restore = append(restore, hidden.win)
|
||||
}
|
||||
}
|
||||
s.hiddenWindows = kept
|
||||
s.restoreGen[owner]++
|
||||
s.mu.Unlock()
|
||||
|
||||
mainRestored := false
|
||||
for _, w := range hidden {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
for _, w := range restore {
|
||||
w.Show()
|
||||
if w == mainWindow {
|
||||
if s.isMainWindow(w, mainWindow) {
|
||||
mainRestored = true
|
||||
}
|
||||
}
|
||||
if mainRestored && mainWindow != nil {
|
||||
raiseToForeground(mainWindow)
|
||||
if mainRestored {
|
||||
s.raiseMainWindow(mainWindow)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WindowManager) coveringPopupLocked(w hideableWindow) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
for name := range s.hiding {
|
||||
if name != w.Name() {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the
|
||||
// main-window screen, then nil (OS-default placement).
|
||||
func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
|
||||
@@ -1169,6 +1371,48 @@ func errorDialogURL(title, message, command string) string {
|
||||
return startURL
|
||||
}
|
||||
|
||||
// appendGeneration adds the painted-report token to a dialog start URL, keeping any
|
||||
// existing query params intact across the "/#/path?params" hash-router form.
|
||||
func appendGeneration(startURL string, gen uint64) string {
|
||||
sep := "?"
|
||||
if strings.Contains(startURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return startURL + sep + generationParam + "=" + strconv.FormatUint(gen, 10)
|
||||
}
|
||||
|
||||
// paintedGeneration reads the token a painted report carries back, returning 0 when the
|
||||
// frontend sent none (an older bundle, or the main window, which is never stamped).
|
||||
func paintedGeneration(data any) uint64 {
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
gen, err := strconv.ParseUint(v, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return gen
|
||||
case float64:
|
||||
return uint64(v)
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
return paintedGeneration(v[0])
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// sameWindow reports whether a hidden entry refers to w, comparing through the interface
|
||||
// so a nil entry never matches a live window.
|
||||
func sameWindow(hidden hideableWindow, w *application.WebviewWindow) bool {
|
||||
if hidden == nil || w == nil {
|
||||
return false
|
||||
}
|
||||
other, ok := hidden.(*application.WebviewWindow)
|
||||
return ok && other == w
|
||||
}
|
||||
|
||||
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
|
||||
func u32ptr(v uint32) *uint32 { return &v }
|
||||
|
||||
|
||||
@@ -17,9 +17,65 @@ func newTestWindowManager() *WindowManager {
|
||||
creating: map[string]bool{},
|
||||
pendingOps: map[string][]windowOp{},
|
||||
pendingClose: map[string]windowCloser{},
|
||||
restoreGen: map[string]uint64{},
|
||||
hiding: map[string]bool{},
|
||||
generation: map[string]uint64{},
|
||||
}
|
||||
}
|
||||
|
||||
type fakeWindow struct {
|
||||
name string
|
||||
visible bool
|
||||
shown int
|
||||
hidden int
|
||||
}
|
||||
|
||||
func newFakeWindow(name string) *fakeWindow {
|
||||
return &fakeWindow{name: name, visible: true}
|
||||
}
|
||||
|
||||
func (f *fakeWindow) Show() application.Window {
|
||||
f.visible = true
|
||||
f.shown++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWindow) Hide() application.Window {
|
||||
f.visible = false
|
||||
f.hidden++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWindow) IsVisible() bool { return f.visible }
|
||||
|
||||
func (f *fakeWindow) Name() string { return f.name }
|
||||
|
||||
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
|
||||
}
|
||||
s.raiseMain = func() { d.raised++ }
|
||||
return d
|
||||
}
|
||||
|
||||
func ownersOf(hidden []hiddenWindow) []string {
|
||||
owners := make([]string, 0, len(hidden))
|
||||
for _, h := range hidden {
|
||||
owners = append(owners, h.owner)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func waitDone(t *testing.T, done <-chan struct{}, msg string) {
|
||||
t.Helper()
|
||||
select {
|
||||
@@ -339,12 +395,245 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
|
||||
// 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}
|
||||
s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}}
|
||||
return &application.WebviewWindow{}
|
||||
}, func(*application.WebviewWindow, bool) {})
|
||||
|
||||
require.Nil(t, s.browserLogin)
|
||||
require.Empty(t, s.hiddenForLogin)
|
||||
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(windowBrowserLogin)
|
||||
s := newTestWindowManager()
|
||||
newFakeDesktop(s, main, settings, popup)
|
||||
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
|
||||
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(windowMain)
|
||||
login := newFakeWindow(windowBrowserLogin)
|
||||
install := newFakeWindow(windowInstallProgress)
|
||||
install.visible = false
|
||||
s := newTestWindowManager()
|
||||
d := newFakeDesktop(s, main, login, install)
|
||||
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
require.False(t, main.visible)
|
||||
|
||||
install.visible = true
|
||||
s.hideOtherWindows(windowInstallProgress)
|
||||
require.False(t, login.visible, "the install popup hides the login popup")
|
||||
|
||||
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.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 TestLoginClosingUnderInstallHandsMainToInstall(t *testing.T) {
|
||||
main := newFakeWindow(windowMain)
|
||||
login := newFakeWindow(windowBrowserLogin)
|
||||
install := newFakeWindow(windowInstallProgress)
|
||||
install.visible = false
|
||||
s := newTestWindowManager()
|
||||
d := newFakeDesktop(s, main, login, install)
|
||||
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
install.visible = true
|
||||
s.hideOtherWindows(windowInstallProgress)
|
||||
require.False(t, main.visible)
|
||||
require.False(t, login.visible, "the install popup hides the login popup")
|
||||
|
||||
// The login popup closes while the install popup is still up: the main window it
|
||||
// hid must not resurface under the install popup, it is handed over instead.
|
||||
s.restoreHiddenWindows(windowBrowserLogin)
|
||||
require.False(t, main.visible, "the install popup still covers the main window")
|
||||
require.Equal(t, 0, d.raised)
|
||||
require.Equal(t, []string{windowInstallProgress, windowInstallProgress}, ownersOf(s.hiddenWindows))
|
||||
|
||||
s.restoreHiddenWindows(windowInstallProgress)
|
||||
require.True(t, main.visible, "the install popup restores the handed-over main window")
|
||||
require.Equal(t, 1, d.raised)
|
||||
require.Empty(t, s.hiddenWindows)
|
||||
}
|
||||
|
||||
func TestInstallClosingUnderLoginHandsMainToLogin(t *testing.T) {
|
||||
main := newFakeWindow(windowMain)
|
||||
install := newFakeWindow(windowInstallProgress)
|
||||
login := newFakeWindow(windowBrowserLogin)
|
||||
login.visible = false
|
||||
s := newTestWindowManager()
|
||||
d := newFakeDesktop(s, main, install, login)
|
||||
|
||||
s.hideOtherWindows(windowInstallProgress)
|
||||
login.visible = true
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
require.False(t, install.visible, "the login popup hides the install popup")
|
||||
|
||||
s.restoreHiddenWindows(windowInstallProgress)
|
||||
require.False(t, main.visible, "the login popup still covers the main window")
|
||||
require.Equal(t, 0, d.raised)
|
||||
require.Equal(t, []string{windowBrowserLogin, windowBrowserLogin}, ownersOf(s.hiddenWindows))
|
||||
|
||||
s.restoreHiddenWindows(windowBrowserLogin)
|
||||
require.True(t, main.visible)
|
||||
require.Equal(t, 1, d.raised)
|
||||
require.Empty(t, s.hiddenWindows)
|
||||
}
|
||||
|
||||
func TestPopupClosingReshowsTheCoveringPopupItself(t *testing.T) {
|
||||
main := newFakeWindow(windowMain)
|
||||
install := newFakeWindow(windowInstallProgress)
|
||||
login := newFakeWindow(windowBrowserLogin)
|
||||
login.visible = false
|
||||
s := newTestWindowManager()
|
||||
d := newFakeDesktop(s, main, install, login)
|
||||
|
||||
s.hideOtherWindows(windowInstallProgress)
|
||||
login.visible = true
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
|
||||
// The login popup hid the install popup itself; closing the login popup must bring
|
||||
// the install popup back rather than hand it over to its own owner.
|
||||
s.restoreHiddenWindows(windowBrowserLogin)
|
||||
require.True(t, install.visible, "a popup is never handed over to itself")
|
||||
require.False(t, main.visible, "the main window stays with the install popup")
|
||||
require.Equal(t, 0, d.raised)
|
||||
require.Equal(t, []string{windowInstallProgress}, ownersOf(s.hiddenWindows))
|
||||
}
|
||||
|
||||
func TestRestoreHiddenWindowsUnknownOwnerKeepsEverything(t *testing.T) {
|
||||
main := newFakeWindow(windowMain)
|
||||
s := newTestWindowManager()
|
||||
d := newFakeDesktop(s, main)
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
|
||||
s.restoreHiddenWindows(windowWelcome)
|
||||
|
||||
require.False(t, main.visible)
|
||||
require.Equal(t, []string{windowBrowserLogin}, ownersOf(s.hiddenWindows))
|
||||
require.Equal(t, 0, d.raised)
|
||||
}
|
||||
|
||||
func TestRestoreHiddenWindowsWithoutMainDoesNotRaise(t *testing.T) {
|
||||
settings := newFakeWindow(windowSettings)
|
||||
s := newTestWindowManager()
|
||||
d := newFakeDesktop(s, settings)
|
||||
s.hideOtherWindows(windowBrowserLogin)
|
||||
|
||||
s.restoreHiddenWindows(windowBrowserLogin)
|
||||
|
||||
require.True(t, settings.visible)
|
||||
require.Equal(t, 0, d.raised)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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(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()
|
||||
require.True(t, s.matchesGeneration(windowMain, 0))
|
||||
}
|
||||
|
||||
func TestPaintedGeneration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data any
|
||||
want uint64
|
||||
}{
|
||||
{"string", "7", 7},
|
||||
{"float", float64(7), 7},
|
||||
{"slice", []any{"7"}, 7},
|
||||
{"empty slice", []any{}, 0},
|
||||
{"unparsable", "abc", 0},
|
||||
{"nil", nil, 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, paintedGeneration(tc.data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user