Skip to content

Screen mirroring MVP (ReplayKit broadcast extension) - #16

Merged
MyNamesEMurray merged 21 commits into
mainfrom
claude/ios-screen-mirror
Jul 14, 2026
Merged

MyNamesEMurray merged 21 commits into
mainfrom
claude/ios-screen-mirror

Conversation

@MyNamesEMurray

Copy link
Copy Markdown
Owner

MVP for mirroring the whole iPhone/iPad screen (video + system audio) into the same OBS source, alongside the camera. Draft — needs on-device testing before merge (broadcast extensions can't run in the CI simulator/compile build; CI only proves it compiles).

How it works

A ReplayKit broadcast-upload extension (LensLinkBroadcast) captures the screen and app audio and streams it to the plugin over the existing wire protocol — so USB via usbmuxd works with no plugin transport change. The plugin dials port 9979 as always; the HELLO's new kind: "screen" tells it what it's receiving.

Scope (as agreed)

  • ✅ Screen video (VideoToolbox H.264, rebuilds encoder on rotation)
  • ✅ System audio (48 kHz stereo, played via obs_source_output_audio)
  • ❌ Microphone — omitted by design (you mic yourself in OBS; phone mic would double it)
  • Wi-Fi + USB; 1080p-class (encodes at the screen's native size)

Changes

iOS — new app-extension target sharing Protocol/StreamClient/VideoEncoder (no duplication); SampleHandler does capture→encode→send + audio conversion; StreamClient gains sourceKind + sendScreenAudio; in-app Mirror screen to OBS button (RPSystemBroadcastPickerView) for discoverability.

Plugin — OBSC_PKT_SCREEN_AUDIO (type 10); source advertises OBS_SOURCE_AUDIO; audio pts shares the video clock so A/V stay aligned; source labels itself "(screen)". Builds clean with -Wall -Wextra -Werror.

Docs — PROTOCOL.md (kind field + packet 10) and README updated.

Known MVP limitations to verify on device

  1. Extension memory (~50 MB cap) — 1080p should be fine; 4K screens may need a downscale. Watch for the extension being killed.
  2. Sideloading with an extension on a free Apple ID — the extension needs its own provisioning; this is the most likely install snag for testers.
  3. No adaptive bitrate in the extension yet (fixed, resolution-scaled) — fine on USB/LAN, may need backoff on weak Wi-Fi.
  4. Local-network permission may prompt on first Wi-Fi use (USB unaffected).
  5. One-at-a-time: camera and screen both use port 9979, so a device serves one or the other.

Tagged Release-Skip: true so merging won't auto-cut a release until you've tested it and are ready.

🤖 Generated with Claude Code

https://claude.ai/code/session_019Qrp83VmgTR1fJmSeBRtHR


Generated by Claude Code

Adds an iOS screen-mirror path alongside the camera: a ReplayKit
broadcast-upload extension (LensLinkBroadcast) captures the whole screen
plus system audio and streams it to the plugin over the existing wire
protocol — so USB via usbmuxd works with no plugin transport change.

iOS:
- New app-extension target sharing Protocol/StreamClient/VideoEncoder
  with the app (no code duplication). SampleHandler encodes screen frames
  with VideoToolbox H.264 (rebuilds the encoder on rotation) and converts
  .audioApp buffers to canonical 48 kHz stereo S16 PCM.
- StreamClient gains sourceKind + sendScreenAudio; HELLO/video-config
  carry "kind": "camera"|"screen".
- In-app "Mirror screen to OBS" button (RPSystemBroadcastPickerView)
  for discoverability. System audio only — mic omitted by design.

Plugin:
- OBSC_PKT_SCREEN_AUDIO (type 10): 48 kHz stereo S16LE, played via
  obs_source_output_audio (source now advertises OBS_SOURCE_AUDIO); pts
  shares the video clock so A/V stay aligned.
- Reads the kind field; labels the source "(screen)".

Protocol/README docs updated. Plugin builds clean with -Wall -Wextra
-Werror. iOS half is validated by CI compile; needs on-device testing
(broadcast extensions can't be exercised in the simulator build).

Release-Skip: true
@MyNamesEMurray
MyNamesEMurray marked this pull request as ready for review July 13, 2026 11:52
Sideloading tools re-sign with a remapped bundle id, so the hardcoded
extension id stopped matching and the broadcast picker fell back to
listing every provider on the phone instead of pre-selecting LensLink.

Release-Skip: true
The extension has no UI, so a listener failure (port taken by the
camera stream) or OBS never dialing in looked like 'recording but
nothing happens'. End the broadcast with a descriptive error instead:
iOS shows it to the user as an alert.

Release-Skip: true
'Waiting for iPhone on USB' with a live broadcast means the mux connect
is refused device-side. The alert now reports the listener's actual
state and how many connections ever arrived, separating listener-never-
ready / nothing-arrived / handshake-died without console access.

Release-Skip: true
… listening'

'Waiting for iPhone on USB' covered both, which made it impossible to
tell whether a failing screen broadcast meant the phone wasn't
enumerated at all or its listener refused the connection.

Release-Skip: true
Apple Mobile Device Service (Windows) pretty-prints its ListDevices
reply, and region_string required <key> and <string> to be adjacent —
so SerialNumber never parsed there: the USB device dropdown stayed
empty forever and UDID pinning silently never engaged. Match the two
tags separately (as the integer parser already did). Unit-tested against
pretty-printed and compact replies.

Release-Skip: true
- Installer now deletes the pre-1.0 ios-camera-source plugin: if left in
  place it loads first, registers the source id, and OBS rejects the
  current plugin as a duplicate — users silently keep running old code
  ('Source ios_camera_source already exists' in the log). README
  troubleshooting entry added for manual installs.
- App gains a 'Check broadcast link' diagnostic that connects to the
  broadcast extension's listener locally, separating extension-side
  failures from OBS/transport ones without console access.

Release-Skip: true
No red broadcast indicator means the extension never launches; the
usual cause on sideloaded installs is the signing tool stripping
PlugIns (while the picker shows a stale replayd cache entry). The
screen-mirror section now inspects the installed bundle and says
whether the .appex is actually there.

Release-Skip: true
New workflow builds, signs, and uploads to TestFlight on published
releases (or manual dispatch), using an App Store Connect API key from
repo secrets — no certificates in the repo. project.yml gains
MARKETING_VERSION/CURRENT_PROJECT_VERSION (CI drives the build number
from the run number). docs/TESTFLIGHT.md covers the one-time setup.

Canonical Apple provisioning should also resolve the broadcast
extension failing to launch under third-party re-signing.

Release-Skip: true
…evices)

Automatic signing archives with a development profile by default, which
requires a registered device — none exist on a CI runner, so the archive
failed with 'No profiles for ... were found'. Forcing Apple Distribution
at archive time needs no device list and lets the API key mint the
cloud-managed cert.

Release-Skip: true
…vice)

Automatic signing during archive defaults to a development profile
(get-task-allow=1), which requires a registered device the CI account
lacks. Archive with CODE_SIGNING_ALLOWED=NO and let -exportArchive
(signingStyle automatic + API key) create the distribution profile and
sign — distribution profiles need no device.

Release-Skip: true
com.exaltedpixels.LensLink is registered to another Apple account
(globally-unique bundle ids), so it can't be used for the app record.
Set an explicit unique id on the app + its .broadcast child; the
runtime-derived broadcast-picker id follows automatically. Docs and the
TestFlight workflow comment updated.

Release-Skip: true
- All four interface orientations (iPad multitasking requirement).
- Select Xcode 26 on the runner: uploads must be built with the iOS 26
  SDK; the image default (16.4) is rejected at validation.

Release-Skip: true
The browser panel at localhost:9980 exposes zoom/exposure/focus/flashlight,
none of which apply to a screen mirror. Track a per-source is_screen flag
(set from the HELLO kind), expose it via ios_camera_is_screen(), and emit it
in /api/status. The panel now swaps the controls for a short note when the
connected source is a screen.

Release-Skip: true
…bounding box

Adds a Screen mirroring section explaining the broadcast picker, how to pin
the source size with OBS's Scale-to-inner-bounds (Fit) transform (with a
screenshot), and that the browser panel hides camera controls for screens.

Release-Skip: true
…gnostics

Fixes the intermittent black screen: with hardware decoding on, some
screen-mirror resolutions (tall, non-16-aligned, e.g. 886x1918) are
silently rejected by the GPU decoder on Windows — avcodec_send_packet
succeeds but avcodec_receive_frame never yields a frame, so decode 'works'
while the source stays black and the plugin looks connected.

The plugin now detects this (a keyframe went in but no frame came out within
~10 packets), destroys the hardware decoder, recreates it in software, and
asks the device for a fresh keyframe (new CONTROL command, honoured by the
screen extension) so the picture returns in a fraction of a second instead
of waiting up to ~2s for the next periodic keyframe. Hardware decoding
stays the default and recovers itself.

Diagnostics to pinpoint any remaining stalls, all funnelled to the OBS log:
- Plugin: per-connection heartbeat (packets, keyframes, decoded frames,
  errors, bitrate, decoded size/format, GPU/CPU) every ~2s; a 'first decoded
  frame' line; and a 'Verbose diagnostics' source toggle (on by default).
- h264-decoder: frames-output and last-frame getters.
- Extension/app: matching heartbeat (screen samples, encoded/sent/dropped
  frames, connection state) forwarded over a new DIAG wire packet so the
  phone's own counters appear in the OBS log too — the extension has no
  console of its own.
- New docs/DEBUGGING-SCREEN-MIRROR.md maps log patterns to causes.

Also adds a one-line HEVC toggle in the extension (preferHEVC) to A/B whether
HEVC's lower bitrate reduces backpressure drops; falls back to H.264 when
unsupported, and the plugin already decodes either.

Release-Skip: true
The audio transport can be perfectly healthy while carrying pure silence —
DRM apps (Apple Music, Spotify, Netflix) mute or pause themselves the
moment a broadcast starts, so 'no audio in OBS' can look identical to a
broken pipeline in the packet counters. The heartbeat now includes the
loudest sample since the last beat (peak=NN%), which separates 'real audio
arriving' from 'a healthy stream of silence' at a glance. Doc table updated
with the DRM and OBS audio-monitoring rows.

Release-Skip: true
@MyNamesEMurray
MyNamesEMurray merged commit 1c2298a into main Jul 14, 2026
10 checks passed
@MyNamesEMurray
MyNamesEMurray deleted the claude/ios-screen-mirror branch July 14, 2026 07:30
MyNamesEMurray added a commit that referenced this pull request Jul 14, 2026
The add-source menu now offers LensLink Camera and LensLink Screen as
distinct source types with their own property sheets, sharing one
connection/decode engine:

- Screen source: connection/decoding/diagnostics only — no lip-sync
  group, no web panel; screen-specific help text.
- Strict kind enforcement: each type accepts only its own stream,
  rejecting at HELLO with an actionable status and a 3 s redial backoff.
- Camera sources no longer advertise audio (no more dead mixer meter);
  only Screen sources appear in the mixer.
- New optional "Disconnect when this source isn't shown anywhere" toggle
  (off by default) on both types: hidden sources release the phone
  entirely and reconnect when shown.
- Existing scenes keep working: the camera source id is unchanged.

Together with the screen-mirroring MVP (#16) already on main, this makes
the first minor release since v1.0.

Release-Bump: minor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant