screencapture

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: BSD-3-Clause Imports: 5 Imported by: 0

README

go-macos/screencapture

ci Go Reference Coverage

Screen and window capture on macOS from pure Go — CGO_ENABLED=0, via purego and go-macos/objc. A thin, honest wrapper over ScreenCaptureKit, built for a compositor that redraws every frame.

content, _ := screencapture.Shareable(ctx)
display, _ := content.MainDisplay()

s, err := screencapture.CaptureDisplay(ctx, display, screencapture.Options{
        FPS: 60, // a CEILING, not a promise
})
if err != nil {
        return err // errors.Is(err, screencapture.ErrPermissionDenied) says exactly what to do
}
defer s.Close()

for {
        f, fresh := s.Frame() // BORROWED bytes, no copy, no allocation
        if fresh {
                composite(f.Pix, f.Width, f.Height, f.Stride) // BGRA, PADDED rows
        }
        render()
}

Why it looks like this

The consumer is an XR virtual-desktop app that composites several captured screens into a panorama every frame, on a 16.6 ms budget. Two decisions follow from that and shape the whole API.

Frame() lends, it does not copy. Frame.Pix aliases the IOSurface the window server itself rendered into. The package retains and locks the CVPixelBuffer behind it and hands you the bytes; the borrow lasts until your next Frame, WaitFrame or Close. Measured on an M4 Max: 25.9 ns and zero allocations per call, at any frame size.

Stride is in the API, not assumed. Captured rows are padded. A 400-pixel-wide capture came back with a stride of 1664 bytes, not 1600 — 64 bytes of padding per row. Index with f.Stride, or use f.Row(y). This is the single most common way to get a sheared image out of ScreenCaptureKit, so the type makes it hard to get wrong.

Frames only arrive when something changes

ScreenCaptureKit is change-driven, and this surprises everyone once. Options.FPS is a ceiling. A stream on a motionless surface delivers one frame and then nothing: a static wallpaper was measured at 1 frame in 3.1 seconds. That is not a failure — it is the API telling you nothing moved. Frame()'s second return value, not a timer, is the truth about whether there is new content.

Permission

Capturing anything owned by another process needs the Screen Recording TCC grant.

if !screencapture.Authorized() {          // CGPreflightScreenCaptureAccess: no prompt
        screencapture.RequestAuthorization() // CGRequestScreenCaptureAccess: may prompt
}

A denial is a named error, never a bare nil, and its message is the remedy:

screencapture: getShareableContent: SCStreamErrorUserDeclined (-3801):
The user declined TCCs for application, window, display capture;
screencapture: Screen Recording permission denied — grant it in System Settings >
Privacy & Security > Screen & System Audio Recording to the application that
launched this program (for a program started from a shell that is the terminal or
editor, not the program itself), then restart that application

errors.Is(err, screencapture.ErrPermissionDenied) matches it.

Two things worth knowing. The grant belongs to the responsible application — for a binary started from a shell that is the terminal or the editor, not your binary. And once an application has been refused, macOS records the refusal and shows no further prompt: RequestAuthorization then returns false immediately, and only System Settings can change the answer.

Your own windows need no permission at all

Content owned by the calling process is capturable with no grant whatsoever.

content, _ := screencapture.CurrentProcessShareable(ctx) // never needs permission
win, _ := content.Window(myWindowID)
s, _ := screencapture.CaptureWindow(ctx, win, screencapture.Options{})

CaptureWindow tries the permissioned enumeration first and falls back to this one, so capturing your own window works on a machine that never granted Screen Recording. It is also how this package's live tests prove themselves on such a machine.

API

func Available() bool               // ScreenCaptureKit is present
func Authorized() bool              // may I capture right now? (no prompt)
func RequestAuthorization() bool    // ask the system (may prompt, once ever)

func Shareable(ctx) (*Content, error)               // needs the grant
func CurrentProcessShareable(ctx) (*Content, error) // needs nothing
func Displays(ctx) ([]Display, error)
func Windows(ctx) ([]Window, error)

func CaptureDisplay(ctx, Display, Options) (*Stream, error)
func CaptureWindow(ctx, Window, Options) (*Stream, error)

func (s *Stream) Frame() (Frame, bool)              // borrowed; bool = fresh
func (s *Stream) WaitFrame(ctx) (Frame, error)      // blocks for a NEW frame
func (s *Stream) Stats() Stats
func (s *Stream) Options() Options
func (s *Stream) Err() error                        // why the SYSTEM stopped us
func (s *Stream) Close() error                      // idempotent

type Frame struct {
        Pix           []byte    // BGRA, Stride bytes per row — BORROWED
        Width, Height int
        Stride        int       // NOT Width*4
        Seq           uint64
        At            time.Time
}

func (f Frame) Row(y int) []byte              // one row, padding trimmed, no alloc
func (f Frame) CopyTight(dst []byte) (int, error) // depad into your buffer, no alloc
func (f Frame) NRGBA() (*image.NRGBA, error)  // allocates; for saving to disk

Options's zero value captures the source at its native pixel size, at up to 60 fps, without the cursor.

Measured

On an Apple M4 Max, macOS 26.6.2, Go 1.26.4, CGO_ENABLED=0.

Stream.Frame() 25.9 ns/op, 0 B/op, 0 allocs/op
1280×720 capture 103.6 fps sustained, 0 allocations per frame
3840×1080 capture ("panorama") 107.9 fps, 15.8 MiB per frame, 0 allocations per frame
CopyTight, 1280×720 0.058 ms (only if you cannot use a padded buffer)
CopyTight, 3840×1080 0.36 ms, 46 GB/s

The capture path costs the consumer essentially nothing: the window server has already rendered the pixels, and Frame() hands over a pointer.

Verifying it yourself

# What can this machine see? Does it have the permission?
go run ./cmd/sccheck
go run ./cmd/sccheck -windows
go run ./cmd/sccheck -request

# Capture 30 frames of the main display and save the last one.
go run ./cmd/sccheck -n 30 -o /tmp/display.png

# The unit suite: no display, no permission, runs anywhere.
CGO_ENABLED=0 go test ./...
CGO_ENABLED=0 GOOS=linux go test ./...

# The LIVE proof. It opens a real NSWindow of its own, captures it, and asserts
# that the centre pixel is the colour the window was just painted — and that it
# CHANGES. It needs no permission, because the window belongs to this process.
SCREENCAPTURE_INTEGRATION=1 go test -tags integration -v -run TestLive .
SCREENCAPTURE_INTEGRATION=1 go test -tags integration -run '^$' \
        -bench . -benchmem -benchtime 500000x .

Captures never go in the repository. The live suite writes its PNGs to os.UserConfigDir()/go-macos-screencapture/captures, or to SCREENCAPTURE_ARTIFACT_DIR when set — and either way the directory is walked up to the filesystem root looking for a .git, and REFUSED if one is found, including a .git that is a file, which is what a worktree has. A capture is a picture of whoever ran the test, at work, and a .gitignore entry is not a control: it is one git add -f away from being published forever. It does not go to t.TempDir() either — the artefact exists so that a person can look at it, and a temporary directory is gone before anyone can. The refusal is tested on every platform and every lane in capturedir_test.go, which is deliberately untagged: a guard that only compiles where the live suite runs is a guard nobody runs. The frame committed under testdata/artifacts/ was put there by hand, from a disposable machine.

Tested against real hardware

This section separates what was actually run on a machine from what is known only from Apple's documentation. The difference matters: a capture path that was never executed can be wrong in a way that no compile and no unit test reports.

Hardware connected and exercised
Hardware What was actually done
Apple M4 Max, macOS 26.6.2 (build 25G83), Go 1.26.4, CGO_ENABLED=0 every figure in Measured, and everything below
Samsung Odyssey G95NC, 7680×2160 the display attached while those figures were taken
Capture of this process's own window proven, not asserted: the live suite opens a real NSWindow, paints it, captures it, and requires the captured centre pixel to be exactly the colour just painted — and then to CHANGE when the window is repainted
The real ScreenCaptureKit classes and selectors looked up in the live Objective-C runtime, including the CMTime by-value ABI round trip through a real SCStreamConfiguration
Not proven on hardware
  • Capture of a whole display, and of another process's window. Both need the Screen Recording TCC grant, which could not be granted on the machine this was built on. What IS exercised there is the refusal: the denial path returns ErrPermissionDenied with the remedy in its message, and that is asserted. The pixels themselves are not.
  • Intel Macs. darwin/amd64 is cross-compiled and vetted in CI on every push; no Intel Mac ever ran it.
  • macOS 12.3 through 25. The stated floor comes from Apple's availability annotations, not from a machine. Only 26.6.2 was run.
Send us hardware

An Intel Mac, an older macOS, or a machine where Screen Recording can be granted would each close one of the gaps above. If you want one of them closed, send us the hardware and what it shows will be listed here. Until then, an unverified line says so.

What this package does not do

  • No audio. SCStreamConfiguration can capture system audio and the microphone; this package asks for screen output only.
  • No pixel format but BGRA. It is what a compositor wants and what the window server produces natively.
  • No SCScreenshotManager. One-shot screenshots are a different shape of problem; open a stream and take one frame.
  • No SCContentSharingPicker. The system picker is an AppKit UI flow.

Platforms

macOS 12.3 or later (13+ in practice; CurrentProcessShareable needs 14). Every other platform compiles and reports ErrUnsupported, so consumers cross-compile without a build tag.

CGDisplayStream, the legacy path, is deprecated and no longer produces frames on current macOS. ScreenCaptureKit is the only route.

Licence

BSD-3-Clause.

Documentation

Overview

Package screencapture is a pure-Go, CGO-free wrapper over Apple's ScreenCaptureKit. It enumerates the displays and windows a process may capture, and streams a display (or a single window) as raw BGRA pixels.

ScreenCaptureKit is the ONLY capture route left on current macOS: the legacy CGDisplayStream path is deprecated and, on macOS 26, no longer produces frames. Everything here goes through SCStream.

The hot path

The package is written for a compositor that redraws every frame and cannot afford a copy or an allocation per frame. Stream.Frame hands back a BORROWED view of the most recent captured frame — the bytes are the IOSurface the window server itself rendered into, not a copy — together with a boolean saying whether it is newer than the one the previous call returned. In steady state a Frame call performs no allocation at all.

The borrow is valid until the next call to Stream.Frame, Stream.WaitFrame or Stream.Close. Copy out of it (see Frame.CopyTight or Frame.NRGBA) if you need to keep it longer.

Stride

A captured frame's rows are PADDED. Stride is the number of bytes per row and it is NOT Width*4 — the window server aligns rows (a 400-pixel-wide capture was measured at stride 1664, not 1600). Always index with Stride, or use Frame.Row. This is the single most common way to get a sheared image.

Frames only arrive when something changes

ScreenCaptureKit is change-driven. FPS is a CEILING, not a rate: a stream on a motionless surface delivers one frame and then nothing until a pixel moves (a static wallpaper was measured at 1 frame in 3.1 s). Do not treat a missing frame as a failure; treat the "fresh" flag from Stream.Frame as the truth about whether anything changed.

Permission

Capturing anything that belongs to another process needs the Screen Recording TCC grant. See Authorized, RequestAuthorization and ErrPermissionDenied. Capturing content owned by the CALLING process ([CurrentProcessContent]) needs no grant at all, which is what makes this package testable on a machine where the grant is missing.

Index

Examples

Constants

View Source
const (
	// DefaultFPS is the frame-rate ceiling used when Options.FPS is zero.
	DefaultFPS = 60.0
	// DefaultQueueDepth is the in-flight frame count used when
	// Options.QueueDepth is zero. Three is the documented minimum that keeps a
	// consumer holding one frame from starving the stream.
	DefaultQueueDepth = 6
	// MinQueueDepth is the smallest queue depth this package accepts.
	MinQueueDepth = 3
	// MaxDimension is the largest frame edge accepted, a sanity bound well
	// above any real display; it exists so a mistaken value fails loudly
	// instead of asking the window server for a terabyte.
	MaxDimension = 32768
)

Defaults applied to the zero value of the corresponding Options field.

Variables

View Source
var (
	// ErrUnsupported is reported on every non-darwin platform, and on a macOS
	// too old to carry ScreenCaptureKit (before 12.3).
	ErrUnsupported = errors.New("screencapture: unsupported on this platform (macOS 12.3 or later only)")

	// ErrPermissionDenied is reported when the Screen Recording TCC grant is
	// missing. Its message names the exact remedy; see also [Authorized].
	ErrPermissionDenied = errors.New("screencapture: Screen Recording permission denied — " +
		"grant it in System Settings > Privacy & Security > Screen & System Audio Recording " +
		"to the application that launched this program (for a program started from a shell " +
		"that is the terminal or editor, not the program itself), then restart that application")

	// ErrNoDisplay is reported when a capture was asked for and the system
	// listed no display at all.
	ErrNoDisplay = errors.New("screencapture: no capturable display")

	// ErrNotFound is reported when a display or window ID does not name
	// anything currently capturable.
	ErrNotFound = errors.New("screencapture: no such display or window")

	// ErrClosed is reported by every [Stream] method after [Stream.Close].
	ErrClosed = errors.New("screencapture: stream is closed")

	// ErrNoFrame is reported by [Stream.WaitFrame] when no frame arrived
	// before its context expired. It is NOT a malfunction: a motionless
	// surface legitimately produces no frames.
	ErrNoFrame = errors.New("screencapture: no frame available")

	// ErrInvalidOption is reported by [Options.Validate] and wraps a
	// description of the offending field.
	ErrInvalidOption = errors.New("screencapture: invalid option")

	// ErrShortBuffer is reported by [Frame.CopyTight] when the destination is
	// too small to hold the frame.
	ErrShortBuffer = errors.New("screencapture: destination buffer too short")
)

Sentinel errors. All are stable and may be matched with errors.Is.

Functions

func Authorized

func Authorized() bool

Authorized reports false: there is no Screen Recording grant to hold.

func Available

func Available() bool

Available reports false: ScreenCaptureKit exists only on macOS.

func RequestAuthorization

func RequestAuthorization() bool

RequestAuthorization reports false and prompts nothing.

Types

type Application

type Application struct {
	PID      int32
	Name     string
	BundleID string
}

Application is a process owning capturable windows.

type Content

type Content struct {
	Displays     []Display
	Windows      []Window
	Applications []Application
}

Content is a snapshot of what the calling process may capture. It is a snapshot: windows open and close, so re-read it rather than caching it.

func CurrentProcessShareable

func CurrentProcessShareable(ctx context.Context) (*Content, error)

CurrentProcessShareable reports ErrUnsupported.

func Shareable

func Shareable(ctx context.Context) (*Content, error)

Shareable reports ErrUnsupported.

func (*Content) Display

func (c *Content) Display(id uint32) (Display, error)

Display returns the display with the given CGDirectDisplayID.

func (*Content) MainDisplay

func (c *Content) MainDisplay() (Display, error)

MainDisplay returns the display carrying the menu bar, or the first one if none is flagged as main.

func (*Content) Window

func (c *Content) Window(id uint32) (Window, error)

Window returns the window with the given CGWindowID.

func (*Content) WindowsByTitle

func (c *Content) WindowsByTitle(title string) []Window

WindowsByTitle returns every window whose title is exactly title.

func (*Content) WindowsOfPID

func (c *Content) WindowsOfPID(pid int32) []Window

WindowsOfPID returns every window owned by the given process.

type Display

type Display struct {
	ID          uint32 // CGDirectDisplayID
	Width       int    // points
	Height      int    // points
	PixelWidth  int    // native pixels
	PixelHeight int    // native pixels
	Frame       Rect   // global desktop position, points
	Main        bool   // this is the display carrying the menu bar
}

Display is a capturable display.

Width and Height are in POINTS, as ScreenCaptureKit reports them. PixelWidth and PixelHeight are the display's native backing store in PIXELS, read from CoreGraphics — on a Retina display they are the larger pair, and they are what you want to hand to Options for a capture with no resampling.

func Displays

func Displays(ctx context.Context) ([]Display, error)

Displays reports ErrUnsupported.

func (Display) Scale

func (d Display) Scale() float64

Scale is the display's backing scale factor (pixels per point), 1 when the display reports no usable size.

func (Display) String

func (d Display) String() string

String renders the display for logs.

type Frame

type Frame struct {
	// Pix is the frame's bytes in [FormatBGRA], Stride bytes per row,
	// Height rows. len(Pix) == Stride*Height.
	Pix []byte
	// Width and Height are the frame's size in pixels.
	Width, Height int
	// Stride is the number of BYTES per row. It is padded and is NOT
	// necessarily Width*4.
	Stride int
	// Seq counts frames since the stream started; it is 0 before the first
	// frame and strictly increases afterwards.
	Seq uint64
	// At is when the delivery callback received the frame.
	At time.Time
}

Frame is a BORROWED view of one captured frame.

Pix aliases memory owned by the window server. It stays valid only until the next Stream.Frame, Stream.WaitFrame or Stream.Close on the stream that produced it. Do not retain it; copy with Frame.CopyTight or Frame.NRGBA if you need it to outlive the borrow.

func (Frame) CopyTight

func (f Frame) CopyTight(dst []byte) (int, error)

CopyTight copies the frame into dst with the row padding removed, so dst holds Width*4*Height bytes of contiguous BGRA. It reports how many bytes it wrote, or ErrShortBuffer if dst is too small. It allocates nothing.

func (Frame) NRGBA

func (f Frame) NRGBA() (*image.NRGBA, error)

NRGBA copies the frame into a freshly allocated image.NRGBA, swapping BGRA to RGBA as it goes. It is the convenience path for saving a frame to disk; it allocates, so it does not belong in a per-frame loop.

func (Frame) Row

func (f Frame) Row(y int) []byte

Row returns row y of the frame, Width*4 bytes with the padding trimmed off. It does not allocate. It returns nil for an out-of-range y or an invalid frame.

Example
f := makeFrame(2, 2, 8)
fmt.Println(f.Stride, len(f.Row(0)))
Output:
16 8

func (Frame) TightLen

func (f Frame) TightLen() int

TightLen is the number of bytes the frame occupies with no row padding, Width*4*Height.

func (Frame) Valid

func (f Frame) Valid() bool

Valid reports whether the frame holds pixels.

type Options

type Options struct {
	// Width and Height are the requested frame size in PIXELS. Zero means
	// "the source's native pixel size", which for a display is its backing
	// store and for a window is its frame scaled by the display's scale.
	Width, Height int

	// FPS is the CEILING on the frame rate, not a guarantee: ScreenCaptureKit
	// only emits a frame when the content changed. Zero means
	// [DefaultFPS]. It is converted to SCStreamConfiguration's
	// minimumFrameInterval.
	FPS float64

	// ShowsCursor draws the mouse pointer into the captured frames.
	ShowsCursor bool

	// QueueDepth is how many frames ScreenCaptureKit keeps in flight. Zero
	// means [DefaultQueueDepth]. It must leave room for the two frames this
	// package holds on the consumer's behalf (the one lent out and the one
	// waiting), so values below 3 are rejected.
	QueueDepth int

	// ExcludeWindows lists CGWindowIDs to keep out of a DISPLAY capture — for
	// example your own overlay, so capturing the screen it sits on does not
	// feed it back into itself. Ignored for a window capture.
	ExcludeWindows []uint32

	// ScalesToFit letterboxes the source into Width×Height instead of
	// cropping it when the aspect ratios differ.
	ScalesToFit bool
}

Options configures a capture stream.

The zero Options is usable: it captures the source at its native pixel size, at up to 60 frames per second, without the cursor.

func (Options) Validate

func (o Options) Validate() error

Validate reports whether the options are self-consistent, wrapping ErrInvalidOption. It does not consult the system.

type PixelFormat

type PixelFormat uint32

PixelFormat is a CoreVideo OSType naming the layout of a captured frame.

const FormatBGRA PixelFormat = 0x42475241 // 'BGRA'

FormatBGRA is 32-bit BGRA, kCVPixelFormatType_32BGRA. It is the only format this package streams: it is what a compositor wants, it is what the window server produces natively, and it is packed rather than planar so a frame is one contiguous run of bytes.

func (PixelFormat) BytesPerPixel

func (f PixelFormat) BytesPerPixel() int

BytesPerPixel is the size of one pixel in this format.

func (PixelFormat) String

func (f PixelFormat) String() string

String renders the OSType as its four-character code, e.g. "BGRA".

type Rect

type Rect struct {
	X, Y, W, H float64
}

Rect is a rectangle in the global desktop coordinate space, in POINTS (not pixels). It mirrors CGRect.

func (Rect) Empty

func (r Rect) Empty() bool

Empty reports whether the rectangle encloses no area.

func (Rect) String

func (r Rect) String() string

String renders the rectangle as "(x,y)+(w×h)".

type Stats

type Stats struct {
	// Frames is the number of frames actually delivered with pixels.
	Frames uint64
	// Idle is the number of callbacks that carried no image, which is how
	// ScreenCaptureKit says "nothing changed".
	Idle uint64
	// Superseded is the number of delivered frames that were replaced by a
	// newer one before the consumer ever asked for them. A large value next to
	// Frames means the consumer is slower than the capture.
	Superseded uint64
	// Last is when the most recent frame with pixels arrived.
	Last time.Time
	// Interval is the gap between the two most recent frames with pixels.
	Interval time.Duration
}

Stats reports what a stream has seen since it started.

func (Stats) FPS

func (s Stats) FPS() float64

FPS is the instantaneous rate implied by Stats.Interval, 0 when fewer than two frames have arrived.

type Stream

type Stream struct {
	// contains filtered or unexported fields
}

Stream is the non-darwin stand-in for a live capture. It can never be created here — CaptureDisplay and CaptureWindow always fail — but the type and its methods exist so consumer code compiles unchanged.

func CaptureDisplay

func CaptureDisplay(ctx context.Context, d Display, opt Options) (*Stream, error)

CaptureDisplay reports ErrUnsupported. It still validates the options first, so a consumer's option bug surfaces identically on every platform.

func CaptureWindow

func CaptureWindow(ctx context.Context, w Window, opt Options) (*Stream, error)

CaptureWindow reports ErrUnsupported, after the same option validation as CaptureDisplay.

func (*Stream) Close

func (s *Stream) Close() error

Close reports nil and is idempotent.

func (*Stream) Err

func (s *Stream) Err() error

Err reports nil.

func (*Stream) Frame

func (s *Stream) Frame() (Frame, bool)

Frame reports the zero frame and false.

func (*Stream) Options

func (s *Stream) Options() Options

Options returns the stream's resolved options.

func (*Stream) Source

func (s *Stream) Source() string

Source names what is being captured.

func (*Stream) Stats

func (s *Stream) Stats() Stats

Stats reports the zero statistics.

func (*Stream) WaitFrame

func (s *Stream) WaitFrame(ctx context.Context) (Frame, error)

WaitFrame reports ErrUnsupported.

type StreamError

type StreamError struct {
	// Code is the NSError code in SCStreamErrorDomain.
	Code int
	// Name is Apple's constant for Code, or "" for a code this package does
	// not know.
	Name string
	// Message is the NSError's localizedDescription.
	Message string
	// Op names the operation that failed, e.g. "getShareableContent".
	Op string
}

StreamError is an error reported by ScreenCaptureKit itself, carrying the SCStreamErrorDomain code. Codes this package recognises unwrap to a sentinel — notably -3801 (SCStreamErrorUserDeclined) unwraps to ErrPermissionDenied — so errors.Is works without anyone having to know the numbers.

func (*StreamError) Error

func (e *StreamError) Error() string

Error renders the code, Apple's name for it and the system's message.

func (*StreamError) Unwrap

func (e *StreamError) Unwrap() error

Unwrap maps the codes with a sentinel to that sentinel.

type Window

type Window struct {
	ID       uint32 // CGWindowID
	Title    string
	AppName  string
	BundleID string
	PID      int32
	Frame    Rect // global desktop position, points
	Layer    int  // CoreGraphics window layer; 0 is the normal application layer
	OnScreen bool
	Active   bool
}

Window is a capturable window.

func Windows

func Windows(ctx context.Context) ([]Window, error)

Windows reports ErrUnsupported.

func (Window) String

func (w Window) String() string

String renders the window for logs.

Directories

Path Synopsis
cmd
sccheck command
Command sccheck reports what github.com/go-macos/screencapture can see and capture on this machine, and is the quickest way to find out whether the Screen Recording permission is in place.
Command sccheck reports what github.com/go-macos/screencapture can see and capture on this machine, and is the quickest way to find out whether the Screen Recording permission is in place.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL