Ask for Screen Recording from the agent and Accessibility on first input

This commit is contained in:
Viktor Liu
2026-08-06 12:56:25 +02:00
parent fcfe643375
commit ef1d406edd
4 changed files with 71 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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