mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 10:01:28 +02:00
650 lines
20 KiB
Go
650 lines
20 KiB
Go
//go:build darwin && !ios
|
|
|
|
package server
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"hash/maphash"
|
|
"image"
|
|
"os"
|
|
"runtime"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/ebitengine/purego"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
var darwinCaptureOnce sync.Once
|
|
|
|
var (
|
|
cgMainDisplayID func() uint32
|
|
cgDisplayPixelsWide func(uint32) uintptr
|
|
cgDisplayPixelsHigh func(uint32) uintptr
|
|
cgDisplayCreateImage func(uint32) uintptr
|
|
cgImageGetWidth func(uintptr) uintptr
|
|
cgImageGetHeight func(uintptr) uintptr
|
|
cgImageGetBytesPerRow func(uintptr) uintptr
|
|
cgImageGetBitsPerPixel func(uintptr) uintptr
|
|
cgImageGetDataProvider func(uintptr) uintptr
|
|
cgDataProviderCopyData func(uintptr) uintptr
|
|
cgImageRelease func(uintptr)
|
|
cfDataGetLength func(uintptr) int64
|
|
cfDataGetBytePtr func(uintptr) uintptr
|
|
cfRelease func(uintptr)
|
|
cgRequestScreenCaptureAccess func() bool
|
|
// cgPreflightScreenCaptureAccess reads the decision without prompting. Kept
|
|
// for the diagnostic in RequestScreenRecording until it is known whether it
|
|
// can be trusted in the agent.
|
|
cgPreflightScreenCaptureAccess func() bool
|
|
cgEventCreate func(uintptr) uintptr
|
|
cgEventGetLocation func(uintptr) cgPoint
|
|
darwinCaptureReady bool
|
|
)
|
|
|
|
// cgPoint mirrors CoreGraphics CGPoint: two doubles, 16 bytes, returned
|
|
// in registers on Darwin amd64/arm64. Used to receive cursor coordinates
|
|
// from CGEventGetLocation via purego.
|
|
type cgPoint struct {
|
|
X, Y float64
|
|
}
|
|
|
|
func initDarwinCapture() {
|
|
darwinCaptureOnce.Do(func() {
|
|
cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
log.Debugf("load CoreGraphics: %v", err)
|
|
return
|
|
}
|
|
cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
if err != nil {
|
|
log.Debugf("load CoreFoundation: %v", err)
|
|
return
|
|
}
|
|
|
|
purego.RegisterLibFunc(&cgMainDisplayID, cg, "CGMainDisplayID")
|
|
purego.RegisterLibFunc(&cgDisplayPixelsWide, cg, "CGDisplayPixelsWide")
|
|
purego.RegisterLibFunc(&cgDisplayPixelsHigh, cg, "CGDisplayPixelsHigh")
|
|
purego.RegisterLibFunc(&cgDisplayCreateImage, cg, "CGDisplayCreateImage")
|
|
purego.RegisterLibFunc(&cgImageGetWidth, cg, "CGImageGetWidth")
|
|
purego.RegisterLibFunc(&cgImageGetHeight, cg, "CGImageGetHeight")
|
|
purego.RegisterLibFunc(&cgImageGetBytesPerRow, cg, "CGImageGetBytesPerRow")
|
|
purego.RegisterLibFunc(&cgImageGetBitsPerPixel, cg, "CGImageGetBitsPerPixel")
|
|
purego.RegisterLibFunc(&cgImageGetDataProvider, cg, "CGImageGetDataProvider")
|
|
purego.RegisterLibFunc(&cgDataProviderCopyData, cg, "CGDataProviderCopyData")
|
|
purego.RegisterLibFunc(&cgImageRelease, cg, "CGImageRelease")
|
|
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
|
|
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
|
|
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
|
|
|
|
// CGRequestScreenCaptureAccess (macOS 11+) raises its dialog on the first
|
|
// call in a process and reports the decision as it stands, without waiting
|
|
// for the user. Its Preflight companion never prompts but has a reputation
|
|
// for lying on Sequoia, see RequestScreenRecording.
|
|
if sym, err := purego.Dlsym(cg, "CGRequestScreenCaptureAccess"); err == nil {
|
|
purego.RegisterFunc(&cgRequestScreenCaptureAccess, sym)
|
|
}
|
|
if sym, err := purego.Dlsym(cg, "CGPreflightScreenCaptureAccess"); err == nil {
|
|
purego.RegisterFunc(&cgPreflightScreenCaptureAccess, sym)
|
|
}
|
|
// CGEventCreate / CGEventGetLocation feed the cursor position used
|
|
// by remote-cursor compositing. Optional; absence reports as a
|
|
// position-source error and disables that feature on this host.
|
|
if sym, err := purego.Dlsym(cg, "CGEventCreate"); err == nil {
|
|
purego.RegisterFunc(&cgEventCreate, sym)
|
|
}
|
|
if sym, err := purego.Dlsym(cg, "CGEventGetLocation"); err == nil {
|
|
purego.RegisterFunc(&cgEventGetLocation, sym)
|
|
}
|
|
|
|
darwinCaptureReady = true
|
|
})
|
|
}
|
|
|
|
// CGCapturer captures the macOS main display using Core Graphics.
|
|
type CGCapturer struct {
|
|
displayID uint32
|
|
w, h int
|
|
// downscale is 1 for pixel-perfect, 2 for Retina 2:1 box-filter downscale.
|
|
downscale int
|
|
hashSeed maphash.Seed
|
|
lastHash uint64
|
|
hasHash bool
|
|
// cursor lazily binds the private CGSCreateCurrentCursorImage symbol
|
|
// so we can emit the Cursor pseudo-encoding without a per-frame cost
|
|
// on builds that never query it.
|
|
cursorOnce sync.Once
|
|
cursor *cgCursor
|
|
}
|
|
|
|
// RequestScreenRecording asks the console user for Screen Recording and reports
|
|
// whether it is granted. Called once at agent startup, which is the only place it
|
|
// can work: TCC prompts at most once in the lifetime of a process, and the daemon
|
|
// is a LaunchDaemon, where a request for a user-scope service is dropped without
|
|
// ever showing a dialog.
|
|
//
|
|
// Capture cannot stand in for this check. Without the permission
|
|
// CGDisplayCreateImage still succeeds and returns the desktop picture with every
|
|
// window missing, so a failing capture is not how a missing grant shows up, and a
|
|
// successful one is no proof of having it.
|
|
//
|
|
// The call returns the decision as it stands and raises its dialog in the
|
|
// background, so the answer it reports is the state before the user gets a say. A
|
|
// grant takes effect immediately, so the session the dialog interrupted goes on to
|
|
// show the real screen without needing a new process.
|
|
//
|
|
// It deliberately does not open System Settings when the answer is no. The dialog
|
|
// is still on screen at that point, and putting the pane up alongside it leaves
|
|
// two things competing for one decision. A dismissed dialog needs no fallback
|
|
// either: the agent is recycled per connection, so the next one asks again.
|
|
func RequestScreenRecording() bool {
|
|
initDarwinCapture()
|
|
if !darwinCaptureReady || cgRequestScreenCaptureAccess == nil {
|
|
// Nothing to ask with, so Settings is the only route left.
|
|
openPrivacyPane("Privacy_ScreenCapture")
|
|
log.Warn("cannot ask for Screen Recording permission on this macOS. " +
|
|
"Opened System Settings > Privacy & Security > Screen Recording; enable netbird there.")
|
|
return false
|
|
}
|
|
// Read the silent check first: the request below can change what it reports.
|
|
// Logged to settle whether it can be trusted here, in the console-user agent,
|
|
// having proven unreliable in the daemon where the request is dropped outright.
|
|
preflight := "unavailable"
|
|
if cgPreflightScreenCaptureAccess != nil {
|
|
preflight = strconv.FormatBool(cgPreflightScreenCaptureAccess())
|
|
}
|
|
|
|
granted := cgRequestScreenCaptureAccess()
|
|
log.Infof("Screen Recording: granted=%v (preflight reported %s)", granted, preflight)
|
|
if !granted {
|
|
log.Warn("Screen Recording permission not granted, the screen shows no windows until it is")
|
|
}
|
|
return granted
|
|
}
|
|
|
|
// NewCGCapturer creates a screen capturer for the main display.
|
|
func NewCGCapturer() (*CGCapturer, error) {
|
|
initDarwinCapture()
|
|
if !darwinCaptureReady {
|
|
return nil, fmt.Errorf("CoreGraphics not available")
|
|
}
|
|
|
|
displayID := cgMainDisplayID()
|
|
c := &CGCapturer{displayID: displayID, downscale: 1, hashSeed: maphash.MakeSeed()}
|
|
|
|
img, err := c.Capture()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("probe capture: %w", err)
|
|
}
|
|
nativeW := img.Rect.Dx()
|
|
nativeH := img.Rect.Dy()
|
|
c.hasHash = false
|
|
if nativeW == 0 || nativeH == 0 {
|
|
return nil, errors.New("display dimensions are zero")
|
|
}
|
|
|
|
logicalW := int(cgDisplayPixelsWide(displayID))
|
|
logicalH := int(cgDisplayPixelsHigh(displayID))
|
|
|
|
// Enable 2:1 downscale on Retina unless explicitly disabled. Cuts pixel
|
|
// count 4x, shrinking convert, diff, and wire data proportionally.
|
|
if !retinaDownscaleDisabled() && nativeW >= 2*logicalW && nativeH >= 2*logicalH && nativeW%2 == 0 && nativeH%2 == 0 {
|
|
c.downscale = 2
|
|
}
|
|
c.w = nativeW / c.downscale
|
|
c.h = nativeH / c.downscale
|
|
|
|
log.Infof("macOS capturer ready: %dx%d (native %dx%d, logical %dx%d, downscale=%d, display=%d)",
|
|
c.w, c.h, nativeW, nativeH, logicalW, logicalH, c.downscale, displayID)
|
|
return c, nil
|
|
}
|
|
|
|
func retinaDownscaleDisabled() bool {
|
|
v := os.Getenv(EnvVNCDisableDownscale)
|
|
if v == "" {
|
|
return false
|
|
}
|
|
disabled, err := strconv.ParseBool(v)
|
|
if err != nil {
|
|
log.Warnf("parse %s: %v", EnvVNCDisableDownscale, err)
|
|
return false
|
|
}
|
|
return disabled
|
|
}
|
|
|
|
// Width returns the screen width.
|
|
func (c *CGCapturer) Width() int { return c.w }
|
|
|
|
// Height returns the screen height.
|
|
func (c *CGCapturer) Height() int { return c.h }
|
|
|
|
// CaptureInto writes a fresh frame directly into dst, skipping the
|
|
// per-frame image.RGBA allocation that Capture() does. It always fills
|
|
// dst: the capturer is shared across all sessions, so dedup here would
|
|
// starve every consumer but the first one to poll after a change.
|
|
// Per-session prevFrame diffing in the session layer handles no-op frames.
|
|
func (c *CGCapturer) CaptureInto(dst *image.RGBA) error {
|
|
cgImage := cgDisplayCreateImage(c.displayID)
|
|
if cgImage == 0 {
|
|
return fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)")
|
|
}
|
|
defer cgImageRelease(cgImage)
|
|
w := int(cgImageGetWidth(cgImage))
|
|
h := int(cgImageGetHeight(cgImage))
|
|
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
|
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
|
provider := cgImageGetDataProvider(cgImage)
|
|
if provider == 0 {
|
|
return fmt.Errorf("CGImageGetDataProvider returned nil")
|
|
}
|
|
cfData := cgDataProviderCopyData(provider)
|
|
if cfData == 0 {
|
|
return fmt.Errorf("CGDataProviderCopyData returned nil")
|
|
}
|
|
defer cfRelease(cfData)
|
|
dataLen := int(cfDataGetLength(cfData))
|
|
dataPtr := cfDataGetBytePtr(cfData)
|
|
if dataPtr == 0 || dataLen == 0 {
|
|
return fmt.Errorf("empty image data")
|
|
}
|
|
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
|
|
|
ds := c.downscale
|
|
if ds < 1 {
|
|
ds = 1
|
|
}
|
|
outW := w / ds
|
|
outH := h / ds
|
|
if dst.Rect.Dx() != outW || dst.Rect.Dy() != outH {
|
|
return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d",
|
|
dst.Rect.Dx(), dst.Rect.Dy(), outW, outH)
|
|
}
|
|
bytesPerPixel := bpp / 8
|
|
if bytesPerPixel == 4 && ds == 1 {
|
|
convertBGRAToRGBA(dst.Pix, dst.Stride, src, bytesPerRow, w, h)
|
|
return nil
|
|
}
|
|
if bytesPerPixel == 4 && ds == 2 {
|
|
convertBGRAToRGBADownscale2(dst.Pix, dst.Stride, src, bytesPerRow, outW, outH)
|
|
return nil
|
|
}
|
|
for row := 0; row < outH; row++ {
|
|
srcOff := row * ds * bytesPerRow
|
|
dstOff := row * dst.Stride
|
|
for col := 0; col < outW; col++ {
|
|
si := srcOff + col*ds*bytesPerPixel
|
|
di := dstOff + col*4
|
|
dst.Pix[di+0] = src[si+2]
|
|
dst.Pix[di+1] = src[si+1]
|
|
dst.Pix[di+2] = src[si+0]
|
|
dst.Pix[di+3] = 0xff
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *CGCapturer) Capture() (*image.RGBA, error) {
|
|
cgImage := cgDisplayCreateImage(c.displayID)
|
|
if cgImage == 0 {
|
|
return nil, fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)")
|
|
}
|
|
defer cgImageRelease(cgImage)
|
|
|
|
w := int(cgImageGetWidth(cgImage))
|
|
h := int(cgImageGetHeight(cgImage))
|
|
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
|
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
|
|
|
provider := cgImageGetDataProvider(cgImage)
|
|
if provider == 0 {
|
|
return nil, fmt.Errorf("CGImageGetDataProvider returned nil")
|
|
}
|
|
|
|
cfData := cgDataProviderCopyData(provider)
|
|
if cfData == 0 {
|
|
return nil, fmt.Errorf("CGDataProviderCopyData returned nil")
|
|
}
|
|
defer cfRelease(cfData)
|
|
|
|
dataLen := int(cfDataGetLength(cfData))
|
|
dataPtr := cfDataGetBytePtr(cfData)
|
|
if dataPtr == 0 || dataLen == 0 {
|
|
return nil, fmt.Errorf("empty image data")
|
|
}
|
|
|
|
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
|
|
|
hash := maphash.Bytes(c.hashSeed, src)
|
|
if c.hasHash && hash == c.lastHash {
|
|
return nil, errFrameUnchanged
|
|
}
|
|
c.lastHash = hash
|
|
c.hasHash = true
|
|
|
|
ds := c.downscale
|
|
if ds < 1 {
|
|
ds = 1
|
|
}
|
|
outW := w / ds
|
|
outH := h / ds
|
|
img := image.NewRGBA(image.Rect(0, 0, outW, outH))
|
|
|
|
bytesPerPixel := bpp / 8
|
|
switch {
|
|
case bytesPerPixel == 4 && ds == 1:
|
|
convertBGRAToRGBA(img.Pix, img.Stride, src, bytesPerRow, w, h)
|
|
case bytesPerPixel == 4 && ds == 2:
|
|
convertBGRAToRGBADownscale2(img.Pix, img.Stride, src, bytesPerRow, outW, outH)
|
|
default:
|
|
convertBGRAToRGBAGeneric(img.Pix, img.Stride, src, bytesPerRow, bgraDownscaleParams{outW: outW, outH: outH, bytesPerPixel: bytesPerPixel, ds: ds})
|
|
}
|
|
|
|
return img, nil
|
|
}
|
|
|
|
type bgraDownscaleParams struct {
|
|
outW, outH, bytesPerPixel, ds int
|
|
}
|
|
|
|
// convertBGRAToRGBAGeneric is the slow per-pixel fallback for non-4-bytes
|
|
// or non-1/2 downscale formats. Always available regardless of the source
|
|
// format quirks the fast paths optimize for.
|
|
func convertBGRAToRGBAGeneric(dst []byte, dstStride int, src []byte, srcStride int, p bgraDownscaleParams) {
|
|
for row := 0; row < p.outH; row++ {
|
|
srcOff := row * p.ds * srcStride
|
|
dstOff := row * dstStride
|
|
for col := 0; col < p.outW; col++ {
|
|
si := srcOff + col*p.ds*p.bytesPerPixel
|
|
di := dstOff + col*4
|
|
dst[di+0] = src[si+2]
|
|
dst[di+1] = src[si+1]
|
|
dst[di+2] = src[si+0]
|
|
dst[di+3] = 0xff
|
|
}
|
|
}
|
|
}
|
|
|
|
// convertBGRAToRGBADownscale2 averages every 2x2 BGRA block into one RGBA
|
|
// output pixel, parallelised across GOMAXPROCS cores. outW and outH are the
|
|
// destination dimensions (source is 2*outW by 2*outH).
|
|
func convertBGRAToRGBADownscale2(dst []byte, dstStride int, src []byte, srcStride, outW, outH int) {
|
|
workers := runtime.GOMAXPROCS(0)
|
|
if workers > outH {
|
|
workers = outH
|
|
}
|
|
if workers < 1 || outH < 32 {
|
|
workers = 1
|
|
}
|
|
|
|
convertRows := func(y0, y1 int) {
|
|
for row := y0; row < y1; row++ {
|
|
srcRow0 := 2 * row * srcStride
|
|
srcRow1 := srcRow0 + srcStride
|
|
dstOff := row * dstStride
|
|
for col := 0; col < outW; col++ {
|
|
s0 := srcRow0 + col*8
|
|
s1 := srcRow1 + col*8
|
|
b := (uint32(src[s0]) + uint32(src[s0+4]) + uint32(src[s1]) + uint32(src[s1+4])) >> 2
|
|
g := (uint32(src[s0+1]) + uint32(src[s0+5]) + uint32(src[s1+1]) + uint32(src[s1+5])) >> 2
|
|
r := (uint32(src[s0+2]) + uint32(src[s0+6]) + uint32(src[s1+2]) + uint32(src[s1+6])) >> 2
|
|
di := dstOff + col*4
|
|
dst[di+0] = byte(r)
|
|
dst[di+1] = byte(g)
|
|
dst[di+2] = byte(b)
|
|
dst[di+3] = 0xff
|
|
}
|
|
}
|
|
}
|
|
|
|
if workers == 1 {
|
|
convertRows(0, outH)
|
|
return
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
chunk := (outH + workers - 1) / workers
|
|
for i := 0; i < workers; i++ {
|
|
y0 := i * chunk
|
|
y1 := y0 + chunk
|
|
if y1 > outH {
|
|
y1 = outH
|
|
}
|
|
if y0 >= y1 {
|
|
break
|
|
}
|
|
wg.Add(1)
|
|
go func(y0, y1 int) {
|
|
defer wg.Done()
|
|
convertRows(y0, y1)
|
|
}(y0, y1)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// convertBGRAToRGBA swaps R/B channels using uint32 word operations, and
|
|
// parallelises across GOMAXPROCS cores for large images.
|
|
func convertBGRAToRGBA(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
|
workers := runtime.GOMAXPROCS(0)
|
|
if workers > h {
|
|
workers = h
|
|
}
|
|
if workers < 1 || h < 64 {
|
|
workers = 1
|
|
}
|
|
|
|
convertRows := func(y0, y1 int) {
|
|
rowBytes := w * 4
|
|
for row := y0; row < y1; row++ {
|
|
dstRow := dst[row*dstStride : row*dstStride+rowBytes]
|
|
srcRow := src[row*srcStride : row*srcStride+rowBytes]
|
|
dstU := unsafe.Slice((*uint32)(unsafe.Pointer(&dstRow[0])), w)
|
|
srcU := unsafe.Slice((*uint32)(unsafe.Pointer(&srcRow[0])), w)
|
|
for i, p := range srcU {
|
|
dstU[i] = (p & 0xff00ff00) | ((p & 0x000000ff) << 16) | ((p & 0x00ff0000) >> 16) | 0xff000000
|
|
}
|
|
}
|
|
}
|
|
|
|
if workers == 1 {
|
|
convertRows(0, h)
|
|
return
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
chunk := (h + workers - 1) / workers
|
|
for i := 0; i < workers; i++ {
|
|
y0 := i * chunk
|
|
y1 := y0 + chunk
|
|
if y1 > h {
|
|
y1 = h
|
|
}
|
|
if y0 >= y1 {
|
|
break
|
|
}
|
|
wg.Add(1)
|
|
go func(y0, y1 int) {
|
|
defer wg.Done()
|
|
convertRows(y0, y1)
|
|
}(y0, y1)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// MacPoller wraps CGCapturer with a staleness-cached on-demand Capture:
|
|
// sessions drive captures themselves from their encoder goroutine, so we
|
|
// don't need a background ticker. The last result is cached for a short
|
|
// window so concurrent sessions coalesce into one capture.
|
|
//
|
|
// The capturer is allocated lazily on first use and released when all
|
|
// clients disconnect. Init is retried with backoff because a display can be
|
|
// momentarily unable to hand out a frame, on a mode change or a display switch.
|
|
type MacPoller struct {
|
|
mu sync.Mutex
|
|
|
|
capturer *CGCapturer
|
|
w, h int
|
|
|
|
lastFrame *image.RGBA
|
|
lastAt time.Time
|
|
|
|
clients atomic.Int32
|
|
initFails int
|
|
initBackoffUntil time.Time
|
|
closed bool
|
|
}
|
|
|
|
// macInitRetryBackoffFor returns the delay we wait between init attempts
|
|
// after consecutive failures. Repeated failures mean the display is not coming
|
|
// back on its own, so we back off aggressively rather than spin.
|
|
func macInitRetryBackoffFor(fails int) time.Duration {
|
|
switch {
|
|
case fails > 15:
|
|
return 30 * time.Second
|
|
case fails > 5:
|
|
return 10 * time.Second
|
|
default:
|
|
return 2 * time.Second
|
|
}
|
|
}
|
|
|
|
// NewMacPoller creates a lazy on-demand capturer for the macOS display.
|
|
func NewMacPoller() *MacPoller {
|
|
return &MacPoller{}
|
|
}
|
|
|
|
// Wake is a no-op retained for API compatibility. With on-demand capture
|
|
// there is no background retry loop to kick: init happens on the next
|
|
// Capture/ClientConnect call.
|
|
func (p *MacPoller) Wake() {
|
|
// intentional no-op
|
|
}
|
|
|
|
// ClientConnect increments the active client count and eagerly initialises
|
|
// the capturer so the first FBUpdateRequest doesn't pay the init cost.
|
|
func (p *MacPoller) ClientConnect() {
|
|
if p.clients.Add(1) == 1 {
|
|
p.mu.Lock()
|
|
_ = p.ensureCapturerLocked()
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// ClientDisconnect decrements the active client count. On the last
|
|
// disconnect the capturer is released.
|
|
func (p *MacPoller) ClientDisconnect() {
|
|
if p.clients.Add(-1) == 0 {
|
|
p.mu.Lock()
|
|
p.capturer = nil
|
|
p.lastFrame = nil
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// Close releases all resources.
|
|
func (p *MacPoller) Close() {
|
|
p.mu.Lock()
|
|
p.closed = true
|
|
p.capturer = nil
|
|
p.lastFrame = nil
|
|
p.mu.Unlock()
|
|
}
|
|
|
|
// Width returns the screen width. Triggers lazy init if needed.
|
|
func (p *MacPoller) Width() int {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
_ = p.ensureCapturerLocked()
|
|
return p.w
|
|
}
|
|
|
|
// Height returns the screen height. Triggers lazy init if needed.
|
|
func (p *MacPoller) Height() int {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
_ = p.ensureCapturerLocked()
|
|
return p.h
|
|
}
|
|
|
|
// CaptureInto fills dst directly via the underlying capturer, bypassing
|
|
// the freshness cache.
|
|
func (p *MacPoller) CaptureInto(dst *image.RGBA) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if err := p.ensureCapturerLocked(); err != nil {
|
|
return err
|
|
}
|
|
if err := p.capturer.CaptureInto(dst); err != nil {
|
|
p.capturer = nil
|
|
return fmt.Errorf("macos capture: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Capture returns a fresh frame, serving from the short-lived cache if a
|
|
// previous caller captured within freshWindow. Handles the
|
|
// errFrameUnchanged return from CGCapturer by reusing the cached frame.
|
|
func (p *MacPoller) Capture() (*image.RGBA, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow {
|
|
return p.lastFrame, nil
|
|
}
|
|
if err := p.ensureCapturerLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
img, err := p.capturer.Capture()
|
|
if errors.Is(err, errFrameUnchanged) {
|
|
if p.lastFrame != nil {
|
|
p.lastAt = time.Now()
|
|
return p.lastFrame, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if err != nil {
|
|
// Drop the capturer so the next call retries init; the display stream
|
|
// can die if the session changes or permissions are revoked.
|
|
p.capturer = nil
|
|
return nil, fmt.Errorf("macos capture: %w", err)
|
|
}
|
|
p.lastFrame = img
|
|
p.lastAt = time.Now()
|
|
return img, nil
|
|
}
|
|
|
|
// ensureCapturerLocked initialises the underlying CGCapturer if needed.
|
|
// Caller must hold p.mu.
|
|
func (p *MacPoller) ensureCapturerLocked() error {
|
|
if p.closed {
|
|
return fmt.Errorf("poller closed")
|
|
}
|
|
if p.capturer != nil {
|
|
return nil
|
|
}
|
|
if time.Now().Before(p.initBackoffUntil) {
|
|
return fmt.Errorf("macOS capturer unavailable (retry scheduled)")
|
|
}
|
|
c, err := NewCGCapturer()
|
|
if err != nil {
|
|
p.initFails++
|
|
p.initBackoffUntil = time.Now().Add(macInitRetryBackoffFor(p.initFails))
|
|
if p.initFails == 1 || p.initFails%10 == 0 {
|
|
log.Warnf("macOS capturer: %v (attempt %d)", err, p.initFails)
|
|
} else {
|
|
log.Debugf("macOS capturer: %v (attempt %d)", err, p.initFails)
|
|
}
|
|
return err
|
|
}
|
|
p.initFails = 0
|
|
p.capturer = c
|
|
p.w, p.h = c.Width(), c.Height()
|
|
return nil
|
|
}
|
|
|
|
var _ ScreenCapturer = (*MacPoller)(nil)
|