show macos dock icon when any window is visible

This commit is contained in:
Eduard Gert
2026-06-16 11:32:04 +02:00
parent 28d85ecae3
commit ebaaddc14b
4 changed files with 78 additions and 0 deletions

View File

@@ -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`.

70
client/ui/dock_darwin.go Normal file
View File

@@ -0,0 +1,70 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa
#import <Cocoa/Cocoa.h>
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()
}

6
client/ui/dock_other.go Normal file
View File

@@ -0,0 +1,6 @@
//go:build !darwin && !android && !ios && !freebsd && !js
package main
// macOS-only; no-op on other operating systems
func initDockObserver() {}

View File

@@ -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)