From ebaaddc14b48730f71a574ae37bd46b8791280bd Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 16 Jun 2026 11:32:04 +0200 Subject: [PATCH] show macos dock icon when any window is visible --- client/ui/CLAUDE.md | 1 + client/ui/dock_darwin.go | 70 ++++++++++++++++++++++++++++++++++++++++ client/ui/dock_other.go | 6 ++++ client/ui/main.go | 1 + 4 files changed, 78 insertions(+) create mode 100644 client/ui/dock_darwin.go create mode 100644 client/ui/dock_other.go diff --git a/client/ui/CLAUDE.md b/client/ui/CLAUDE.md index 27a1ce0e3..213d3eacf 100644 --- a/client/ui/CLAUDE.md +++ b/client/ui/CLAUDE.md @@ -8,6 +8,7 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th ### Go (top-level package `main`) - `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; default is **empty** so "no flag" is distinguishable from explicit `--log-file console` — when empty `parseFlagsAndInitLog` falls back to `console` for `InitLog` and returns `userSetLogFile=false`), `--log-level` (`trace|debug|info|warn|error`, default `info`). See "GUI debug logging" below for what `userSetLogFile` gates. +- `dock_darwin.go` / `dock_other.go` — flips the macOS dock entry to follow window visibility. Wails seeds `Mac.ActivationPolicy` at startup but exposes no runtime setter, so `dock_darwin.go` calls `[NSApp setActivationPolicy:]` directly via cgo (with `activateIgnoringOtherApps:` on the 0→1 transition only — without it a newly-shown window opens behind whatever app the user is currently focused on; gating on the transition stops auxiliaries opening on top of an already-visible main from stealing focus). **The Go side is one call** — `main.go` does `initDockObserver()` from the `ApplicationStarted` hook (so NSApp is up). Everything else lives in cgo: the observer registers default-`NSNotificationCenter` block subscriptions for `NSWindowDidChangeOcclusionStateNotification` and `NSWindowWillCloseNotification` (`object:nil` so windows created later are picked up automatically — no per-window plumbing on the Go side, no `WindowManager` constructor wiring), each firing `refreshDockPolicy`. `refreshDockPolicy` `dispatch_async`s a recount onto the main queue, enumerates `[NSApp windows]`, filters by Wails's `WebviewWindow` subclass, and asks each for `NSWindow.isVisible` — true iff the window is ordered in **regardless of occlusion**. That's the fix for the occlusion problem we'd otherwise hit relying on Wails's WindowHide event (Wails maps `windowDidChangeOcclusionState` to it, so covering our window with another app would have looked like a hide). `dispatch_async` also defers past any pending `orderOut:` / `close:` from the originating notification, so destroy-on-close auxiliaries have actually left `[NSApp windows]` by the time we look. A `lastDockState` static int on the C side suppresses no-op flips (and the `activate` focus-steal that goes with them). Linux/Windows: `dock_other.go` is a no-op `initDockObserver()` since their panels already gate taskbar entries on window visibility. - `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast. - **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — status-text transitions (`tray_status.go applyStatus`, which also folds in daemon-version and session-deadline changes into one aggregated relayout per push), Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` → `fillExitNodeSubmenu`), and the About → Update row (`tray_update.go applyState` → `onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers. - **Tray concurrency model (menuMu domain).** Wails dispatches `Event.On` listeners and menu `OnClick` callbacks on **fresh goroutines** (so `applyStatus` runs are concurrent with each other and with relayouts), and Wails `MenuItem` setters are not goroutine-safe. Hence `t.menu` and every `*Item`/`*Submenu` field on `Tray` are `menuMu`-owned: `buildMenu` reassigns them all on each relayout, and repaints happen from the caches inside `relayoutMenu` (caches are committed *before* the menu is touched, which makes a write on an orphaned pre-relayout item self-healing). Two sanctioned out-of-lock accesses: the Connect/Disconnect `OnClick` closures capture their own item, and `refreshSessionExpiresLabel` (30s ticker) snapshots `sessionExpiresItem` under `menuMu`. `relayoutMenu` is the **only post-startup `tray.SetMenu` call site** — never push a menu pointer snapshotted outside `menuMu` (it can reinstall a stale tree); `applyStatusIndicator` is `SetBitmap`-only, the relayout's trailing `SetMenu` is what repaints the macOS dot. Tray code never runs on the OS main thread, so taking `menuMu` from any goroutine can't deadlock the setters' `dispatch_sync`/`InvokeSync`. diff --git a/client/ui/dock_darwin.go b/client/ui/dock_darwin.go new file mode 100644 index 000000000..dd8c60073 --- /dev/null +++ b/client/ui/dock_darwin.go @@ -0,0 +1,70 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa +#import + +static int lastDockState = -1; + +static void refreshDockPolicy(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + Class cls = NSClassFromString(@"WebviewWindow"); + if (cls == nil) { + return; + } + int visible = 0; + for (NSWindow *w in [NSApp windows]) { + if ([w isKindOfClass:cls] && [w isVisible]) { + visible = 1; + break; + } + } + if (visible == lastDockState) { + return; + } + lastDockState = visible; + + // Set application to "Regular" and show dock icon (when visible) or to "Accessory" (when hidden) + if (visible) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [NSApp activateIgnoringOtherApps:YES]; + } else { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + } + }); +} + +static int dockObserverInstalled = 0; + +static void initDockObserver(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (dockObserverInstalled) { + return; + } + dockObserverInstalled = 1; + NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; + void (^trigger)(NSNotification *) = ^(NSNotification *_) { + refreshDockPolicy(); + }; + + [nc addObserverForName:NSWindowDidChangeOcclusionStateNotification + object:nil + queue:nil + usingBlock:trigger]; + [nc addObserverForName:NSWindowWillCloseNotification + object:nil + queue:nil + usingBlock:trigger]; + + refreshDockPolicy(); + }); +} +*/ +import "C" + +func initDockObserver() { + C.initDockObserver() +} diff --git a/client/ui/dock_other.go b/client/ui/dock_other.go new file mode 100644 index 000000000..e28222d62 --- /dev/null +++ b/client/ui/dock_other.go @@ -0,0 +1,6 @@ +//go:build !darwin && !android && !ios && !freebsd && !js + +package main + +// macOS-only; no-op on other operating systems +func initDockObserver() {} diff --git a/client/ui/main.go b/client/ui/main.go index dae1611d5..e2dca17d4 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -109,6 +109,7 @@ func main() { // authorized). app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { go requestNotificationAuthorization(notifier) + initDockObserver() }) bundle, prefStore, localizer := buildI18n(app)