Summary
The macOS build had bit-rotted since CI stopped building it. This brings it back on top of 2.0 and adds the macOS pieces needed to use it day to day: a menu bar app with a per-client virtual display, the same display settings as Windows, Remote Monitor, Remote Input and HDR. It also adds a macOS job to CI that builds Apple Silicon and Intel DMGs for macOS 14.2+ and smoke-tests them.
What's included
Build and packaging
- Restores the build with Apple Clang 15–21, including Xcode versions without
std::jthread(util::jthreadfalls back to a small stand-in there). - Packages a signed DMG: ad hoc signing without a Developer ID, which Apple Silicon needs to launch it, or the Developer ID when the release secrets are present.
- Builds a static OpenSSL for the deployment target in CI, so the Intel DMG targets 14.2 instead of the runner's macOS 15. The smoke test fails if any bundled binary needs a newer macOS than the deployment target.
- Bundles dylibs that other dylibs load through
@rpath(Homebrew's libwebp loads libsharpyuv), whichfixup_bundlecan't resolve on its own. - With a Developer ID, signs the app with the
audio-inputentitlement. Developer ID signing turns on the hardened runtime, which otherwise blocks audio capture.
Host
- Menu bar icon, with Open at Login (a launchd agent via SMAppService).
- Permissions: requests Screen Recording through ScreenCaptureKit, then Accessibility for remote input on the next launch. On macOS 27,
CGRequestScreenCaptureAccess()no longer prompts or lists the app, and macOS shows one privacy prompt at a time, so asking for both at once dropped one. Allowing Screen Recording needs a relaunch anyway, which macOS offers. - Per-client virtual display (private
CGVirtualDisplayAPI, looked up at runtime), followingvirtual_display_mode,virtual_display_layoutanddd.virtual_display_scale_percent. The exclusive layout turns the Mac's displays off. A helper process and a marker file turn them back on if Vibepollo dies mid-stream. - Sleep and lid: brings the virtual display up when a client connects (so a headless Mac has something to probe), streams from a sleeping Mac, and ends streams cleanly on sleep or lid close.
- Session stats: reports GPU utilization.
- Remote Monitor: each client gets its own virtual display, arranged by the remote display topology, captured with ScreenCaptureKit, with the pointer routed to it.
- Remote Input: a black-frame source for VideoToolbox and software encoding, so a Remote Input client gets the video stream Moonlight needs. VideoToolbox emits asynchronously, so the encoder is drained after the session's only frame, as AMF is.
- HDR: when Moonlight asks for HDR, the virtual display is created as an HDR display and captured in HDR. macOS 27's private
CGVirtualDisplayModetakes a transfer function, and 1 makes the display HDR, with 5× EDR headroom; the value is undocumented and was found by testing. ScreenCaptureKit's HDR capture (macOS 15) delivers 10-bit frames already in BT.2020 with PQ, so they reach VideoToolbox without conversion. The display reports its HDR metadata: a Display P3 gamut, and a peak of its EDR headroom times SDR white, which measures about 140 nits in the captured frames. Remote Monitor displays and physical HDR displays, such as a MacBook Pro's, use the same path. Without the API, displays and streams stay SDR. - Display scaling: keeps the Mac's own scaling (for example "More Space") when a virtual display is added. macOS otherwise switches the other displays to the default mode for the new combination of displays.
Shared code to review
Most changes are under src/platform/macos, src_assets/macos and the macOS CMake files. Outside those:
-Werroron macOS: dropped unused lambda captures and unused statics; platform-only flags marked[[maybe_unused]]; WebRTC-only constants underSUNSHINE_ENABLE_WEBRTC; a no-op Vulkan HDR layer hook off Windows (config.cpp,confighttp.cpp,nvhttp.cpp,process.cpp,rtsp.cpp,session_history_writer.cpp,stream.cpp,video.cpp,webrtc_stream.cpp,main.cpp).- File times:
lutris_integration.cpp,provider_scan_protocol.cpp,steam_artwork.cppandsteam_auto_sync_policy.cppcast file-time counts to 64 bits beforestd::to_string, because libc++ counts in__int128. The values are unchanged on Windows and Linux, so existing fingerprints stay valid. webrtc_stream.cpp: the output-override lease is declared on macOS too; the non-Windows branch already assigned it.util::jthreadreplacesstd::jthreadin code macOS compiles (main.cpp,stream.cpp,confighttp.cpp,nvhttp.cpp,tests/unit/test_remote_session.cpp). It isstd::jthreadwherever that exists.cmake/packaging/common.cmake: keeps asset subdirectories in the build tree. Build-tree runs on every platform were missingassets/remote-session/icons.cmake/targets/web.cmake: builds the web UI with parallel make.cmake/targets/common.cmake: no versioned executable and symlink inside a macOS.app(codesign rejects it).nvhttp.cpp/stream.cpp: the Remote Monitor runtime is registered on macOS as on Windows and Linux. Remote Monitor and Remote Input streams don't get the game's virtual display. On macOS, the virtual display brought up at launch is made HDR when the client asks for HDR.video.cpp: on macOS,make_black_display()returns the new black-frame source, and the input-only path drains VideoToolbox after its dummy frame (encode_avcodec()gains adrainflag that is only used there).tests/CMakeLists.txt: links the Apple frameworks FFmpeg needs for the artwork tests..github/workflows/ci.yml: adds the macOS job, gated on the tag push like Windows and Arch.
Not included (follow-ups)
- Quitting the game keeps Remote Monitor and Remote Input attached, as the Terminate control does. That is a shared change, coming in a separate PR together with a fix for the Remote Input tile missing from Moonlight's app list after its session ends (Windows and macOS).
- Virtual display scale default: 2.0 changed it from Recommended (-1) to Retain (0), and on macOS 0 currently means standard density. I'd like to discuss that separately.
Known limitations
- Open at Login after updates: builds without a Developer ID have no Team ID, so launchd pins the login agent to the build that registered it. After an update the app still starts, and logs that Open at Login needs to be turned off and on once.
- Private API:
CGVirtualDisplayis private. If a macOS release removes it, streams fall back to the physical display. - HDR depends on an undocumented value in the private
CGVirtualDisplayAPI, tested on macOS 27. Where a macOS release lacks it, streams are SDR.
Testing
- CI: both architectures build with
-DBUILD_WERROR=ON, package, and pass the smoke test (signature, minimum macOS 14.2, serves the web UI and serverinfo). - Unit tests on macOS: 63/65 pass.
regression_frame_limiter_vsyncandregression_virtual_capture_readinesscompile Windows code with the hostg++ -D_WIN32, which macOS's libc++ rejects. They fail the same way onmaster. - Manual: MacBook Pro (Apple Silicon), macOS 27, with Moonlight on iPhone, iPad Pro and Steam Deck:
- Pairing, Desktop stream, HEVC/H.264, audio
- Virtual display: per-client mode, exclusive and extended layouts, Retina scaling
- Remote input (mouse, keyboard, touch) after granting Accessibility
- Screen Recording prompt and listing on a fresh install
- Sleep and lid close mid-stream; displays come back
- Menu bar menu, Open at Login
- Remote Monitor from a second device beside a Desktop stream; pointer on the Remote Monitor; the Mac's scaling unchanged
- Remote Input from a second device: controls the Mac over a black stream
- Developer ID build with the hardened runtime, from reset permissions: one prompt per launch (Screen Recording, then Accessibility), the system audio prompt and audio, remote mouse input without a restart
- HDR Desktop stream to the iPad Pro: HDR virtual display, BT.2020 PQ capture, HEVC Main10, and an HDR video's highlights above SDR white
- HDR to a Remote Monitor, and from the MacBook's own display (same code path, not yet tested)
Screenshots
The menu bar menu: Open Vibepollo, Reload Apps, Check for Update, Open at Login, Restart, Quit.
AI assistance
This work was done with AI assistance: Claude Code, using Claude Opus 5.5 throughout at high or ultracode effort. I reviewed the changes and tested them on my Mac as listed above, per the contributing guidelines.
Addendum: Developer ID signing
I have an Apple Developer Program membership, so I can provide Developer ID signing and notarization for the macOS builds; we can work out whether that goes through the repo's release secrets or another route. The macOS CI already signs with a Developer ID and notarizes when the APPLE_* secrets are set; this PR adds the audio entitlement the hardened runtime needs, tested with a build signed with my Developer ID. A Developer ID gives the app a Team ID, which removes the Open at Login limitation above, keeps privacy permissions across updates, and drops the "Open Anyway" step on first launch.
Thanks for pulling the build, Boost.Process, and DNS-SD fixes from this PR, and the Remote Input reconnection fix from #561, into
master. I appreciate it.I've folded the rest of my macOS work into this PR so there's one place to review. It's merged with
masteras of 1b3167870 and also includes what was left of #561 (quitting the game keeps Remote Monitor and Remote Input attached), all of #587 (keeping displays off while a game is paused, and streaming across lid transitions), and pen and touch input. I'll close #561 and #587. The description is updated, and the shared-code section lists everything outside the macOS directories. A signed and notarized build of this branch is at 2.0.2-nightly.d20261023.macos.6.If it helps, I'm happy to build and maintain the macOS host going forward: keep it building as
mastermoves, test and fix macOS regressions, and handle Developer ID signing and notarization for releases (see the addendum). Whatever split works best for you.