From e75948753a66dd363fcf184b97ca6e601cb02512 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 17 May 2026 08:28:40 +0200 Subject: [PATCH] Prompt for macOS Accessibility and Screen Recording at VNC enable time --- client/internal/engine_vnc_darwin.go | 5 ++ client/vnc/server/capture_darwin.go | 21 ++++++ client/vnc/server/input_darwin.go | 107 ++++++++++++++++++++++++--- 3 files changed, 121 insertions(+), 12 deletions(-) diff --git a/client/internal/engine_vnc_darwin.go b/client/internal/engine_vnc_darwin.go index 7efe6f064..0f182cdb0 100644 --- a/client/internal/engine_vnc_darwin.go +++ b/client/internal/engine_vnc_darwin.go @@ -10,6 +10,11 @@ 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() 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 ac3244911..2efe02f08 100644 --- a/client/vnc/server/capture_darwin.go +++ b/client/vnc/server/capture_darwin.go @@ -93,6 +93,27 @@ type CGCapturer struct { hasHash bool } +// PrimeScreenCapturePermission triggers the macOS Screen Recording +// permission probe (and prompt, if not granted) without creating a full +// capturer. The platform wiring calls this at VNC-server enable time so +// the user sees the prompt the moment they turn the feature on, rather +// than on first-client-connect when the cause may not be obvious. +func PrimeScreenCapturePermission() { + initDarwinCapture() + if !darwinCaptureReady { + return + } + if cgPreflightScreenCaptureAccess == nil || cgPreflightScreenCaptureAccess() { + return + } + if cgRequestScreenCaptureAccess != nil { + cgRequestScreenCaptureAccess() + } + openPrivacyPane("Privacy_ScreenCapture") + log.Warn("Screen Recording permission not granted. Approve the prompt " + + "or grant in System Settings > Privacy & Security > Screen Recording.") +} + // NewCGCapturer creates a screen capturer for the main display. func NewCGCapturer() (*CGCapturer, error) { initDarwinCapture() diff --git a/client/vnc/server/input_darwin.go b/client/vnc/server/input_darwin.go index 2982c71d9..1b830035d 100644 --- a/client/vnc/server/input_darwin.go +++ b/client/vnc/server/input_darwin.go @@ -7,6 +7,7 @@ import ( "os/exec" "strings" "sync" + "unsafe" "github.com/ebitengine/purego" log "github.com/sirupsen/logrus" @@ -54,6 +55,23 @@ var ( cgEventCreateScrollWheelEventAddr uintptr axIsProcessTrusted func() bool + // axIsProcessTrustedWithOptions takes a CFDictionary; when the dict's + // kAXTrustedCheckOptionPrompt key is true, macOS shows the native + // Accessibility prompt with an "Open System Settings" button the + // first time the process asks. The bare AXIsProcessTrusted variant is + // a silent check that never prompts. + axIsProcessTrustedWithOptions func(uintptr) bool + // cfDictionaryCreate builds the options dictionary above. + cfDictionaryCreate func(uintptr, *uintptr, *uintptr, int64, uintptr, uintptr) uintptr + // cfBooleanTrue is the global CF boolean we cache from a Dlsym lookup. + cfBooleanTrue uintptr + // axTrustedCheckOptionPromptCFStr is the option key for the dict. + axTrustedCheckOptionPromptCFStr uintptr + // kCFTypeDictionaryKey/Value CallBacks: standard CF retain/release + // callback tables. Required so the dict properly manages refcounts on + // the CFString key and CFBoolean value. + kCFTypeDictionaryKeyCallBacksAddr uintptr + kCFTypeDictionaryValueCallBacksAddr uintptr // IOKit power-management bindings used to wake the display and inhibit // idle sleep while a VNC client is driving input. @@ -100,14 +118,49 @@ func initDarwinInput() { if sym, err := purego.Dlsym(ax, "AXIsProcessTrusted"); err == nil { purego.RegisterFunc(&axIsProcessTrusted, sym) } + if sym, err := purego.Dlsym(ax, "AXIsProcessTrustedWithOptions"); err == nil { + purego.RegisterFunc(&axIsProcessTrustedWithOptions, sym) + } } + // initPowerAssertions registers cfStringCreateWithCString, which + // initCFDictionarySymbols then uses to build the AX prompt key. initPowerAssertions() + initCFDictionarySymbols() darwinInputReady = true }) } +// initCFDictionarySymbols loads the CF symbols needed to build the +// options dictionary for AXIsProcessTrustedWithOptions. Best-effort: +// failure here just leaves axIsProcessTrustedWithOptions unusable and we +// fall back to the silent check. +func initCFDictionarySymbols() { + cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL) + if err != nil { + log.Debugf("load CoreFoundation for AX prompt dict: %v", err) + return + } + if sym, err := purego.Dlsym(cf, "CFDictionaryCreate"); err == nil { + purego.RegisterFunc(&cfDictionaryCreate, sym) + } + if sym, err := purego.Dlsym(cf, "kCFTypeDictionaryKeyCallBacks"); err == nil { + kCFTypeDictionaryKeyCallBacksAddr = sym + } + if sym, err := purego.Dlsym(cf, "kCFTypeDictionaryValueCallBacks"); err == nil { + kCFTypeDictionaryValueCallBacksAddr = sym + } + if sym, err := purego.Dlsym(cf, "kCFBooleanTrue"); err == nil { + // kCFBooleanTrue is a pointer-to-pointer (CFBooleanRef stored at the + // symbol address). Dereference once to get the actual CFBoolean. + cfBooleanTrue = *(*uintptr)(unsafe.Pointer(sym)) + } + if cfStringCreateWithCString != nil { + axTrustedCheckOptionPromptCFStr = cfStringCreateWithCString(0, "AXTrustedCheckOptionPrompt", kCFStringEncodingUTF8) + } +} + func initPowerAssertions() { iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL) if err != nil { @@ -234,23 +287,53 @@ func NewMacInputInjector() (*MacInputInjector, error) { return m, nil } -// checkMacPermissions warns and opens the Privacy pane if Accessibility is -// missing. Uses AXIsProcessTrusted which returns immediately; the previous -// osascript probe blocked for 120s (AppleEvent timeout) when access was -// denied, which delayed VNC server startup past client deadlines. +// 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 axIsProcessTrusted != nil && !axIsProcessTrusted() { - openPrivacyPane("Privacy_Accessibility") + if !axProcessIsTrusted() { log.Warn("Accessibility permission not granted. Input injection will not work. " + - "Opened System Settings > Privacy & Security > Accessibility; enable netbird.") + "Approve the prompt or grant in System Settings > Privacy & Security > Accessibility.") + openPrivacyPane("Privacy_Accessibility") } - - log.Info("Screen Recording permission is required for screen capture. " + - "If the screen appears black, grant in System Settings > Privacy & Security > Screen Recording.") } -// openPrivacyPane opens the given Privacy pane in System Settings so the user -// can toggle the permission without navigating manually. +// 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. +func axProcessIsTrusted() bool { + if axIsProcessTrustedWithOptions != nil && + cfDictionaryCreate != nil && + axTrustedCheckOptionPromptCFStr != 0 && + cfBooleanTrue != 0 && + kCFTypeDictionaryKeyCallBacksAddr != 0 && + kCFTypeDictionaryValueCallBacksAddr != 0 { + keys := [1]uintptr{axTrustedCheckOptionPromptCFStr} + values := [1]uintptr{cfBooleanTrue} + dict := cfDictionaryCreate(0, &keys[0], &values[0], 1, + kCFTypeDictionaryKeyCallBacksAddr, + kCFTypeDictionaryValueCallBacksAddr) + if dict != 0 { + return axIsProcessTrustedWithOptions(dict) + } + } + if axIsProcessTrusted != nil { + return axIsProcessTrusted() + } + // Symbol load failed entirely. Assume trusted so we don't spam the + // log every cycle; capture/inject calls will report concrete errors + // if access really is missing. + return true +} + +// openPrivacyPane opens the relevant pane of System Settings so the user +// can toggle the permission without navigating manually. The +// x-apple.systempreferences URL scheme works on every macOS release from +// 10.10 onward; the per-pane anchor (Privacy_Accessibility, Privacy_ScreenCapture) +// is what System Settings/Preferences uses to land on the right row. func openPrivacyPane(pane string) { url := "x-apple.systempreferences:com.apple.preference.security?" + pane if err := exec.Command("open", url).Start(); err != nil {