virtualdisplay

package module
v0.3.0 Latest Latest
Warning

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

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

README

go-macos/virtualdisplay

ci Go Reference Coverage Private API

Create virtual displays on macOS from pure Go, CGO_ENABLED=0, via purego. A display this package opens is a real display as far as the system is concerned: it has a CGDirectDisplayID, it is in CGGetActiveDisplayList and System Settings, the desktop extends onto it, applications can be moved there, and a capture API can record it.

if err := virtualdisplay.Available(); err != nil {
        log.Printf("no virtual displays on this macOS: %v", err)
        return // carry on with none — see "Degrading cleanly" below
}

d, err := virtualdisplay.Open(virtualdisplay.Spec{
        Name:   "XR screen 1",
        Width:  1920,
        Height: 1080,
})
if err != nil {
        return err
}
defer d.Close()

capture(d.ID()) // a CGDirectDisplayID, ready for ScreenCaptureKit

⚠ This is PRIVATE CoreGraphics API

There is no public macOS API that creates a virtual display. The public route is a DriverKit driver extension, which needs an Apple-granted entitlement. This package drives four undocumented Objective-C classes that CoreGraphics has carried for years and that every third-party virtual-display app on macOS uses: CGVirtualDisplayDescriptor, CGVirtualDisplay, CGVirtualDisplaySettings and CGVirtualDisplayMode.

  • Apple may change, rename or remove these classes in any macOS release, including a point release. Nothing here is covered by any compatibility promise.
  • A program that links this package cannot ship on the Mac App Store. Review rejects private-API use.
  • The selectors are reverse-engineered. Their argument types were read off the live runtime's method type encodings; a future OS could keep a selector's name and change its signature, which no amount of checking can detect.

Nothing else is needed: the classes live in CoreGraphics inside the dyld shared cache on the sealed system volume. No third-party software, driver, kext or system extension is required or used.

Degrading cleanly

Sending a message to a class that no longer exists, or a selector a class no longer implements, is a hard crash in the Objective-C runtime, not an error return. So this package never sends a message it has not first verified.

Available() — which Open calls before it allocates anything — looks up every class with objc_getClass and every selector with class_getInstanceMethod, and reports ErrUnavailable naming the first thing that has gone missing:

virtualdisplay: the private CoreGraphics virtual-display API is not available:
class CGVirtualDisplaySettings does not respond to setModes:

Call it at startup and decide whether to offer the feature at all. A consumer can run with zero virtual displays and simply do less; it never has to catch a crash to find out. The package never panics on a missing class or selector and never assumes a non-nil return. Off darwin every entry point reports ErrUnsupported and the package still compiles, so consumers cross-compile without thinking about it.

API

func Available() error                            // is the private API here, in the expected shape?
func Open(spec Spec) (*Display, error)            // create a display
func CloseAll() error                             // close every display this process opened
func OpenCount() int
func WaitGone(timeout time.Duration, ids ...uint32) error // wait for a removal to be observable

func ActiveDisplays() ([]DisplayInfo, error)      // every display macOS reports, virtual or not
func ActiveDisplayIDs() ([]uint32, error)
func DisplayModes(id uint32) ([]ActiveMode, error)

type Spec struct {
        Name                              string
        Width, Height                     uint32   // pixels; required
        RefreshRate                       float64  // Hz; 0 => 60
        HiDPI                             bool     // also ADVERTISE Retina modes
        ExtraModes                        []Mode
        SizeMM                            Size     // physical size; 0 => derived at 96 dpi
        VendorID, ProductID, SerialNumber uint32   // 0 => derived
        OnTerminate                       func()   // leave nil unless needed — see below
}

func (d *Display) ID() uint32                        // the CGDirectDisplayID
func (d *Display) Close() error                      // idempotent
func (d *Display) Name() string
func (d *Display) Size() (w, h uint32)
func (d *Display) Modes() []Mode
func (d *Display) HiDPI() bool
func (d *Display) ActiveMode() ActiveMode            // read at Open
func (d *Display) CurrentMode() (ActiveMode, error)
func (d *Display) AvailableModes() ([]ActiveMode, error)
func (d *Display) Closed() bool

Errors: ErrUnsupported, ErrUnavailable, ErrInvalidSpec, ErrRejected, ErrCreateFailed, ErrWrongMode, ErrModesUnreadable, ErrStillPresent.

What was measured, not assumed

All of the following was established on macOS 26.6.2 (25G83), Apple Silicon, by creating real displays and checking the result from outside with CGGetActiveDisplayList. Three of these behaviours are surprising enough that the package's whole design follows from them.

Changing a virtual display's mode makes it un-removable

Releasing the CGVirtualDisplay object is what destroys the display — unless the display's mode has been changed at any point. After a mode change, the object is deallocated (its retain count reaches zero) and the display stays on the desktop until the process exits. This was reproduced with CGDisplaySetDisplayMode, and with CGBeginDisplayConfiguration transactions at all three scopes (ForAppOnly, ForSession, Permanently), HiDPI or not, and switching back to the original mode first does not undo it.

So this package never sets a display's mode. Close works because of that.

macOS restores a remembered mode, per monitor identity

macOS remembers, per (VendorID, ProductID, SerialNumber), whichever mode that monitor was last set to, and restores it — so a display created at 1920×1080 whose identity was last seen at 800×600 comes up at 800×600. Since the mode cannot be corrected afterwards, Open instead derives a stable monitor identity from the display's name and pixel size, so a fresh identity has nothing remembered and comes up at the size requested, while reopening the same logical display finds the arrangement the user last chose for it. If the identity does come up wrong, Open retries once under a salted identity and then fails with ErrWrongMode rather than handing back a display of the wrong size.

CoreGraphics will not report modes for a display this process created

If a process asks CoreGraphics about displays before creating a virtual one, it can never obtain a CGDisplayMode for that new display: CGDisplayCopyDisplayMode returns NULL and CGDisplayCopyAllDisplayModes returns nothing, permanently. Nothing refreshes it — not a CGDisplayRegisterReconfigurationCallback, not pumping the run loop.

It affects only mode reporting, only in the creating process. The display itself is completely real: it is in the active list, CGDisplayPixelsWide reports its size correctly, and another process sees everything, HiDPI modes included. Nothing in this package depends on reading a mode; the affected calls report ErrModesUnreadable.

A process that dies leaves no phantom display

The window server owns the other end of the connection and reclaims the display when the creating process goes away — on a clean exit, on os.Exit without Close, and on a hard crash. Verified repeatedly, including by a child process that creates a display and calls os.Exit(0).

That is not a licence to skip Close: the display stays on the user's desktop for as long as your process lives.

Removing a display is asynchronous — Close is not the end of it

Open does not return until the display is active. Close has no matching wait, and the difference is not small: closing four 640x480 displays, CloseAll returned in 33 µs and macOS kept listing them for 716 ms (macOS 26.6.2, M4 Max; six 1920x1080 displays took 1.9 s). A caller that closes and immediately reads ActiveDisplays sees displays that are already dead, and reads them as a leak — which is exactly what happened, twice, before this was measured.

ids := []uint32{d1.ID(), d2.ID()}
_ = CloseAll()
if err := WaitGone(5*time.Second, ids...); err != nil {
        // ErrStillPresent: something else holds them, or a mode was changed.
}

This package's own integration tests used a fixed 1.5 s sleep before asserting a removal — shorter than a batch actually takes. They wait now.

HiDPI is advertised, never entered

Spec.HiDPI makes macOS advertise Retina modes — half the points, the same pixels. Measured on a 1600×1200 display: 11 modes of which 5 are Retina (including 800×600 points / 1600×1200 pixels), against 6 modes and none Retina with HiDPI unset.

The package does not switch into one, because that is a mode change and the display would then be un-removable. Setting HiDPI makes the Retina modes selectable — in System Settings, or by a caller who accepts that trade-off.

Leave OnTerminate nil unless you need it

Setting it installs an Objective-C block that calls back into Go. The window server can invoke that block while the process is exiting, after the Go runtime has begun shutting down, which crashes — observed intermittently in a process that created a display and exited without closing it. With OnTerminate nil, no block is installed and there is nothing to call.

Tested against real hardware

What was measured, not assumed above is the detail; this is the summary of what was actually connected to a machine, and what is known only from documentation. The difference matters for a package built on undocumented classes: a behaviour nobody ran is a guess, and nothing about the code says so.

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 finding in What was measured, not assumed, and everything below
Samsung Odyssey G95NC, 7680×2160 the one physical display attached throughout; it is the id=4 7680x2160 in the vdprobe transcript
Virtual displays created and destroyed repeatedly, singly and six at once at 1920×1080, each coming up at exactly the requested size, all removed, the active display list returning to precisely what it was
The un-removable-after-mode-change behaviour reproduced deliberately, with CGDisplaySetDisplayMode and with CGBeginDisplayConfiguration at all three scopes
Process death a child process created a display and called os.Exit(0); the window server reclaimed it
Not proven on hardware
  • Intel Macs. Apple Silicon only. darwin/amd64 is cross-compiled and vetted in CI; no Intel Mac ever ran it.
  • Any macOS other than 26.6.2. These classes are undocumented and carry no compatibility promise, so a finding on one release is evidence about that release. Available() exists precisely because the answer may differ.
  • More than one physical display attached. Every measurement was taken with exactly one.
Send us hardware

An Intel Mac, another macOS release, or a multi-monitor machine would each turn one of the lines above from a guess into a measurement. If you want one verified, send us the hardware and what it shows will be listed here. Until then, an unverified line says so.

Reproducing it

cmd/vdprobe runs the whole check from outside: enumerate, create, enumerate, destroy, enumerate, and assert the differences. It cleans up on a panic or a signal.

$ go build -o vdprobe ./cmd/vdprobe
$ ./vdprobe -w 800 -h 600 -hold 2s
Available(): the private CoreGraphics virtual-display API is present in the expected shape
BEFORE: 1 display(s)
    id=4 7680x2160 at (0,0) [main]
Open: "Go Virtual Display" -> CGDirectDisplayID 28, 800x600 requested, came up as 800x600 points / 800x600 pixels @0Hz
IMMEDIATELY AFTER CREATE: 2 display(s)
    id=4 7680x2160 at (0,0) [main]
    id=28 800x600 at (-800,0)
AFTER CREATE + 2s: 2 display(s)
    id=4 7680x2160 at (0,0) [main]
    id=28 800x600 at (-800,0)
OK: display 28 is active as 800x600 points / 800x600 pixels @0Hz and is not the main display
Close: 1 displays closed twice each, no error
AFTER CLOSE: 1 display(s)
    id=4 7680x2160 at (0,0) [main]
OK: the active display list is exactly what it was: [4]
PASS

Other flags: -n 3 for several at once, -hidpi, -list, -modes <id> to read a display's modes (use it from a second process to see the HiDPI modes of a display the first one created).

Tests

go test ./...                                     # portable logic + live runtime-shape check

The portable layer is held at 100 % statement coverage including every error branch, gated in CI. The purego bindings cannot be covered without a window server, so the gate is on virtualdisplay.go rather than a total that would have to be a lie.

The macOS lane runs on a CI runner with no GUI session, and still checks the thing that matters most for a package built on private API: it asks the live Objective-C runtime whether every class and selector in requiredShape is really there.

Tests that create real displays need a window-server session, so they are behind a build tag and an environment variable:

VIRTUALDISPLAY_INTEGRATION=1 go test -tags integration -v -run Integration ./...

They only ever ADD a display and REMOVE the one they added; a guard fails the test if any pre-existing display changed, and every test cleans up even on failure. Run the two HiDPI tests on their own — they must read the mode list before anything else in the process enumerates displays.

⚠ What a display leaves on the machine, for ever

Opening a virtual display makes macOS remember a monitor: it writes an ICC profile into /Library/ColorSync/Profiles/Displays, owned by root, and nothing removes it — not closing the display, not rebooting. The key is the monitor identity, which this package derives from the name and pixel size (see Spec.SerialNumber). So:

  • reusing a name and size reuses one profile, whatever a program does with it;
  • inventing a name per test leaves a new file on the developer's machine every time a test is added;
  • the retry path in Open deliberately mints a different identity when the window server will not bring a display up, so a run that retries leaves one more.

Measured on the machine this package was written on: 106 of 235 stored display profiles came from this project's probes and tests. That is somebody's system directory, filled by a test suite.

The tests therefore draw from a fixed, declared set of six identities, and TestIntegrationZLeavesNoIdentityBehind reads what macOS remembers and fails on any go-macos monitor outside it, printing the rm command. Write a throwaway probe the same way: reuse go-macos test 1, do not invent a name.

To see what is there, and what a project has left behind:

ls /Library/ColorSync/Profiles/Displays

Requirements

Go 1.26+, macOS on Apple Silicon or Intel. CGO_ENABLED=0 throughout, no cgo, no shelling out.

Licence

BSD-3-Clause.

Documentation

Overview

Package virtualdisplay creates virtual displays on macOS from pure Go, with CGO_ENABLED=0. A display it opens is a real display as far as the rest of the system is concerned: it has a CGDirectDisplayID, it appears in CGGetActiveDisplayList and in System Settings > Displays, the desktop extends onto it, and ordinary applications can be dragged there and captured from there.

⚠ This package uses PRIVATE CoreGraphics API

There is no public macOS API that creates a virtual display. The public route is a DriverKit driver extension, which needs an Apple-granted entitlement. This package instead drives four undocumented Objective-C classes that CoreGraphics has carried for years and that every third-party virtual-display app on macOS uses: CGVirtualDisplayDescriptor, CGVirtualDisplay, CGVirtualDisplaySettings and CGVirtualDisplayMode.

The consequences are not negotiable, so they are stated up front:

  • Apple may change, rename or remove these classes in any macOS release, including a point release. Nothing here is covered by any compatibility promise.
  • A program that links this package cannot be distributed on the Mac App Store. App Store review rejects private-API use.
  • The selectors are reverse-engineered. The argument types below were read off the live runtime's method type encodings on the macOS this package was developed against; a future OS could keep a selector's name and change its signature, which no amount of checking can detect.

Failing loudly rather than crashing

Sending a message to a class that no longer exists, or a selector a class no longer implements, is a hard crash in the Objective-C runtime, not an error return. So this package never sends a message it has not first verified. Available — which Open calls before it allocates anything — looks up every class with objc_getClass and every selector with class_getInstanceMethod, and reports ErrUnavailable naming the first class or selector that has gone missing:

virtualdisplay: the private CoreGraphics virtual-display API is not available:
class CGVirtualDisplaySettings does not respond to setModes:

That is the failure mode to expect when a future macOS moves the shape. Treat ErrUnavailable as "this OS release broke it", print it, and carry on without virtual displays. Do not retry.

Lifetime

A virtual display lives exactly as long as the CGVirtualDisplay object that owns it. Display.Close releases that object and the display disappears. Close is idempotent: calling it twice is safe and the second call is a no-op, because a second Objective-C release would be a use-after-free.

The display is also torn down when the creating process dies, including on a crash or SIGKILL — the WindowServer owns the other end of the connection and reclaims it. This was verified deliberately (see the package README): a process that panics mid-flight leaves no phantom display behind. That is a safety property worth relying on, but it is not a licence to skip Close: the display stays on the user's desktop for as long as your process lives.

Usage

if err := virtualdisplay.Available(); err != nil {
	log.Printf("no virtual displays on this macOS: %v", err)
	return
}
d, err := virtualdisplay.Open(virtualdisplay.Spec{
	Name:   "XR screen 1",
	Width:  1920,
	Height: 1080,
	HiDPI:  true,
})
if err != nil {
	return err
}
defer d.Close()
capture(d.ID()) // a CGDirectDisplayID, ready for ScreenCaptureKit

Portability

Every exported symbol exists on every platform so consumers cross-compile. Off darwin the entry points report ErrUnsupported.

Index

Constants

View Source
const (
	// MinDimension is the smallest pixel width or height a virtual display may
	// have. Below this the WindowServer rejects the mode.
	MinDimension = 64
	// MaxDimension is the largest pixel width or height this package will ask
	// for. It is a sanity bound, not a measured hardware limit.
	MaxDimension = 16384
	// MinRefreshRate and MaxRefreshRate bound [Spec.RefreshRate] in Hz.
	MinRefreshRate = 1
	MaxRefreshRate = 240

	// DefaultRefreshRate is used when [Spec.RefreshRate] is zero.
	DefaultRefreshRate = 60
	// DefaultDPI is the pixel density used to derive a physical size in
	// millimetres when [Spec.SizeMM] is left zero. macOS reads the physical
	// size as an EDID would, and uses it to decide what counts as a sensible
	// default resolution.
	DefaultDPI = 96
	// DefaultName is the display name used when [Spec.Name] is empty. It is
	// what System Settings > Displays shows.
	DefaultName = "Go Virtual Display"

	// DefaultVendorID and DefaultProductID identify displays this package
	// creates. They are arbitrary: no real vendor owns them.
	DefaultVendorID  = 0x676F // "go"
	DefaultProductID = 0x5644 // "VD"

)

Limits and defaults.

Variables

View Source
var (
	// ErrUnsupported is returned by every entry point on a non-darwin platform.
	ErrUnsupported = errors.New("virtualdisplay: unsupported on this platform (macOS only)")

	// ErrUnavailable is returned when the private CoreGraphics virtual-display
	// classes are absent, or present but missing a selector this package needs.
	// The wrapped message names the first class or selector that is missing, so
	// a report from a future macOS says exactly what moved. See [Available].
	ErrUnavailable = errors.New("virtualdisplay: the private CoreGraphics virtual-display API is not available")

	// ErrInvalidSpec is returned by [Open] when the [Spec] cannot describe a
	// display: a dimension out of range, an impossible refresh rate, a mode
	// larger than the display it belongs to, a name the Objective-C runtime
	// cannot carry.
	ErrInvalidSpec = errors.New("virtualdisplay: invalid spec")

	// ErrRejected is returned when the display object was created but the
	// WindowServer refused the settings (-[CGVirtualDisplay applySettings:]
	// returned NO), so no display ever became active. An empty mode list and a
	// mode larger than the descriptor's maximum both produce this.
	ErrRejected = errors.New("virtualdisplay: the WindowServer rejected the display settings")

	// ErrCreateFailed is returned when -[CGVirtualDisplay initWithDescriptor:]
	// yields nil, i.e. the WindowServer would not hand out a display at all.
	// The usual cause is a process with no window-server session (a plain ssh
	// login, a CI runner, a LaunchDaemon).
	ErrCreateFailed = errors.New("virtualdisplay: the WindowServer would not create a virtual display")

	// ErrWrongMode is returned when the display came up at a size other than
	// the one requested and could not be made to come up at the right one.
	//
	// macOS remembers, per (VendorID, ProductID, SerialNumber), whichever mode
	// that monitor was last set to, and restores it. A fresh identity has
	// nothing remembered and comes up at the requested size, so [Open] retries
	// once under a fresh identity — but only when it chose the serial number
	// itself. If [Spec.SerialNumber] was set explicitly, that identity is the
	// caller's to manage and this error is returned instead of quietly using a
	// different one.
	//
	// The mode CANNOT simply be corrected after the fact: see
	// [Display.Close]'s documentation on why this package never changes a
	// display's mode.
	ErrWrongMode = errors.New("virtualdisplay: the display came up at a size other than the one requested")

	// ErrModesUnreadable is returned by [Display.AvailableModes],
	// [Display.CurrentMode] and [DisplayModes] when CoreGraphics will not
	// report a display's modes to THIS process.
	//
	// Measured on macOS 26.6.2: CoreGraphics builds a per-process cache of
	// display modes the first time a process asks about displays. A virtual
	// display created after that cache exists is fully real — it is in
	// CGGetActiveDisplayList, CGDisplayPixelsWide reports its size, other
	// processes see it completely — but this process can no longer obtain a
	// CGDisplayMode for it, permanently, and nothing refreshes that: not a
	// reconfiguration callback, not pumping the run loop.
	//
	// It affects only mode reporting, never the display itself, and only the
	// process that created it. Nothing else in this package depends on being
	// able to read a mode. To inspect the modes of a display you created —
	// which is how to confirm HiDPI took effect — read them from another
	// process, or arrange to create the display before anything in the process
	// enumerates displays.
	ErrModesUnreadable = errors.New("virtualdisplay: CoreGraphics will not report this display's modes to this process")

	// ErrStillPresent is what [WaitGone] returns when macOS is still listing a
	// display after the time allowed for it to go.
	//
	// Reaching it means something holds the display that this package cannot
	// release: another process created it, or its mode was changed after
	// creation, which stops a release from removing it (see [Display.Close]).
	ErrStillPresent = errors.New("virtualdisplay: macOS still lists a display that was released")
)

Errors reported by the package. They are stable and may be tested with errors.Is.

Functions

func ActiveDisplayIDs

func ActiveDisplayIDs() ([]uint32, error)

ActiveDisplayIDs returns the IDs from ActiveDisplays, sorted.

func Available

func Available() error

Available reports whether this macOS still carries the private virtual-display API in the shape this package expects. It returns nil if every class and selector is present, ErrUnavailable naming the first thing missing otherwise, and ErrUnsupported off darwin.

Open calls it first, so calling it yourself is only needed to decide whether to offer the feature at all.

func CloseAll

func CloseAll() error

CloseAll closes every display this process still has open and returns the first error. Use it from a signal handler or a top-level defer so a display never outlives the run that created it.

func OpenCount

func OpenCount() int

OpenCount returns how many displays this process currently has open.

func WaitGone added in v0.2.0

func WaitGone(timeout time.Duration, ids ...uint32) error

WaitGone waits until macOS lists none of ids as an active display.

⚠ Releasing a display is ASYNCHRONOUS, and this is the half that says so. Display.Close returns as soon as the CGVirtualDisplay object is released; the WindowServer retires the display a moment later. Measured on macOS 26.6.2, six 1920x1080 displays closed in one go were still in ActiveDisplays 250 ms later and took 1.9 s to all leave — long enough that a caller which closes and immediately looks sees displays that are already dead and reads them as a leak. Open does not return until the display is active; this is the other direction, and a release is not observable until it finishes.

A zero or negative timeout checks once and does not wait. No ids is not an error: nothing is present, so the wait is over before it starts.

Types

type ActiveMode

type ActiveMode struct {
	PointsWide, PointsHigh int
	PixelsWide, PixelsHigh int
	RefreshRate            float64
}

ActiveMode is a mode a display can be in. Points are what the desktop measures in; pixels are what a capture of the display produces. They differ by a factor of two on a HiDPI (Retina) mode.

func DisplayModes

func DisplayModes(id uint32) ([]ActiveMode, error)

DisplayModes returns every mode macOS offers for any display, virtual or not. It can report ErrModesUnreadable for a display this same process created; from any other process the same display reads normally.

func (ActiveMode) HiDPI

func (a ActiveMode) HiDPI() bool

HiDPI reports whether this is a Retina mode — more pixels than points.

func (ActiveMode) String

func (a ActiveMode) String() string

String renders the mode as "960x540 points / 1920x1080 pixels @60Hz".

type Display

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

Display is a live virtual display. It is safe for concurrent use.

func Open

func Open(spec Spec) (*Display, error)

Open creates a virtual display and returns it. The display is active — it is in CGGetActiveDisplayList and the desktop has extended onto it — by the time Open returns.

⚠ IT LEAVES SOMETHING ON THE MACHINE. macOS remembers every monitor it has seen: opening a display writes an ICC profile into /Library/ColorSync/Profiles/Displays, owned by root, and neither Display.Close nor a reboot removes it. The identity is derived from the name and pixel size (see Spec.SerialNumber), so a program that reuses a name reuses one profile while one that invents names fills somebody's system directory. Measured on one machine: 106 of 235 stored profiles came from this project's own probes and tests. Use few, stable names.

It never becomes the main display, and it never disturbs a display that already existed.

Close it when done. See the package documentation for what happens if you do not.

func (*Display) ActiveMode

func (d *Display) ActiveMode() ActiveMode

ActiveMode returns the mode the display came up in, as read at Open. Its PixelsWide and PixelsHigh are guaranteed to be the requested Spec.Width and Spec.Height — Open fails rather than return a display of another size.

func (*Display) AvailableModes

func (d *Display) AvailableModes() ([]ActiveMode, error)

AvailableModes returns every mode macOS offers for this display. With Spec.HiDPI set the list also holds Retina entries — half the points, the same pixels — which is how to confirm HiDPI took effect.

It is read-only. Selecting one of these modes is not something this package will do; see Display.Close.

It commonly reports ErrModesUnreadable for a display this process created: read that error's documentation before concluding anything about the display.

func (*Display) Close

func (d *Display) Close() error

Close destroys the display. It is idempotent: the second and later calls do nothing and return nil, which matters because the underlying teardown is an Objective-C release and doing it twice would be a use-after-free.

Close returns before macOS has finished retiring the display: the removal is asynchronous, and WaitGone is how to wait for it to be observable.

Why this package never changes a display's mode

Close only works because the display's mode was never changed. Measured on macOS 26.6.2: once a virtual display is switched into a different mode — by CGDisplaySetDisplayMode, or by a CGBeginDisplayConfiguration transaction at any scope, HiDPI or not — releasing its CGVirtualDisplay object no longer removes it. The object is deallocated (its retain count reaches zero) and the display stays on the desktop until the process exits.

So this package chooses the mode by picking a monitor identity macOS has nothing remembered for, and never switches modes afterwards. If something else switches this display's mode, Close stops being able to remove it; that is a property of the private API, not of this code. Display.CurrentMode will show it.

func (*Display) Closed

func (d *Display) Closed() bool

Closed reports whether Display.Close has already run.

func (*Display) CurrentMode

func (d *Display) CurrentMode() (ActiveMode, error)

CurrentMode reads the mode the display is in right now. It differs from Display.ActiveMode only if something outside this package changed it, which is worth knowing: such a change pins the display for the life of the process (see Display.Close).

It can report ErrModesUnreadable; that is a limit on what this process can see, not on the display.

func (*Display) HiDPI

func (d *Display) HiDPI() bool

HiDPI reports whether Retina modes were requested. Whether one was actually selected is ActiveMode.HiDPI on Display.ActiveMode.

func (*Display) ID

func (d *Display) ID() uint32

ID returns the display's CGDirectDisplayID — the handle every other macOS display API takes, ScreenCaptureKit and CGDisplayStream included. After Display.Close it still returns the ID the display had, which is then stale.

func (*Display) Modes

func (d *Display) Modes() []Mode

Modes returns the modes the display advertises, primary first. macOS adds more of its own; this is what was asked for.

func (*Display) Name

func (d *Display) Name() string

Name returns the display's name as macOS shows it.

func (*Display) Size

func (d *Display) Size() (width, height uint32)

Size returns the display's pixel dimensions, as requested.

type DisplayInfo

type DisplayInfo struct {
	// ID is the CGDirectDisplayID.
	ID uint32
	// Mode is the mode the display is currently in, points and pixels both.
	Mode ActiveMode
	// X, Y, Width and Height are the display's bounds in the global display
	// coordinate space, in points.
	X, Y, Width, Height float64
	// Main reports whether this is the main display (the one with the menu
	// bar).
	Main bool
}

DisplayInfo describes one display macOS reports as active, virtual or not.

func ActiveDisplays

func ActiveDisplays() ([]DisplayInfo, error)

ActiveDisplays returns every display macOS currently reports as active, ordered by display ID. It is the outside check on this package's work: call it before and after Open and the difference is the display that was created.

func (DisplayInfo) String

func (d DisplayInfo) String() string

String renders the display as "id=7 800x600 at (-800,0)", noting the pixel size separately when the mode is HiDPI.

type Mode

type Mode struct {
	Width, Height uint32
	RefreshRate   float64
}

Mode is one resolution a virtual display advertises. Width and Height are pixels; RefreshRate is Hz, and zero means the display's own rate.

macOS synthesises further modes of its own around the ones given here (a 1920x1080 display is also offered at 1600x900, 1280x720 and so on), so a single mode is usually enough.

func (Mode) String

func (m Mode) String() string

String renders the mode as "1920x1080@60".

type Size

type Size struct{ Width, Height float64 }

Size is a physical size in millimetres, as an EDID would report it.

type Spec

type Spec struct {
	// Name is what System Settings > Displays calls the display. Empty means
	// [DefaultName].
	Name string

	// Width and Height are the display's pixel dimensions, and also the
	// maximum any [Mode] may have. Required; both must be within
	// [MinDimension] and [MaxDimension].
	Width, Height uint32

	// RefreshRate is the primary mode's rate in Hz. Zero means
	// [DefaultRefreshRate].
	RefreshRate float64

	// HiDPI asks macOS to additionally ADVERTISE Retina modes — each mode at
	// half its pixel size in points, with a backing scale factor of 2. With
	// HiDPI set, a 1920x1080 display also offers 960x540 points at 1920x1080
	// pixels, and [Display.AvailableModes] shows it.
	//
	// It does not change which mode the display starts in, and this package
	// will not switch into one: entering a Retina mode is a mode change, and a
	// virtual display whose mode has been changed cannot be removed until the
	// process exits (see [Display.Close]). Setting HiDPI makes the Retina modes
	// selectable — in System Settings, or by a caller who accepts that
	// trade-off — nothing more.
	HiDPI bool

	// ExtraModes are additional resolutions to advertise, beyond the primary
	// Width x Height. None may exceed Width or Height: the WindowServer
	// rejects the whole settings object if one does ([ErrRejected]).
	ExtraModes []Mode

	// SizeMM is the physical size reported to macOS. Zero means derived from
	// the pixel size at [DefaultDPI].
	SizeMM Size

	// VendorID, ProductID and SerialNumber are the identity macOS uses to tell
	// displays apart and to remember each one's arrangement and resolution.
	// Zero VendorID and ProductID mean [DefaultVendorID] and
	// [DefaultProductID]; a zero SerialNumber is derived, deterministically,
	// from Name and the pixel size — so reopening the same logical display
	// finds the arrangement the user last chose for it, while a differently
	// named or sized display is a different monitor. Two displays that share a
	// name and a size are therefore ONE monitor as far as macOS is concerned;
	// give them different names, or set this field, if you open several at
	// once.
	VendorID, ProductID, SerialNumber uint32

	// OnTerminate, if non-nil, is called when the WindowServer terminates the
	// display from its side rather than at your request — a window-server
	// restart, for instance. It is NOT called by [Display.Close]. It runs on an
	// internal dispatch queue, so it must not block; hand the work to a
	// goroutine.
	//
	// Leave it nil unless you need it. Setting it installs an Objective-C block
	// that calls back into Go, and the WindowServer can invoke that block while
	// the process is exiting — after the Go runtime has started shutting down,
	// which crashes. With OnTerminate nil no block is installed at all and
	// there is nothing to call. This was observed as an intermittent crash on
	// exit in a process that created a display and exited without closing it.
	OnTerminate func()
}

Spec describes a display to create. The only required fields are Width and Height, in pixels.

Directories

Path Synopsis
cmd
vdprobe command
Command vdprobe is the outside check on github.com/go-macos/virtualdisplay: it enumerates the displays macOS reports, creates a virtual display, enumerates again, destroys it, and enumerates a third time — printing all three lists and asserting the differences.
Command vdprobe is the outside check on github.com/go-macos/virtualdisplay: it enumerates the displays macOS reports, creates a virtual display, enumerates again, destroys it, and enumerates a third time — printing all three lists and asserting the differences.

Jump to

Keyboard shortcuts

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