From ef1d406edd2d6433137f627b5b1419377ec4981a Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 6 Aug 2026 12:56:25 +0200 Subject: [PATCH] Ask for Screen Recording from the agent and Accessibility on first input --- client/cmd/vnc_agent_darwin.go | 7 ++++ client/internal/engine_vnc_darwin.go | 9 ++--- client/vnc/server/capture_darwin.go | 13 ++++++ client/vnc/server/input_darwin.go | 59 ++++++++++++++++++++++------ 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/client/cmd/vnc_agent_darwin.go b/client/cmd/vnc_agent_darwin.go index 6bf264602..e7fee4c92 100644 --- a/client/cmd/vnc_agent_darwin.go +++ b/client/cmd/vnc_agent_darwin.go @@ -9,6 +9,13 @@ import ( ) func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) { + // Ask for Screen Recording here and nowhere else: this process runs as the + // console user, which is what TCC requires for a user-scope service, and it + // is the point where somebody is demonstrably trying to view the screen. + // Granting it also requires the capturing process to restart, which comes + // for free since the agent is respawned per session. + vncserver.PrimeScreenCapturePermission() + capturer := vncserver.NewMacPoller() injector, err := vncserver.NewMacInputInjector() if err != nil { diff --git a/client/internal/engine_vnc_darwin.go b/client/internal/engine_vnc_darwin.go index 309d14f5c..58635a6f3 100644 --- a/client/internal/engine_vnc_darwin.go +++ b/client/internal/engine_vnc_darwin.go @@ -12,11 +12,10 @@ import ( func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) { capturer := vncserver.NewMacPoller() - // Prompt for Screen Recording at server-enable time rather than first - // client-connect. The native prompt is far easier for users to act on - // in the moment they toggled VNC on than later when "the screen looks - // like wallpaper" would otherwise be the only clue. - vncserver.PrimeScreenCapturePermission() + // No permission request here. Screen Recording is a user-scope TCC service, + // so a request from this process is dropped when it runs as a LaunchDaemon: + // no prompt appears and NetBird never even shows up in the Screen Recording + // list. The per-user agent asks instead, see newAgentResources. injector, err := vncserver.NewMacInputInjector() if err != nil { log.Debugf("VNC: macOS input injector: %v", err) diff --git a/client/vnc/server/capture_darwin.go b/client/vnc/server/capture_darwin.go index d3c31a1a1..8de941e56 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -148,6 +148,18 @@ func notifyScreenRecordingMissing() { } // NewCGCapturer creates a screen capturer for the main display. +// screenCaptureWorking records that a real capture succeeded, which is the only +// trustworthy signal that Screen Recording is granted: CGPreflight lies on +// Sequoia. The input side waits for this before asking for Accessibility, so the +// two permission panes never compete (macOS shows one at a time, and losing the +// Screen Recording pane is the worse outcome: without it there is no picture). +var screenCaptureWorking atomic.Bool + +// ScreenCaptureWorking reports whether a capture has succeeded in this process. +func ScreenCaptureWorking() bool { + return screenCaptureWorking.Load() +} + func NewCGCapturer() (*CGCapturer, error) { initDarwinCapture() if !darwinCaptureReady { @@ -162,6 +174,7 @@ func NewCGCapturer() (*CGCapturer, error) { notifyScreenRecordingMissing() return nil, fmt.Errorf("probe capture: %w", err) } + screenCaptureWorking.Store(true) nativeW := img.Rect.Dx() nativeH := img.Rect.Dy() c.hasHash = false diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 76f4c3a79..9f8fe50e9 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -7,6 +7,7 @@ import ( "os/exec" "strings" "sync" + "sync/atomic" "time" "unsafe" @@ -301,6 +302,9 @@ type MacInputInjector struct { // field on each posted event, not from event timing. clickCount [5]int64 clickAt [5]time.Time + // axAsked is set once the Accessibility request has been made, so the + // per-event check is one atomic load. + axAsked atomic.Bool } // NewMacInputInjector creates a macOS input injector. @@ -309,7 +313,7 @@ func NewMacInputInjector() (*MacInputInjector, error) { if !darwinInputReady { return nil, fmt.Errorf("CoreGraphics not available for input injection") } - checkMacPermissions() + logAccessibilityStatus() m := &MacInputInjector{} if path, err := exec.LookPath("pbcopy"); err == nil { @@ -328,20 +332,48 @@ func NewMacInputInjector() (*MacInputInjector, error) { return m, nil } -// checkMacPermissions probes Accessibility access. Prefers the prompting -// variant of AXIsProcessTrusted: when the process is not yet trusted, -// macOS shows its native "would like to control your computer" dialog -// with an "Open System Settings" button. The silent variant is the -// fallback when the prompting symbol or its CF dictionary plumbing -// couldn't be loaded. -func checkMacPermissions() { - if !axProcessIsTrusted() { - log.Warn("Accessibility permission not granted. Input injection will not work. " + - "Approve the prompt or grant in System Settings > Privacy & Security > Accessibility.") - openPrivacyPane("Privacy_Accessibility") +// logAccessibilityStatus reports Accessibility state without prompting. Asking +// here would put the Accessibility pane on screen the moment a session starts, +// on top of the Screen Recording request, and macOS shows only one pane at a +// time: the Accessibility one wins and the more important request is buried. +// The ask happens on the first input instead, see ensureAccessibility. +func logAccessibilityStatus() { + if axIsProcessTrusted != nil && !axIsProcessTrusted() { + log.Info("Accessibility permission not granted yet; asking on the first input event") } } +// ensureAccessibility asks for Accessibility at most once per process, on the +// first input that is actually delivered. Injection happens per event, so the +// common path has to be a single atomic load. +func (m *MacInputInjector) ensureAccessibility() { + if m.axAsked.Load() { + return + } + m.askAccessibility() +} + +// askAccessibility is the cold path of ensureAccessibility. +// +// It waits for a capture to have succeeded before prompting: Screen Recording is +// the permission a session cannot do without, and requesting Accessibility while +// that pane is open replaces it. A session that never captures never gets here, +// which is the right outcome, since input on a black screen is not useful. +func (m *MacInputInjector) askAccessibility() { + if !ScreenCaptureWorking() { + return + } + if !m.axAsked.CompareAndSwap(false, true) { + return + } + if axProcessIsTrusted() { + return + } + log.Warn("Accessibility permission not granted. Input injection will not work. " + + "Approve the prompt or grant in System Settings > Privacy & Security > Accessibility.") + openPrivacyPane("Privacy_Accessibility") +} + // axProcessIsTrusted asks macOS whether netbird has Accessibility access, // and triggers the native prompt the first time when not trusted. Returns // the current trust status either way. @@ -384,6 +416,7 @@ func openPrivacyPane(pane string) { // InjectKey simulates a key press or release. func (m *MacInputInjector) InjectKey(keysym uint32, down bool) { + m.ensureAccessibility() wakeDisplay() src := ensureEventSource() if src == 0 { @@ -401,6 +434,7 @@ func (m *MacInputInjector) InjectKey(keysym uint32, down bool) { // entirely different scheme from PC AT scancodes, so the table is the // authoritative bridge. On miss we fall back to the keysym path. func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) { + m.ensureAccessibility() wakeDisplay() src := ensureEventSource() if src == 0 { @@ -491,6 +525,7 @@ func isFnShiftedKeycode(keycode uint16) bool { // InjectPointer simulates mouse movement and button events. func (m *MacInputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) { + m.ensureAccessibility() wakeDisplay() if serverW == 0 || serverH == 0 { return