mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-06 05:29:07 +02:00
[client] Fix browser login popup show from go (#7408)
* [client] Show the SSO login popup and open the browser from Go The browser-login popup was created hidden and relied on its own webview to size and show itself and to launch the external browser. On macOS a hidden WKWebView gets throttled or suspended (App Nap / hidden-window throttling), so on the first-use path nothing appeared and the browser never opened, leaving the session-expiration dialog disabled until the PKCE flow timed out. Reproduced by freezing the popup's WebContent process: the old code showed nothing, the new code shows the popup and opens the browser within 30 ms regardless of the webview state. Show and focus the popup from Go right after creation and launch the browser from Go on both the create and reuse paths. The popup's frontend no longer shows or focuses itself, so the browser keeps the foreground once it activates. This also fixes the reuse path, where a fragment-only SetURL kept the mounted React tree and the once-only guard skipped opening the browser for the new URI. Browser launch failures surface in the error dialog instead of being swallowed. * [client] Show every dialog window from Go once its frontend has painted Dialog windows (browser-login, session-expiration, install-progress, welcome, error) were created hidden and made visible only by their own webview's Show call after sizing. A hidden WKWebView on macOS can be throttled or suspended before that code runs, which left the window hidden forever. The main and settings windows already avoided this with the painted event plus a fallback timer, but that timer was armed on WindowRuntimeReady, which a frozen webview never reaches either. Route all dialogs through the same mechanism: the auto-size hook emits the painted event instead of showing the window, Go shows and focuses it on that event, and a fallback timer armed at creation shows it after 3 s regardless. The browser-login popup opens the browser in an after-show callback so the browser still lands in front of the popup, also on the fallback path. * [client] Tie install-progress hidden-window restore to the current popup CloseInstallProgress nils s.installProgress before calling w.Close(), so a replacement popup can open before the old window's WindowClosing event runs. The old callback then restored the windows the replacement had just hidden, because the restore sat outside the identity check. Guard the restore with the same check the state reset uses, and restore from CloseInstallProgress itself so the programmatic close path still re-shows the hidden windows — mirroring how CloseBrowserLogin already handles it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [client] Correlate painted reports with the window generation that sent them A painted report carried only the window name, so a late report from a popup that was already closed and replaced marked its replacement ready. The replacement was then shown before its own frontend had rendered, which is the blank-dialog case this flow exists to prevent. Each dialog start URL now carries a monotonic generation token, echoed back by ReadySignal, and a report whose token no longer matches the live window is dropped. * [client] Separate a window being painted from its frontend being mounted One flag gated both showing a window and emitting to it, so the fallback timer set it for a frontend that had not subscribed yet: the queued events were flushed into a window that could not hear them, losing the login trigger and the settings tab selection. Showing is now gated on painted and emitting on mounted, and only a real frontend report sets mounted. The fallback timer also moved to its own helper so the runtime-ready hook can rearm it, giving the frontend a full budget to mount rather than sharing one with webview boot. * [client] Tag hidden windows with the popup that hid them Windows hidden while a popup owned the screen went into one untagged list, so whichever popup closed first restored all of them and emptied the list. An install started during SSO login re-showed the main window the login popup had deliberately hidden, and left the login popup with nothing to restore. Each entry now records the popup that hid it, and a restore releases only that popup's own entries. This also subsumes the manual filtering CloseRenewFlow did to keep its own session-expiration window from being re-shown. * [client] Cover the hidden-window bookkeeping with tests application.Window carries unexported methods, so the hide/restore paths could not be faked and the earlier tests could only assert which entries survived a restore, never which windows were actually shown. The bookkeeping now goes through hideableWindow, the four methods it needs, with the window enumeration and the main-window raise behind seams that are nil in production. That makes the case the owner tag exists for testable end to end: an install started during SSO login restores only the login popup it hid, and leaves the main window hidden until the login popup itself closes. * [client] Report the first paint from unstamped windows too The main and settings windows carry no generation token, so ReadySignal saw an empty generation that already matched the ref's initial value and never emitted the painted event. Those windows only became visible through the fallback timer, and their frontend was never marked mounted, so the login trigger and the requested settings tab stayed queued. Start the ref from null so the first report goes out regardless of the generation value. * [client] Hand covered windows over when a popup closes under another Closing the browser-login popup while the install-progress popup was still up restored the main window the login had hidden, even though the install popup was meant to own the screen until it finished. The owner tag on each hidden entry only stops a popup from restoring another's windows; it says nothing about what to do with its own when a second popup still covers them. Track which popups currently own the screen and, on restore, re-tag the entries another live popup covers to that popup instead of showing them. A popup is never handed its own window, so closing the popup on top still brings the one below back. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6b3cfbabd2
commit
bc44cdc37a
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Events } from "@wailsio/runtime";
|
import { Events } from "@wailsio/runtime";
|
||||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||||
|
|
||||||
@@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted";
|
|||||||
|
|
||||||
export const ReadySignal = () => {
|
export const ReadySignal = () => {
|
||||||
const { isReady } = useStatus();
|
const { isReady } = useStatus();
|
||||||
const sent = useRef(false);
|
const [params] = useSearchParams();
|
||||||
|
const generation = params.get("gen") ?? "";
|
||||||
|
const sent = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReady || sent.current) return;
|
if (!isReady || sent.current === generation) return;
|
||||||
sent.current = true;
|
sent.current = generation;
|
||||||
void Events.Emit(EVENT_WINDOW_PAINTED);
|
void Events.Emit(EVENT_WINDOW_PAINTED, generation);
|
||||||
}, [isReady]);
|
}, [isReady, generation]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { useLayoutEffect, useRef } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { Window } from "@wailsio/runtime";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { Events, Window } from "@wailsio/runtime";
|
||||||
import i18next from "@/lib/i18n";
|
import i18next from "@/lib/i18n";
|
||||||
import { isLinux } from "@/lib/platform";
|
import { isLinux } from "@/lib/platform";
|
||||||
|
|
||||||
|
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
|
||||||
|
|
||||||
// Sizes the current Wails window to the measured content height (keeping `width`),
|
// Sizes the current Wails window to the measured content height (keeping `width`),
|
||||||
// then shows it. Re-applies on content resize and language change.
|
// then reports it as painted so Go shows it. Re-applies on content resize and language change.
|
||||||
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
|
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
|
||||||
const ref = useRef<T | null>(null);
|
const ref = useRef<T | null>(null);
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const generation = params.get("gen") ?? "";
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
let shown = false;
|
let painted = false;
|
||||||
let raf1 = 0;
|
let raf1 = 0;
|
||||||
let raf2 = 0;
|
let raf2 = 0;
|
||||||
const showOnce = () => {
|
const paintedOnce = () => {
|
||||||
if (shown) return;
|
if (painted) return;
|
||||||
shown = true;
|
painted = true;
|
||||||
Window.Show().catch(() => {});
|
Events.Emit(EVENT_WINDOW_PAINTED, generation).catch(() => {});
|
||||||
Window.Focus().catch(() => {});
|
|
||||||
};
|
};
|
||||||
const apply = async () => {
|
const apply = async () => {
|
||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
@@ -33,7 +37,7 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
|||||||
await Window.SetMaxSize(width, targetH);
|
await Window.SetMaxSize(width, targetH);
|
||||||
}
|
}
|
||||||
await Window.SetSize(width, targetH);
|
await Window.SetSize(width, targetH);
|
||||||
showOnce();
|
paintedOnce();
|
||||||
} catch {
|
} catch {
|
||||||
// window gone / not ready — ignore
|
// window gone / not ready — ignore
|
||||||
}
|
}
|
||||||
@@ -55,6 +59,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
|
|||||||
cancelAnimationFrame(raf2);
|
cancelAnimationFrame(raf2);
|
||||||
i18next.off("languageChanged", scheduleApply);
|
i18next.off("languageChanged", scheduleApply);
|
||||||
};
|
};
|
||||||
}, [width, ready]);
|
}, [width, ready, generation]);
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Events } from "@wailsio/runtime";
|
import { Events } from "@wailsio/runtime";
|
||||||
@@ -21,7 +21,6 @@ export default function LoginWaitingForBrowserDialog() {
|
|||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const uri = params.get("uri") ?? "";
|
const uri = params.get("uri") ?? "";
|
||||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||||
const openedRef = useRef(false);
|
|
||||||
|
|
||||||
const reportOpenFailure = useCallback(
|
const reportOpenFailure = useCallback(
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
@@ -33,13 +32,6 @@ export default function LoginWaitingForBrowserDialog() {
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Open the browser only after mount, or it lands on top of the still-hidden popup.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!uri || openedRef.current) return;
|
|
||||||
openedRef.current = true;
|
|
||||||
Connection.OpenURL(uri).catch(reportOpenFailure);
|
|
||||||
}, [uri, reportOpenFailure]);
|
|
||||||
|
|
||||||
const tryAgain = useCallback(() => {
|
const tryAgain = useCallback(() => {
|
||||||
if (!uri) return;
|
if (!uri) return;
|
||||||
Connection.OpenURL(uri).catch(reportOpenFailure);
|
Connection.OpenURL(uri).catch(reportOpenFailure);
|
||||||
|
|||||||
@@ -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
|
// window.open, so the SSO verification page can't pop inline. Honors $BROWSER
|
||||||
// before the platform default.
|
// before the platform default.
|
||||||
func (s *Connection) OpenURL(url string) error {
|
func (s *Connection) OpenURL(url string) error {
|
||||||
if browser := os.Getenv("BROWSER"); browser != "" {
|
return openURL(url)
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
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 {
|
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||||
return s.classifier.classify(err)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+364
-120
@@ -5,6 +5,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,6 +27,16 @@ type windowOp func(w *application.WebviewWindow, created bool)
|
|||||||
|
|
||||||
type windowCloser func(w *application.WebviewWindow)
|
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.
|
// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow.
|
||||||
const EventTriggerLogin = "trigger-login"
|
const EventTriggerLogin = "trigger-login"
|
||||||
|
|
||||||
@@ -37,7 +48,10 @@ const EventSettingsOpen = "netbird:settings:open"
|
|||||||
|
|
||||||
const EventWindowPainted = "netbird:window-painted"
|
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
|
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 {
|
type WindowManager struct {
|
||||||
app *application.App
|
app *application.App
|
||||||
mainWindow *application.WebviewWindow
|
mainWindow *application.WebviewWindow
|
||||||
@@ -213,19 +233,35 @@ type WindowManager struct {
|
|||||||
installProgress *application.WebviewWindow
|
installProgress *application.WebviewWindow
|
||||||
welcome *application.WebviewWindow
|
welcome *application.WebviewWindow
|
||||||
errorDialog *application.WebviewWindow
|
errorDialog *application.WebviewWindow
|
||||||
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
|
// hiddenWindows holds windows hidden while a popup owns the screen, each tagged with
|
||||||
hiddenForLogin []application.Window
|
// the popup that hid it so closing one popup cannot restore what another still hides.
|
||||||
mu sync.Mutex
|
hiddenWindows []hiddenWindow
|
||||||
newMain func(startURL string) *application.WebviewWindow
|
hiding map[string]bool
|
||||||
creating map[string]bool
|
// allWindows and raiseMain are the seams the hide/restore tests replace; both are nil
|
||||||
pendingOps map[string][]windowOp
|
// in production, where the Wails app and the platform helper are used directly.
|
||||||
pendingClose map[string]windowCloser
|
allWindows func() []hideableWindow
|
||||||
restoreGen uint64
|
raiseMain func()
|
||||||
ready map[uint]bool
|
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
|
showPending map[uint]bool
|
||||||
pendingTab map[uint]string
|
pendingTab map[uint]string
|
||||||
pendingEmits map[uint][]string
|
pendingEmits map[uint][]string
|
||||||
fallbackTimers map[uint]*time.Timer
|
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
|
headlessMain bool
|
||||||
headlessTimer *time.Timer
|
headlessTimer *time.Timer
|
||||||
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
// 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{},
|
creating: map[string]bool{},
|
||||||
pendingOps: map[string][]windowOp{},
|
pendingOps: map[string][]windowOp{},
|
||||||
pendingClose: map[string]windowCloser{},
|
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{},
|
showPending: map[uint]bool{},
|
||||||
pendingTab: map[uint]string{},
|
pendingTab: map[uint]string{},
|
||||||
pendingEmits: map[uint][]string{},
|
pendingEmits: map[uint][]string{},
|
||||||
fallbackTimers: map[uint]*time.Timer{},
|
fallbackTimers: map[uint]*time.Timer{},
|
||||||
|
afterShow: map[uint]func(){},
|
||||||
|
generation: map[string]uint64{},
|
||||||
}
|
}
|
||||||
s.watchPainted()
|
s.watchPainted()
|
||||||
s.watchTriggerLogin()
|
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.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
ready := s.ready[w.ID()]
|
mounted := s.mounted[w.ID()]
|
||||||
if !ready {
|
if !mounted {
|
||||||
s.pendingTab[w.ID()] = target
|
s.pendingTab[w.ID()] = target
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if ready {
|
if mounted {
|
||||||
s.app.Event.Emit(EventSettingsOpen, target)
|
s.app.Event.Emit(EventSettingsOpen, target)
|
||||||
}
|
}
|
||||||
s.showWhenReady(w)
|
s.showWhenReady(w)
|
||||||
@@ -327,21 +368,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
|
|||||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||||
}
|
}
|
||||||
s.withWindow(windowBrowserLogin, &s.browserLogin, func() *application.WebviewWindow {
|
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) {
|
}, func(w *application.WebviewWindow, created bool) {
|
||||||
if created {
|
if !created && uri != "" {
|
||||||
s.centerOnCursorScreen(w)
|
w.SetURL(s.stampGeneration(windowBrowserLogin, startURL))
|
||||||
return
|
|
||||||
}
|
|
||||||
if uri != "" {
|
|
||||||
w.SetURL(startURL)
|
|
||||||
}
|
}
|
||||||
s.centerOnCursorScreen(w)
|
s.centerOnCursorScreen(w)
|
||||||
w.Show()
|
s.showThenOpenBrowser(w, uri)
|
||||||
w.Focus()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (s *WindowManager) newBrowserLoginWindow(startURL string) *application.WebviewWindow {
|
||||||
s.hideOtherWindows(windowBrowserLogin)
|
s.hideOtherWindows(windowBrowserLogin)
|
||||||
opts := DialogWindowOptions(windowBrowserLogin, s.title("window.title.signIn"), startURL, s.linuxIcon)
|
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 {
|
if userClosed {
|
||||||
s.browserLogin = nil
|
s.browserLogin = nil
|
||||||
}
|
}
|
||||||
|
s.forgetWindowLocked(w)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if userClosed {
|
if userClosed {
|
||||||
s.restoreHiddenWindows()
|
s.restoreHiddenWindows(windowBrowserLogin)
|
||||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
s.armReady(w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,13 +445,11 @@ func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) CloseBrowserLogin() {
|
func (s *WindowManager) CloseBrowserLogin() {
|
||||||
// The WindowClosing hook no-ops on a programmatic close, so restore here —
|
// The WindowClosing hook no-ops on a programmatic close, so the closer restores.
|
||||||
// but only if a popup was actually open. The frontend calls this even when no
|
// The frontend calls this even when no popup was ever shown (resetDialog() after an
|
||||||
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
|
// early RequestExtend failure, or connection.ts's catch path); closeWindow skips the
|
||||||
// or connection.ts's catch path), and hiddenForLogin is shared with
|
// closer then, and an owner-scoped restore cannot touch what install-progress hides.
|
||||||
// OpenInstallProgress, so an unconditional restore could re-show windows a
|
s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoringCloser(windowBrowserLogin))
|
||||||
// still-running install-progress is hiding.
|
|
||||||
s.closeWindow(windowBrowserLogin, &s.browserLogin, s.restoreAndClose)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
|
// 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)
|
startURL += "&deadline=" + strconv.FormatInt(deadlineUnixMilli, 10)
|
||||||
}
|
}
|
||||||
s.withWindow(windowSessionExpiration, &s.sessionExpiration, func() *application.WebviewWindow {
|
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) {
|
}, func(w *application.WebviewWindow, created bool) {
|
||||||
if created {
|
if !created {
|
||||||
s.centerOnCursorScreen(w)
|
w.SetURL(s.stampGeneration(windowSessionExpiration, startURL))
|
||||||
return
|
|
||||||
}
|
}
|
||||||
w.SetURL(startURL)
|
|
||||||
s.centerOnCursorScreen(w)
|
s.centerOnCursorScreen(w)
|
||||||
w.Show()
|
s.showWhenReady(w)
|
||||||
w.Focus()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,8 +481,10 @@ func (s *WindowManager) newSessionExpirationWindow(startURL string) *application
|
|||||||
if s.sessionExpiration == w {
|
if s.sessionExpiration == w {
|
||||||
s.sessionExpiration = nil
|
s.sessionExpiration = nil
|
||||||
}
|
}
|
||||||
|
s.forgetWindowLocked(w)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
})
|
})
|
||||||
|
s.armReady(w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,20 +496,20 @@ func (s *WindowManager) CloseSessionExpiration() {
|
|||||||
// closes the browser-login popup and the session-expiration window together.
|
// closes the browser-login popup and the session-expiration window together.
|
||||||
func (s *WindowManager) CloseRenewFlow() {
|
func (s *WindowManager) CloseRenewFlow() {
|
||||||
s.mu.Lock()
|
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)
|
se := s.takeWindowLocked(windowSessionExpiration, &s.sessionExpiration, closeOnly)
|
||||||
if se != nil {
|
if se != nil {
|
||||||
kept := s.hiddenForLogin[:0]
|
kept := s.hiddenWindows[:0]
|
||||||
for _, w := range s.hiddenForLogin {
|
for _, hidden := range s.hiddenWindows {
|
||||||
if w != se {
|
if !sameWindow(hidden.win, se) {
|
||||||
kept = append(kept, w)
|
kept = append(kept, hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.hiddenForLogin = kept
|
s.hiddenWindows = kept
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.restoreHiddenWindows()
|
s.restoreHiddenWindows(windowBrowserLogin)
|
||||||
// Close after unlock so the re-entrant handlers can take s.mu.
|
// Close after unlock so the re-entrant handlers can take s.mu.
|
||||||
if bl != nil {
|
if bl != nil {
|
||||||
bl.Close()
|
bl.Close()
|
||||||
@@ -471,14 +527,12 @@ func (s *WindowManager) OpenInstallProgress(version string) {
|
|||||||
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
|
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
|
||||||
}
|
}
|
||||||
s.withWindow(windowInstallProgress, &s.installProgress, func() *application.WebviewWindow {
|
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) {
|
}, func(w *application.WebviewWindow, created bool) {
|
||||||
if !created {
|
if !created {
|
||||||
w.SetURL(startURL)
|
w.SetURL(s.stampGeneration(windowInstallProgress, startURL))
|
||||||
w.Show()
|
|
||||||
w.Focus()
|
|
||||||
}
|
}
|
||||||
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) {
|
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.installProgress == w {
|
userClosed := s.installProgress == w
|
||||||
|
if userClosed {
|
||||||
s.installProgress = nil
|
s.installProgress = nil
|
||||||
}
|
}
|
||||||
|
s.forgetWindowLocked(w)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
s.restoreHiddenWindows()
|
if userClosed {
|
||||||
|
s.restoreHiddenWindows(windowInstallProgress)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
s.armReady(w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) CloseInstallProgress() {
|
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.
|
// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close.
|
||||||
func (s *WindowManager) OpenWelcome() {
|
func (s *WindowManager) OpenWelcome() {
|
||||||
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, created bool) {
|
s.withWindow(windowWelcome, &s.welcome, s.newWelcomeWindow, func(w *application.WebviewWindow, _ bool) {
|
||||||
if !created {
|
s.showWhenReady(w)
|
||||||
w.Show()
|
|
||||||
w.Focus()
|
|
||||||
}
|
|
||||||
s.centerWhenReady(w)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
|
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.Width = 420
|
||||||
opts.InitialPosition = application.WindowCentered
|
opts.InitialPosition = application.WindowCentered
|
||||||
w := s.app.Window.NewWithOptions(opts)
|
w := s.app.Window.NewWithOptions(opts)
|
||||||
@@ -523,8 +578,10 @@ func (s *WindowManager) newWelcomeWindow() *application.WebviewWindow {
|
|||||||
if s.welcome == w {
|
if s.welcome == w {
|
||||||
s.welcome = nil
|
s.welcome = nil
|
||||||
}
|
}
|
||||||
|
s.forgetWindowLocked(w)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
})
|
})
|
||||||
|
s.armReady(w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,14 +599,12 @@ func (s *WindowManager) OpenError(title, message, command string) {
|
|||||||
}
|
}
|
||||||
startURL := errorDialogURL(title, message, command)
|
startURL := errorDialogURL(title, message, command)
|
||||||
s.withWindow(windowError, &s.errorDialog, func() *application.WebviewWindow {
|
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) {
|
}, func(w *application.WebviewWindow, created bool) {
|
||||||
if !created {
|
if !created {
|
||||||
w.SetURL(startURL)
|
w.SetURL(s.stampGeneration(windowError, startURL))
|
||||||
w.Show()
|
|
||||||
w.Focus()
|
|
||||||
}
|
}
|
||||||
s.centerWhenReady(w)
|
s.showWhenReady(w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,8 +617,10 @@ func (s *WindowManager) newErrorWindow(startURL string) *application.WebviewWind
|
|||||||
if s.errorDialog == w {
|
if s.errorDialog == w {
|
||||||
s.errorDialog = nil
|
s.errorDialog = nil
|
||||||
}
|
}
|
||||||
|
s.forgetWindowLocked(w)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
})
|
})
|
||||||
|
s.armReady(w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,14 +646,14 @@ func (s *WindowManager) ShowMainAndEmit(event string) {
|
|||||||
s.ensureMain("/", func(w *application.WebviewWindow, _ bool) {
|
s.ensureMain("/", func(w *application.WebviewWindow, _ bool) {
|
||||||
id := w.ID()
|
id := w.ID()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
ready := s.ready[id]
|
mounted := s.mounted[id]
|
||||||
if !ready {
|
if !mounted {
|
||||||
s.pendingEmits[id] = append(s.pendingEmits[id], event)
|
s.pendingEmits[id] = append(s.pendingEmits[id], event)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.showWhenReady(w)
|
s.showWhenReady(w)
|
||||||
if ready {
|
if mounted {
|
||||||
s.app.Event.Emit(event)
|
s.app.Event.Emit(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -741,31 +798,66 @@ func (s *WindowManager) releaseCreationLocked(name string) {
|
|||||||
delete(s.pendingClose, name)
|
delete(s.pendingClose, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) restoreAndClose(w *application.WebviewWindow) {
|
func (s *WindowManager) restoringCloser(owner string) windowCloser {
|
||||||
s.restoreHiddenWindows()
|
return func(w *application.WebviewWindow) {
|
||||||
w.Close()
|
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) {
|
func (s *WindowManager) armReady(w *application.WebviewWindow) {
|
||||||
if w == nil {
|
if w == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.armPaintedFallback(w)
|
||||||
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
|
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
|
||||||
timer := time.AfterFunc(paintedFallback, func() {
|
s.armPaintedFallback(w)
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
func (s *WindowManager) watchPainted() {
|
||||||
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
|
||||||
if w := s.windowByName(e.Sender); w != nil {
|
w := s.windowByName(e.Sender)
|
||||||
s.markReady(w)
|
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
|
s.headlessTimer = nil
|
||||||
}
|
}
|
||||||
w := s.mainWindow
|
w := s.mainWindow
|
||||||
ready := w != nil && s.ready[w.ID()]
|
ready := w != nil && s.mounted[w.ID()]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if ready {
|
if ready {
|
||||||
return
|
return
|
||||||
@@ -788,7 +880,7 @@ func (s *WindowManager) watchTriggerLogin() {
|
|||||||
if created {
|
if created {
|
||||||
s.headlessMain = true
|
s.headlessMain = true
|
||||||
}
|
}
|
||||||
pending := !s.ready[w.ID()]
|
pending := !s.mounted[w.ID()]
|
||||||
if pending {
|
if pending {
|
||||||
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
|
s.pendingEmits[w.ID()] = append(s.pendingEmits[w.ID()], EventTriggerLogin)
|
||||||
}
|
}
|
||||||
@@ -850,18 +942,67 @@ func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
|
|||||||
timer.Stop()
|
timer.Stop()
|
||||||
}
|
}
|
||||||
delete(s.fallbackTimers, id)
|
delete(s.fallbackTimers, id)
|
||||||
delete(s.ready, id)
|
delete(s.painted, id)
|
||||||
|
delete(s.mounted, id)
|
||||||
delete(s.showPending, id)
|
delete(s.showPending, id)
|
||||||
delete(s.pendingTab, id)
|
delete(s.pendingTab, id)
|
||||||
delete(s.pendingEmits, id)
|
delete(s.pendingEmits, id)
|
||||||
|
delete(s.afterShow, id)
|
||||||
|
|
||||||
kept := s.hiddenForLogin[:0]
|
kept := s.hiddenWindows[:0]
|
||||||
for _, hidden := range s.hiddenForLogin {
|
for _, hidden := range s.hiddenWindows {
|
||||||
if hidden != application.Window(w) {
|
if !sameWindow(hidden.win, w) {
|
||||||
kept = append(kept, hidden)
|
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 {
|
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
||||||
@@ -872,24 +1013,50 @@ func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
|
|||||||
return s.mainWindow
|
return s.mainWindow
|
||||||
case windowSettings:
|
case windowSettings:
|
||||||
return s.settings
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) markReady(w *application.WebviewWindow) {
|
func (s *WindowManager) markPainted(w *application.WebviewWindow) {
|
||||||
id := w.ID()
|
id := w.ID()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
already := s.ready[id]
|
already := s.painted[id]
|
||||||
s.ready[id] = true
|
s.painted[id] = true
|
||||||
wanted := s.showPending[id]
|
wanted := s.showPending[id]
|
||||||
tab, hasTab := s.pendingTab[id]
|
delete(s.showPending, id)
|
||||||
emits := s.pendingEmits[id]
|
|
||||||
if timer := s.fallbackTimers[id]; timer != nil {
|
if timer := s.fallbackTimers[id]; timer != nil {
|
||||||
timer.Stop()
|
timer.Stop()
|
||||||
delete(s.fallbackTimers, id)
|
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.pendingTab, id)
|
||||||
delete(s.pendingEmits, id)
|
delete(s.pendingEmits, id)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -902,10 +1069,6 @@ func (s *WindowManager) markReady(w *application.WebviewWindow) {
|
|||||||
s.app.Event.Emit(EventSettingsOpen, tab)
|
s.app.Event.Emit(EventSettingsOpen, tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wanted {
|
|
||||||
s.showNow(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, event := range emits {
|
for _, event := range emits {
|
||||||
s.app.Event.Emit(event)
|
s.app.Event.Emit(event)
|
||||||
}
|
}
|
||||||
@@ -918,18 +1081,19 @@ func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
|
|||||||
|
|
||||||
id := w.ID()
|
id := w.ID()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
ready := s.ready[id]
|
painted := s.painted[id]
|
||||||
if !ready {
|
if !painted {
|
||||||
s.showPending[id] = true
|
s.showPending[id] = true
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if ready {
|
if painted {
|
||||||
s.showNow(w)
|
s.showNow(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
||||||
|
id := w.ID()
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if w == s.mainWindow {
|
if w == s.mainWindow {
|
||||||
s.headlessMain = false
|
s.headlessMain = false
|
||||||
@@ -938,10 +1102,15 @@ func (s *WindowManager) showNow(w *application.WebviewWindow) {
|
|||||||
s.headlessTimer = nil
|
s.headlessTimer = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
after := s.afterShow[id]
|
||||||
|
delete(s.afterShow, id)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
w.Show()
|
w.Show()
|
||||||
w.Focus()
|
w.Focus()
|
||||||
s.centerWhenReady(w)
|
s.centerWhenReady(w)
|
||||||
|
if after != nil {
|
||||||
|
after()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WindowManager) ShowMainAt(url string) {
|
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) {
|
func (s *WindowManager) hideOtherWindows(keepName string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
gen := s.restoreGen
|
s.hiding[keepName] = true
|
||||||
|
gen := s.restoreGen[keepName]
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
var hidden []application.Window
|
var hidden []hideableWindow
|
||||||
for _, w := range s.app.Window.GetAll() {
|
for _, w := range s.hideableWindows() {
|
||||||
if w == nil || w.Name() == keepName || !w.IsVisible() {
|
if w == nil || w.Name() == keepName || !w.IsVisible() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1088,9 +1263,11 @@ func (s *WindowManager) hideOtherWindows(keepName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
restored := s.restoreGen != gen
|
restored := s.restoreGen[keepName] != gen
|
||||||
if !restored {
|
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()
|
s.mu.Unlock()
|
||||||
if !restored {
|
if !restored {
|
||||||
@@ -1101,33 +1278,58 @@ func (s *WindowManager) hideOtherWindows(keepName string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreHiddenWindows re-shows windows hidden by hideOtherWindows. If the main
|
// restoreHiddenWindows re-shows the windows owner hid, unless another popup still covers
|
||||||
// window was among them, raiseToForeground lifts it above the SSO browser, which
|
// them, in which case they are handed to that popup. If the main window was among them,
|
||||||
// still owns the foreground — a plain Show/Focus would be demoted to a taskbar
|
// raiseToForeground lifts it above the SSO browser, which still owns the foreground — a
|
||||||
// flash and leave it stranded behind.
|
// plain Show/Focus would be demoted to a taskbar flash and leave it stranded behind.
|
||||||
func (s *WindowManager) restoreHiddenWindows() {
|
func (s *WindowManager) restoreHiddenWindows(owner string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
hidden := s.hiddenForLogin
|
|
||||||
s.hiddenForLogin = nil
|
|
||||||
s.restoreGen++
|
|
||||||
mainWindow := s.mainWindow
|
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()
|
s.mu.Unlock()
|
||||||
|
|
||||||
mainRestored := false
|
mainRestored := false
|
||||||
for _, w := range hidden {
|
for _, w := range restore {
|
||||||
if w == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
w.Show()
|
w.Show()
|
||||||
if w == mainWindow {
|
if s.isMainWindow(w, mainWindow) {
|
||||||
mainRestored = true
|
mainRestored = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if mainRestored && mainWindow != nil {
|
if mainRestored {
|
||||||
raiseToForeground(mainWindow)
|
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
|
// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the
|
||||||
// main-window screen, then nil (OS-default placement).
|
// main-window screen, then nil (OS-default placement).
|
||||||
func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
|
func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
|
||||||
@@ -1169,6 +1371,48 @@ func errorDialogURL(title, message, command string) string {
|
|||||||
return startURL
|
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.
|
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
|
||||||
func u32ptr(v uint32) *uint32 { return &v }
|
func u32ptr(v uint32) *uint32 { return &v }
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,65 @@ func newTestWindowManager() *WindowManager {
|
|||||||
creating: map[string]bool{},
|
creating: map[string]bool{},
|
||||||
pendingOps: map[string][]windowOp{},
|
pendingOps: map[string][]windowOp{},
|
||||||
pendingClose: map[string]windowCloser{},
|
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) {
|
func waitDone(t *testing.T, done <-chan struct{}, msg string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
select {
|
select {
|
||||||
@@ -339,12 +395,245 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
|
|||||||
// Seeded after the call so the deferred closer, not CloseRenewFlow's own
|
// 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
|
// immediate restore, is what has to drain it. A nil entry is skipped by
|
||||||
// restoreHiddenWindows, so no Wails window is needed.
|
// restoreHiddenWindows, so no Wails window is needed.
|
||||||
s.hiddenForLogin = []application.Window{nil}
|
s.hiddenWindows = []hiddenWindow{{owner: windowBrowserLogin}}
|
||||||
return &application.WebviewWindow{}
|
return &application.WebviewWindow{}
|
||||||
}, func(*application.WebviewWindow, bool) {})
|
}, func(*application.WebviewWindow, bool) {})
|
||||||
|
|
||||||
require.Nil(t, s.browserLogin)
|
require.Nil(t, s.browserLogin)
|
||||||
require.Empty(t, s.hiddenForLogin)
|
require.Empty(t, s.hiddenWindows)
|
||||||
require.Empty(t, s.creating)
|
require.Empty(t, s.creating)
|
||||||
require.Empty(t, s.pendingClose)
|
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