KDE Plasma routes a tray left-click to the SNI Activate method (right-click
opens the context menu), but NetBird wired no Activate action, so on KDE a
left-click appeared completely dead while only right-click surfaced the menu.
Bind the Linux tray OnClick handler to ShowWindow(). OpenMenu() is not an
option on Linux: Wails v3 leaves linuxSystemTray.openMenu unimplemented (it
only logs), so left-click→OpenMenu would still do nothing on KDE. ShowWindow()
is the same call Windows already runs from its double-click handler, and it
does not reproduce the macOS OpenMenu freeze (c77e5cef8) — that came from
NSStatusItem's blocking embedded menu loop, whereas Show/Focus return
immediately.
Split the Linux click handler into its own tray_click_linux.go and narrow the
macOS no-op bindTrayClick build tag accordingly. The context menu stays on
right-click on every host. On hosts that already open the menu on left-click
natively (GNOME Shell + AppIndicator) left-click now opens the window instead;
the menu remains on right-click.
The XEmbed tray (panel) can come up after the autostarted UI on minimal
WMs, so the single startup probe added in #6320 could miss a tray that
appears a second or two later, leaving the icon silently absent. Re-probe
for a ~10s grace period in a goroutine, claiming the watcher as soon as a
tray shows up; back off cleanly if none ever appears (headless/Wayland).
MarkManagement{Connected,Disconnected} and MarkSignal{Connected,
Disconnected} fired notifyStateChange unconditionally. The connect
goroutine re-marks the same state on every health-check cycle, so a
steady "connected -> connected" re-mark pushed a full SubscribeStatus
snapshot to every consumer each time — flooding the desktop UI (and its
tray) with identical Connected snapshots.
Guard each with an early return when neither the state nor the error
actually changed, so only real transitions wake SubscribeStatus
subscribers. The notifier already deduplicates, so collapsing both calls
under one guard is safe.
Status(GetFullPeerStatus=true) RPCs trigger a full health probe
(network round-trips to management, signal and the relays). The
desktop UI issues these frequently and concurrently, and a burst of
parallel Get() calls each fired its own probe — the lastProbe guard
was unprotected against concurrent access and only advanced when every
component was healthy, so a sustained unhealthy state (e.g. relay down)
disabled the throttle entirely and let every call re-probe.
Extract the throttle/single-flight policy into probeThrottle:
- single-flight: only one probe runs at a time; concurrent callers
that piled up while it ran share its result instead of each
launching another, even when that probe failed.
- throttle: lastOK only advances on a fully successful probe, so
while anything is unhealthy callers keep probing frequently and
notice recovery quickly (preserved from the original design).
RunHealthProbes now takes a context so a caller that gives up (e.g. a
Status RPC whose client disconnected) cancels the in-flight STUN/TURN
probe instead of letting it run to its per-component timeout. The
engine's own lifetime ctx still applies independently.
Linux now shows monochrome (black/white silhouette) tray icons instead
of the colored orange PNGs, matching the macOS template look. Since
Wails' Linux SNI backend ignores SetDarkModeIcon (its setDarkModeIcon
just calls setIcon, last-write-wins) and the SNI spec carries no panel
light/dark hint, the panel color scheme is detected in-process and the
black-vs-white silhouette is chosen in iconForState, pushed via a single
SetIcon.
Detection order (tray_theme_linux.go): freedesktop Settings portal
(org.freedesktop.appearance/color-scheme) -> GTK_THEME env (:dark
suffix) -> default dark. A SettingChanged subscription repaints live on
theme flips. macOS (template) and Windows (colored) paths are unchanged.
Icons are 48x48 mono PNGs (3% margin) generated from the macOS
silhouettes.
WebKitGTK crashes at startup when its bubblewrap sandbox can't create an
unprivileged user namespace (bwrap: setting up uid map: Permission denied
-> Failed to fully launch dbus-proxy -> panic in webkit_web_view_load_uri).
This happens in containers/VMs and on Ubuntu 24.04+ where AppArmor
restricts unprivileged user namespaces. Detect that the kernel blocks
userns via procfs and set WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS so the
UI stays usable; honor an explicit user override either way.
A native Windows MessageBox attached to a parent window disables that
window (WS_DISABLED) for its lifetime and re-enables it on dismissal.
When the parent is the main window — whose WindowClosing hook hides
instead of closes — the enable/hide sequence races and leaves the window
unable to process its close (X) button afterwards, so e.g. a rejected
login error dialog left the main window stuck open.
Route all native dialogs through src/lib/dialogs.ts, which forces
Detached: true on Windows (NULL owner, no window ever disabled) and is a
no-op on macOS/Linux (keeps the attached sheet-style presentation).
Win32 swallows a lone & in an MFT_STRING menu item as the mnemonic
prefix, so "Help & Support" rendered as "Help Support". Add a
build-tagged menuLabel() helper that doubles & to && on Windows and is
the identity on macOS/Linux (which render & literally), and apply it to
the About submenu label.
Wire the Windows systray's double-click to ShowWindow(), matching the
Windows-native convention for tray apps. The Wails v3 systray dispatches
WM_LBUTTONDBLCLK to the doubleClickHandler, so OnDoubleClick fires; left-
and right-click continue to open the menu. macOS/Linux are unchanged.
Two follow-ups to the "hold NeedsLogin during the SSO browser wait" change.
Both target the visible state churn the tray showed during the auto-login
handoff (Connect / profile-switch lands on NeedsLogin -> the UI's startLogin
kicks off the SSO flow) and the broken recovery after the user dismisses the
browser-login popup with the window's X.
Background
----------
When a connect attempt lands on NeedsLogin, the UI's startLogin() drives the
SSO flow: Connection.Login() -> (NeedsSSOLogin) open the browser-login popup
-> Connection.WaitSSOLogin() blocks until the browser leg completes. The tray
and the React status page both paint the raw daemon status, so any transient
state the daemon publishes during this handoff is visible as a flicker.
Previously the handoff churned the daemon status through
NeedsLogin -> Idle -> Connecting -> NeedsLogin
which read as a flicker on the tray icon and the status dot. Two distinct
sources produced the two intermediate states:
* Idle came from the UI's defensive cli.Down() at the top of
Connection.Login (services/connection.go): it tore the engine
down before every login to dislodge a possibly-parked
WaitSSOLogin, emitting a StatusIdle on the way.
* Connecting came from server.go Login() unconditionally setting
StatusConnecting before deciding whether the request is an
SSO flow (which immediately returns NeedsLogin) or a
setup-key flow (which actually dials Management).
Changes
-------
1. server.go Login(): only set StatusConnecting on the setup-key path, where
we are about to dial Management with the key and the Connecting paint is
meaningful. The SSO path returns NeedsLogin and parks on the browser leg,
so it no longer flashes Connecting first. Removes the Connecting blip.
2. services/connection.go Login(): drop the pre-Login cli.Down(). The daemon
already dislodges a pending WaitSSOLogin at Login entry (actCancel), and an
abandoned browser leg is now torn down by cancelling the WaitSSOLogin RPC
(see 3/4). Removing the Down removes the Idle blip on every login.
3. MainConnectionStatusSwitch.tsx startLogin(): on cancel (the browser-login
popup's Cancel button or its window X, both routed through
EventBrowserLoginCancel), cancel the in-flight WaitSSOLogin gRPC call via
waitPromise.cancel() instead of issuing a heavy Connection.Down(). The
daemon ties the wait to this call's context, so cancelling the call ends
the wait cleanly with no engine teardown and no Idle paint.
4. server.go WaitSSOLogin(): when the wait unblocks with context.Canceled and
the cancellation came from our caller (callerCtx.Err() != nil — the client
cancelled the RPC or went away), clear the cached oauthAuthFlow so a fresh
Login starts a new device code instead of reusing the abandoned one. The
entry NeedsLogin stays in place, so a reattaching client still shows the
login affordance. An internal abort (actCancel fired by a newer
Login/WaitSSOLogin while our callerCtx is still live) is left untouched so
the new owner's flow is not clobbered.
Effect
------
The auto-login handoff now goes Connected -> Connecting -> NeedsLogin and
holds, with no Idle/Connecting flicker in between. Dismissing the browser-login
popup with X now recovers the same way as the Cancel button: the WaitSSOLogin
RPC is cancelled, the stale OAuth flow is cleared, and the next connect opens a
fresh browser-login window instead of getting stuck.
WaitSSOLogin set StatusConnecting on entry and ran the browser wait on
rootCtx. If the client that drove the login went away mid-wait (UI restart,
CLI Ctrl+C), the wait orphaned on rootCtx until the OAuth device-code window
expired, and the daemon stayed stuck reporting Connecting — a reattaching
client saw a spinner that never resolved instead of a login prompt.
Hold StatusNeedsLogin for the whole browser wait (also in the Login
cached-flow path) so any client attaching mid-wait reads 'login required',
and bridge the wait to callerCtx so a departing client cancels it. On that
cancel the defer leaves NeedsLogin in place, so the next client shows the
login affordance instead of a stale Connecting.
StatusLabel only mapped Idle and DaemonUnavailable, so Connected,
Connecting, NeedsLogin, LoginFailed and SessionExpired leaked the raw
daemon enum into the tray menu — untranslated in de/hu. Map all five to
tray.status.* keys (added in en/de/hu); keep the raw-enum default as a
fallback for any future status.
Add a second, longer-lived switchLoginWatch flag alongside switchInProgress
in DaemonFeed. Suppression still clears on the first Connecting push from the
new Up, but the login watcher survives past it to catch the eventual
NeedsLogin / LoginFailed / SessionExpired terminal and emit EventTriggerLogin,
so the React orchestrator opens the browser-login flow without a second
Connect click. shouldSuppress becomes consumeForSwitch, returning both the
suppress and triggerLogin signals. CancelProfileSwitch disarms the watch so
an aborted switch does not pop a login window.
Add an Autostart Wails service wrapping app.Autostart and a toggle in
the General settings tab. The OS login-item registration is the single
source of truth (nothing mirrored to the preferences file). Affects the
graphical UI only, not the daemon. The toggle hides itself on platforms
where autostart is unsupported.
Without an explicit authorization request the macOS notification center
keeps the app at .notDetermined and silently drops every toast. Request
it from the ApplicationStarted hook (after the notifier's Startup has
initialised the delegate), off the main goroutine since the call blocks
until the user responds. Linux/Windows notifier stubs report authorized,
so this is a no-op there.
Wails v3 does not auto-show the tray menu on left-click on Windows — its
default left-click handler only logs and does nothing visible, so only
right-click opened the menu. macOS (NSStatusItem) and Linux
(StatusNotifierItem host) give us click→menu natively.
Add a build-tag-split bindTrayClick: the Windows variant wires
OnClick→OpenMenu (the same menu.ShowAt path right-click uses), while the
macOS/Linux variant stays a no-op — binding OnClick→OpenMenu on macOS
freezes the tray via NSStatusItem's blocking mouseDown on the main GCD
queue (the reason commit c77e5cef8 reverted the earlier wiring).
test.go is a non-_test.go file so its exported StartTestServer helper is
visible to the ssh/proxy and ssh/client external test packages. That drags
the testing/flag/regexp chain into every build that links ssh/server,
including the wasm client (via the engine). Gate the file with //go:build
!js: native test packages still see the helper, wasm drops the dependency.
The wasm client never runs the engine's session-warning flow, so linking
the full sessionwatch package (timers, event composition) only bloats the
binary. Put the watcher behind a sessionDeadlineWatcher interface and split
the constructor by build tag: !js wires the real sessionwatch.Watcher, js
gets a no-op stub that still mirrors the deadline into the status recorder
(so the Status snapshot stays correct) but drops the timers. Removes the
sessionwatch package from the wasm dependency graph.
* Pin actions with SHA, replace unmaintained, add dependabot for actions
* Update FreeBSD to version 15 for tests
* Use shared actions
* Update sign-pipelines version
de/hu translations contain real foreign words (Sie, oder, ist) that
codespell flags as misspellings. Only en/common.json is the spell-check
source of truth; add each new locale dir to the skip list as languages land.
Extract parseFlagsAndInitLog, newApplication, buildI18n, registerServices,
and newMainWindow so main() stays under the 100-line limit. Wiring order and
shared service instances are unchanged.
Move the event backoff op body into subscribeAndStreamEvents and the
per-event fan-out into dispatchSystemEvent, bringing toastStreamLoop under
the 20 cognitive-complexity limit. No behavior change.
Explain that macOS/Windows have a native tray and the SNI+XEmbed bridge is
Linux-WM-only, so the body is intentionally empty to let main.go call it
unconditionally across all build targets.