Compare commits

...

3 Commits

Author SHA1 Message Date
Zoltan Papp
bc73e6bd71 [client] Debug: create GUI windows on demand and destroy them on close
Experiment on top of the memory profiler: the main and Settings windows are no
longer created at startup or kept alive hidden, so an idle tray holds no webview
process. The WindowManager owns creation, rebuilds the main window on the next
show, and defers every show until the frontend reports it has rendered, which
keeps an empty webview off screen.

Measured on macOS: idle footprint drops from ~160 MB to ~74 MB, at the cost of
~150-250 ms per window open.
2026-08-07 14:43:52 +02:00
Zoltan Papp
04ca92d42b [client] Debug: add process tree and window inventory to GUI memory dumps
The webview runs in child processes whose memory the Go runtime profiles
cannot see, so each snapshot now also records the whole process tree with
RSS/VMS plus PSS and Private_Dirty from smaps_rollup on Linux, and the live
Wails window inventory. Snapshots now run at startup, 2 and 5 minutes.
2026-08-07 14:43:29 +02:00
Zoltan Papp
079aee8d63 [client] Debug: dump GUI memory profiles to /tmp/nbgui
Temporary debug patch for the GUI memory consumption investigation. Writes
two snapshots - one at startup, one after 5 minutes - each into its own
/tmp/nbgui/<timestamp>-<pid> directory containing the heap, goroutine and
threadcreate profiles plus a memstats.txt summary.
2026-08-07 14:43:29 +02:00
8 changed files with 619 additions and 74 deletions

View File

@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
import { Events } from "@wailsio/runtime";
import { useStatus } from "@/contexts/StatusContext.tsx";
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
export const ReadySignal = () => {
const { isReady } = useStatus();
const sent = useRef(false);
useEffect(() => {
if (!isReady || sent.current) return;
sent.current = true;
void Events.Emit(EVENT_WINDOW_PAINTED);
}, [isReady]);
return null;
};

View File

@@ -5,6 +5,7 @@ import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
import { RestrictionsProvider } from "@/contexts/RestrictionsContext.tsx";
import { ReadySignal } from "@/components/ReadySignal.tsx";
export const AppLayout = () => {
return (
@@ -16,6 +17,7 @@ export const AppLayout = () => {
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
<ReadySignal />
</ClientVersionProvider>
</DebugBundleProvider>
</RestrictionsProvider>

View File

@@ -95,6 +95,10 @@ func main() {
}
})
// Debug patch, not for release: dumps heap/goroutine profiles and the
// process tree to /tmp/nbgui for the memory consumption investigation.
startMemProfiler(app)
profiles := services.NewProfiles(conn)
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
// Update service is a thin Wails-bound facade over it plus the install RPCs.
@@ -139,13 +143,11 @@ func main() {
prefStore: prefStore,
})
window := newMainWindow(app, prefStore)
// Settings is created eagerly (hidden) so the first gear click paints
// instantly and React keeps per-tab state across reopens. The other
// auxiliary windows stay lazy + destroy-on-close so Wails's macOS
// dock-reopen handler can't resurrect them.
windowManager := services.NewWindowManager(app, window, bundle, prefStore, iconWindow)
windowManager := services.NewWindowManager(app, nil, bundle, prefStore, iconWindow)
windowManager.SetMainFactory(func() *application.WebviewWindow {
return newMainWindow(app, prefStore, windowManager)
})
registerDockReopenHook(app, windowManager)
// Minimal WMs (XEmbed-tray path) neither center small windows nor restore
// position across hide -> show, dropping them top-left. Gate Go-side
// re-centering on that environment; nil leaves placement to the WM on full
@@ -168,7 +170,7 @@ func main() {
// RegisterStatusNotifierItem hits a watcher we control.
startStatusNotifierWatcher()
tray = NewTray(app, window, TrayServices{
tray = NewTray(app, nil, TrayServices{
Connection: connection,
Settings: settings,
Profiles: profiles,
@@ -338,9 +340,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
app.RegisterService(application.NewService(s.compat))
}
// newMainWindow creates the hidden main window, sized to the user's last view
// mode, and installs the hide-on-close and macOS dock-reopen hooks.
func newMainWindow(app *application.App, prefStore *preferences.Store) *application.WebviewWindow {
func newMainWindow(app *application.App, prefStore *preferences.Store, wm *services.WindowManager) *application.WebviewWindow {
// Width matches the last view mode so Advanced-mode users don't see the
// window pop from 380px to 900px on launch. Height is mode-agnostic.
initialWidth := 380
@@ -368,29 +368,25 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
},
})
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
window.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
if services.ShuttingDown() {
return
}
e.Cancel()
window.Hide()
wm.ForgetMain()
})
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
window.Show()
window.Focus()
})
}
return window
}
func registerDockReopenHook(app *application.App, wm *services.WindowManager) {
if runtime.GOOS != "darwin" {
return
}
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
wm.ShowMain()
})
}

319
client/ui/memprof.go Normal file
View File

@@ -0,0 +1,319 @@
//go:build !android && !ios && !freebsd && !js
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v4/process"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
// memProfOffsets are the snapshot times measured from application startup.
var memProfOffsets = []time.Duration{0, 2 * time.Minute, 5 * time.Minute}
// memProfMaxDepth bounds the child walk so a cycle in the reported parent links
// cannot spin forever.
const memProfMaxDepth = 4
type memProfileSpec struct {
profile string
file string
debug int
}
var memProfileSpecs = []memProfileSpec{
{profile: "heap", file: "heap.pprof", debug: 0},
{profile: "heap", file: "heap.txt", debug: 1},
{profile: "goroutine", file: "goroutine.txt", debug: 1},
{profile: "threadcreate", file: "threadcreate.txt", debug: 1},
}
var memProfStart = time.Now()
// startMemProfiler dumps a profile snapshot at every memProfOffsets mark, each
// into its own timestamped directory under memProfBaseDir. The first runs once
// the application is up so the window inventory sees the eagerly created
// windows. Every failure is logged and never stops the GUI.
func startMemProfiler(app *application.App) {
log.Infof("memory profiler enabled, writing to %s (snapshots at %v after startup)", memProfBaseDir(), memProfOffsets)
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
go func() {
started := time.Now()
for _, offset := range memProfOffsets {
if wait := time.Until(started.Add(offset)); wait > 0 {
time.Sleep(wait)
}
writeMemProfile(app)
}
}()
})
}
// memProfBaseDir returns the directory holding the snapshot directories.
func memProfBaseDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(os.TempDir(), "nbgui")
}
return "/tmp/nbgui"
}
// writeMemProfile creates a <timestamp>-<pid> directory and fills it with the
// runtime profiles, the memory statistics summary and the process tree.
func writeMemProfile(app *application.App) {
name := fmt.Sprintf("%s-%d", time.Now().Format("20060102-150405"), os.Getpid())
dir := filepath.Join(memProfBaseDir(), name)
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Warnf("create memory profile dir %s: %v", dir, err)
return
}
// The heap profile reports live objects as of the last collection, so force
// one to keep inuse_space from counting garbage that is already unreachable.
runtime.GC()
if err := writeMemStats(filepath.Join(dir, "memstats.txt"), app); err != nil {
log.Warnf("write memory statistics: %v", err)
}
if err := writeProcTree(filepath.Join(dir, "proctree.txt")); err != nil {
log.Warnf("write process tree: %v", err)
}
for _, spec := range memProfileSpecs {
if err := writeMemProfileFile(spec, filepath.Join(dir, spec.file)); err != nil {
log.Warnf("write %s profile: %v", spec.profile, err)
}
}
log.Infof("memory profile written to %s", dir)
}
// writeMemProfileFile writes a single runtime profile to path.
func writeMemProfileFile(spec memProfileSpec, path string) error {
p := pprof.Lookup(spec.profile)
if p == nil {
return fmt.Errorf("unknown profile %q", spec.profile)
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create %s: %w", path, err)
}
defer func() {
if err := f.Close(); err != nil {
log.Debugf("close %s: %v", path, err)
}
}()
if err := p.WriteTo(f, spec.debug); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// writeMemStats dumps the runtime memory statistics next to the process
// resident set size. A resident set much larger than Sys means the memory sits
// outside the Go heap (webview, GTK, other cgo allocations), where the pprof
// profiles cannot see it.
func writeMemStats(path string, app *application.App) error {
var m runtime.MemStats
runtime.ReadMemStats(&m)
var b strings.Builder
fmt.Fprintf(&b, "time: %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(&b, "uptime: %s\n", time.Since(memProfStart).Round(time.Second))
fmt.Fprintf(&b, "pid: %d\n", os.Getpid())
fmt.Fprintf(&b, "\n")
rss, vms := processMemory()
fmt.Fprintf(&b, "process_rss: %s\n", rss)
fmt.Fprintf(&b, "process_vms: %s\n", vms)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "sys: %s\n", formatMemBytes(m.Sys))
fmt.Fprintf(&b, "heap_alloc: %s\n", formatMemBytes(m.HeapAlloc))
fmt.Fprintf(&b, "heap_sys: %s\n", formatMemBytes(m.HeapSys))
fmt.Fprintf(&b, "heap_inuse: %s\n", formatMemBytes(m.HeapInuse))
fmt.Fprintf(&b, "heap_idle: %s\n", formatMemBytes(m.HeapIdle))
fmt.Fprintf(&b, "heap_released: %s\n", formatMemBytes(m.HeapReleased))
fmt.Fprintf(&b, "heap_objects: %d\n", m.HeapObjects)
fmt.Fprintf(&b, "stack_inuse: %s\n", formatMemBytes(m.StackInuse))
fmt.Fprintf(&b, "stack_sys: %s\n", formatMemBytes(m.StackSys))
fmt.Fprintf(&b, "mspan_sys: %s\n", formatMemBytes(m.MSpanSys))
fmt.Fprintf(&b, "mcache_sys: %s\n", formatMemBytes(m.MCacheSys))
fmt.Fprintf(&b, "gc_sys: %s\n", formatMemBytes(m.GCSys))
fmt.Fprintf(&b, "other_sys: %s\n", formatMemBytes(m.OtherSys))
fmt.Fprintf(&b, "next_gc: %s\n", formatMemBytes(m.NextGC))
fmt.Fprintf(&b, "num_gc: %d\n", m.NumGC)
fmt.Fprintf(&b, "\n")
fmt.Fprintf(&b, "goroutines: %d\n", runtime.NumGoroutine())
fmt.Fprintf(&b, "cgo_calls: %d\n", runtime.NumCgoCall())
fmt.Fprintf(&b, "gomaxprocs: %d\n", runtime.GOMAXPROCS(0))
fmt.Fprintf(&b, "\n")
writeWindowInventory(&b, app)
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// writeWindowInventory lists the live Wails windows. A window that exists holds
// a webview process even while hidden, so this tells apart a leaked window (the
// count grows) from windows whose content grew (the count stays put).
func writeWindowInventory(b *strings.Builder, app *application.App) {
windows := app.Window.GetAll()
fmt.Fprintf(b, "windows: %d\n", len(windows))
for _, w := range windows {
visible := "unknown"
if ww, ok := w.(*application.WebviewWindow); ok {
visible = strconv.FormatBool(ww.IsVisible())
}
fmt.Fprintf(b, " id=%-3d name=%-20q visible=%-7s minimised=%-5t focused=%t\n",
w.ID(), w.Name(), visible, w.IsMinimised(), w.IsFocused())
}
}
// writeProcTree dumps this process and its descendants with their memory
// footprint. The webview runs in child processes whose memory the Go runtime
// profiles cannot see, so this is what attributes a footprint to a component.
func writeProcTree(path string) error {
self, err := process.NewProcess(int32(os.Getpid()))
if err != nil {
return fmt.Errorf("open own process: %w", err)
}
var b strings.Builder
fmt.Fprintf(&b, "time: %s\n", time.Now().Format(time.RFC3339))
fmt.Fprintf(&b, "uptime: %s\n\n", time.Since(memProfStart).Round(time.Second))
fmt.Fprintf(&b, "%-8s %-8s %-28s %12s %12s %12s %12s\n", "PID", "PPID", "NAME", "RSS", "VMS", "PSS", "PRIV_DIRTY")
var totalRSS, totalPSS, totalPrivate uint64
walkProcTree(&b, self, 0, &totalRSS, &totalPSS, &totalPrivate)
fmt.Fprintf(&b, "\n%-8s %-8s %-28s %12s %12s %12s %12s\n", "", "", "TOTAL",
formatKB(totalRSS), "", formatKB(totalPSS), formatKB(totalPrivate))
fmt.Fprintf(&b, "\nPSS and PRIV_DIRTY come from /proc/<pid>/smaps_rollup and are Linux only.\n")
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// walkProcTree appends one line per process, depth-first, accumulating totals.
func walkProcTree(b *strings.Builder, p *process.Process, depth int, totalRSS, totalPSS, totalPrivate *uint64) {
name, err := p.Name()
if err != nil {
name = "unknown"
}
var rss, vms uint64
if info, err := p.MemoryInfo(); err == nil {
rss, vms = info.RSS, info.VMS
}
pss, private := smapsRollup(p.Pid)
*totalRSS += rss
*totalPSS += pss
*totalPrivate += private
ppid, err := p.Ppid()
if err != nil {
ppid = -1
}
fmt.Fprintf(b, "%-8d %-8d %-28s %12s %12s %12s %12s\n", p.Pid, ppid,
strings.Repeat(" ", depth)+name, formatKB(rss), formatKB(vms), formatKB(pss), formatKB(private))
if depth >= memProfMaxDepth {
return
}
children, err := p.Children()
if err != nil {
return
}
for _, child := range children {
walkProcTree(b, child, depth+1, totalRSS, totalPSS, totalPrivate)
}
}
// smapsRollup returns the proportional set size and private dirty bytes of pid,
// both zero on platforms without /proc.
func smapsRollup(pid int32) (uint64, uint64) {
f, err := os.Open(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
if err != nil {
return 0, 0
}
defer func() {
if err := f.Close(); err != nil {
log.Debugf("close smaps_rollup for %d: %v", pid, err)
}
}()
var pss, private uint64
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
kb, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
continue
}
switch fields[0] {
case "Pss:":
pss = kb * 1024
case "Private_Dirty:":
private = kb * 1024
}
}
return pss, private
}
// processMemory returns the formatted resident and virtual size of this process.
func processMemory() (string, string) {
p, err := process.NewProcess(int32(os.Getpid()))
if err != nil {
unavailable := fmt.Sprintf("unavailable (%v)", err)
return unavailable, unavailable
}
info, err := p.MemoryInfo()
if err != nil {
unavailable := fmt.Sprintf("unavailable (%v)", err)
return unavailable, unavailable
}
return formatMemBytes(info.RSS), formatMemBytes(info.VMS)
}
// formatMemBytes renders a byte count as megabytes with the raw value kept.
func formatMemBytes(n uint64) string {
return fmt.Sprintf("%8.1f MB (%d bytes)", float64(n)/(1024*1024), n)
}
// formatKB renders a byte count as megabytes for the process tree columns, and
// a dash when the platform did not report the value.
func formatKB(n uint64) string {
if n == 0 {
return "-"
}
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}

View File

@@ -8,6 +8,7 @@ import (
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
@@ -29,6 +30,10 @@ const EventBrowserLoginCancel = "browser-login:cancel"
// EventSettingsOpen tells the mounted settings window which tab to show.
const EventSettingsOpen = "netbird:settings:open"
const EventWindowPainted = "netbird:window-painted"
const paintedFallback = 2 * time.Second
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
// WindowHeight is shared by the main and Settings windows.
@@ -94,9 +99,6 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
}
}
// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
type WindowManager struct {
app *application.App
mainWindow *application.WebviewWindow
@@ -112,15 +114,31 @@ type WindowManager struct {
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
hiddenForLogin []application.Window
mu sync.Mutex
newMain func() *application.WebviewWindow
ready map[uint]bool
showPending map[uint]bool
showAsked map[uint]time.Time
pendingTab map[uint]string
fallbackTimers map[uint]*time.Timer
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
recenterOnShow func() bool
}
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
// Settings window is created here (hidden) so the first OpenSettings is instant.
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
s := &WindowManager{
app: app,
mainWindow: mainWindow,
translator: translator,
prefs: prefs,
linuxIcon: linuxIcon,
ready: map[uint]bool{},
showPending: map[uint]bool{},
showAsked: map[uint]time.Time{},
pendingTab: map[uint]string{},
fallbackTimers: map[uint]*time.Timer{},
}
s.watchPainted()
// Re-title live windows on language flip. Wired internally so the binding generator
// doesn't try to expose the interface param.
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
@@ -136,7 +154,11 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
}
}()
}
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
return s
}
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
Title: s.title("window.title.settings"),
Width: 900,
@@ -150,18 +172,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
URL: "/#/settings",
Mac: AppleMacOSAppearanceOptions(),
Windows: MicrosoftWindowsAppearanceOptions(),
Linux: LinuxAppearanceOptions(linuxIcon),
Linux: LinuxAppearanceOptions(s.linuxIcon),
})
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if ShuttingDown() {
return
}
e.Cancel()
s.app.Event.Emit(EventSettingsOpen, "general")
s.settings.Hide()
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.mu.Lock()
s.settings = nil
s.forgetWindowLocked(w)
s.mu.Unlock()
})
return s
return w
}
// OpenSettings shows the settings window on tab (empty → General), switching tab via
@@ -171,11 +190,23 @@ func (s *WindowManager) OpenSettings(tab string) {
if target == "" {
target = "general"
}
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.settings)
s.mu.Lock()
fresh := s.settings == nil
if fresh {
s.settings = s.newSettingsWindow()
s.armReady(s.settings)
}
w := s.settings
if fresh {
s.pendingTab[w.ID()] = target
}
s.mu.Unlock()
if !fresh {
s.app.Event.Emit(EventSettingsOpen, target)
}
s.showWhenReady(w)
}
// OpenBrowserLogin shows the SSO popup, creating it on first use.
@@ -440,13 +471,167 @@ func (s *WindowManager) OpenMain() {
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
func (s *WindowManager) ShowMain() {
if s.mainWindow == nil {
s.showWhenReady(s.MainWindow())
}
func (s *WindowManager) MainWindow() *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
if s.mainWindow == nil && s.newMain != nil {
s.mainWindow = s.newMain()
s.armReady(s.mainWindow)
}
return s.mainWindow
}
func (s *WindowManager) armReady(w *application.WebviewWindow) {
if w == nil {
return
}
s.mainWindow.Show()
s.mainWindow.Focus()
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.mainWindow)
created := time.Now()
w.RegisterHook(events.Common.WindowRuntimeReady, func(_ *application.WindowEvent) {
log.Infof("window %q runtime ready after %s", w.Name(), time.Since(created).Round(time.Millisecond))
timer := time.AfterFunc(paintedFallback, func() {
s.markReady(w, "fallback")
})
s.mu.Lock()
s.fallbackTimers[w.ID()] = timer
s.mu.Unlock()
})
}
func (s *WindowManager) watchPainted() {
s.app.Event.On(EventWindowPainted, func(e *application.CustomEvent) {
w := s.windowByName(e.Sender)
if w == nil {
log.Infof("painted event from unknown sender %q", e.Sender)
return
}
s.markReady(w, "painted")
})
}
func (s *WindowManager) forgetWindowLocked(w *application.WebviewWindow) {
if w == nil {
return
}
id := w.ID()
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
}
delete(s.fallbackTimers, id)
delete(s.ready, id)
delete(s.showPending, id)
delete(s.showAsked, id)
delete(s.pendingTab, id)
kept := s.hiddenForLogin[:0]
for _, hidden := range s.hiddenForLogin {
if hidden != application.Window(w) {
kept = append(kept, hidden)
}
}
s.hiddenForLogin = kept
}
func (s *WindowManager) windowByName(name string) *application.WebviewWindow {
s.mu.Lock()
defer s.mu.Unlock()
switch name {
case "main":
return s.mainWindow
case "settings":
return s.settings
default:
return nil
}
}
func (s *WindowManager) markReady(w *application.WebviewWindow, source string) {
id := w.ID()
s.mu.Lock()
already := s.ready[id]
s.ready[id] = true
wanted := s.showPending[id]
asked := s.showAsked[id]
tab, hasTab := s.pendingTab[id]
if timer := s.fallbackTimers[id]; timer != nil {
timer.Stop()
delete(s.fallbackTimers, id)
}
delete(s.showPending, id)
delete(s.showAsked, id)
delete(s.pendingTab, id)
s.mu.Unlock()
if already {
return
}
if hasTab {
s.app.Event.Emit(EventSettingsOpen, tab)
}
waited := "no show waiting"
if wanted && !asked.IsZero() {
waited = time.Since(asked).Round(time.Millisecond).String()
}
log.Infof("window %q ready via %s, show waited %s", w.Name(), source, waited)
if wanted {
s.showNow(w)
}
}
func (s *WindowManager) showWhenReady(w *application.WebviewWindow) {
if w == nil {
return
}
id := w.ID()
s.mu.Lock()
ready := s.ready[id]
if !ready {
s.showPending[id] = true
s.showAsked[id] = time.Now()
}
s.mu.Unlock()
if ready {
log.Infof("window %q already ready, showing now", w.Name())
s.showNow(w)
return
}
log.Infof("window %q not ready yet, deferring show", w.Name())
}
func (s *WindowManager) showNow(w *application.WebviewWindow) {
w.Show()
w.Focus()
s.centerWhenReady(w)
}
func (s *WindowManager) ShowMainAt(url string) {
w := s.MainWindow()
if w == nil {
return
}
w.SetURL(url)
s.showWhenReady(w)
}
func (s *WindowManager) SetMainFactory(f func() *application.WebviewWindow) {
s.mu.Lock()
defer s.mu.Unlock()
s.newMain = f
}
func (s *WindowManager) ForgetMain() {
s.mu.Lock()
defer s.mu.Unlock()
s.forgetWindowLocked(s.mainWindow)
s.mainWindow = nil
}
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).

View File

@@ -174,7 +174,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// in the right locale — no English flash then re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.updater = newTrayUpdater(app, t.showMainAt, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection before the first paint so the initial icon
// matches the panel's light/dark scheme (Linux only).
@@ -241,9 +241,6 @@ func (t *Tray) ShowWindow() {
w.Focus()
return
}
if t.window == nil {
return
}
// Route through WindowManager so the main window is centered on first
// show — minimal WMs (fluxbox, the XEmbed tray path) otherwise drop it in
// the top-left corner.
@@ -251,8 +248,40 @@ func (t *Tray) ShowWindow() {
t.svc.WindowManager.ShowMain()
return
}
t.window.Show()
t.window.Focus()
if w := t.mainWindow(); w != nil {
w.Show()
w.Focus()
}
}
func (t *Tray) mainWindow() *application.WebviewWindow {
if t.svc.WindowManager == nil {
return t.window
}
return t.svc.WindowManager.MainWindow()
}
func (t *Tray) showMain() {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
if w := t.mainWindow(); w != nil {
w.Show()
w.Focus()
}
}
func (t *Tray) showMainAt(url string) {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMainAt(url)
return
}
if w := t.mainWindow(); w != nil {
w.SetURL(url)
w.Show()
w.Focus()
}
}
// applyLanguage re-renders every translated surface in the Localizer's current

View File

@@ -30,10 +30,7 @@ const (
// handleSessionExpired notifies and brings the window forward so the user can reconnect.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.Show()
t.window.Focus()
}
t.showMain()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -307,6 +304,7 @@ func (t *Tray) openSessionExtendFlow() {
}
seconds := int(time.Until(deadline).Seconds())
if seconds <= 0 {
t.showMain()
t.app.Event.Emit(services.EventTriggerLogin)
return
}

View File

@@ -19,7 +19,7 @@ import (
// trayUpdater owns the tray UI that reacts to auto-update. Composed inside Tray.
type trayUpdater struct {
app *application.App
window *application.WebviewWindow
showMainAt func(url string)
update *services.Update
notifier *Notifier
loc *Localizer
@@ -36,10 +36,10 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, showMainAt func(url string), update *services.Update, notifier *Notifier, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
showMainAt: showMainAt,
update: update,
notifier: notifier,
loc: loc,
@@ -185,14 +185,12 @@ func (u *trayUpdater) sendUpdateNotification(st updater.State) {
// openProgressWindow points the main window at the /update progress page and
// brings it forward.
func (u *trayUpdater) openProgressWindow(version string) {
if u.window == nil {
if u.showMainAt == nil {
return
}
url := "/#/update"
if version != "" {
url += "?version=" + version
}
u.window.SetURL(url)
u.window.Show()
u.window.Focus()
u.showMainAt(url)
}