Virtual camera transport and capture hooks
Live host camera source (local extension, 2026-10-29)
Camera → Source: System Camera… selects a host video camera (see picker below). The menu requests video permission explicitly; it does not request microphone access. Start Streaming opens an AVCaptureSession on its own serial queue; Stop Streaming, Source: Off, source replacement and VM shutdown stop capture. Selecting the source without streaming does not start the session. Reconnecting the guest transport resumes an active stream but never restarts a stopped one.
The capture delegate retains only its latest pixel buffer under a lock. The existing sender samples that buffer at its existing 30 Hz transport cadence, aspect-fits it onto a black 1280x720 BGRA frame, and uses the unchanged VSOCK 1338 protocol. This forwards camera imagery through the synthetic camera; it does not pass through the physical camera's USB device, depth data or controls.
Launchpad is the responsible app for the VM process tree. Its source now
declares NSCameraUsageDescription and the hardened-runtime camera entitlement;
the signing script verifies that entitlement. The core bundle also carries
the usage description for standalone launches. An updated, appropriately
signed Launchpad is required before exercising this source through Launchpad.
The existing notarized installation has not been replaced with an ad hoc app.
The Release VPhone build and separate Release Launchpad build both passed.
The separate Launchpad was development-signed with the standard signing script;
signature, camera entitlement and helper requirement verification passed.
It remains under .vphone-setup/camera-launchpad-build/Build/Products/Release/
in the workspace.
Live acceptance: the user approved running the development copy. Installed the
core as 2.14.0-local.ca790397, retaining unchanged guest environment
2.14.0-local.b0297e92. The separate development Launchpad started Duo without
replacing the notarized app or installed privileged helper. Camera permission
completed, System Camera selection enabled Start Streaming, and the guest's
Camera.app visibly showed the default host capture device's Elgato NO SIGNAL
image. Thus the live capture-device feed reaches the native guest preview;
an active HDMI picture was not available to test. Two stopped observations of
the 64-byte shared frame header were identical (frame index 1147). Restarting
streaming advanced the frame index to 1325, and the menu returned to Stop
Streaming. Capture was stopped again at the end of the test. No camera images
were saved as test artifacts. The initial implementation followed AVFoundation's
default video device.
Camera picker (local extension, 2026-10-29)
Features → Camera → Source: System Camera… presents an accessible native camera picker with System Default and discovered devices by localized name. Built-in, external, Continuity and Desk View device types are enumerated afresh each time the picker opens. Both Launchpad and the core bundle declare the Continuity Camera device-type opt-in. Device selection uses unique IDs, never names, so duplicate camera names cannot select the wrong device.
Cancel preserves the current source. An explicitly selected disconnected camera
reports an error instead of falling back to another camera. Failed setup
preserves the current source; successful switching preserves streaming state.
The old and new capture sessions share a serial queue so the old session stops
before the new one starts. The picker remembers selection for the current VM
window only; it does not change the Mac's preferred camera or persist across
VM launches. Release builds and development Launchpad signing passed; deployed
core 2.14.0-local.44b76c2a retains the unchanged guest environment.
Runtime boundary
The host camera server sends BGRA frames over VSOCK port 1338. vphoned
receives them, writes a sequence guarded shared memory frame, and posts
com.vphone.vcam.frame. The optional libvcamcaptured hook reads that frame
and supplies it to camera clients. The host's Camera server: connected
status confirms the VSOCK connection only. It does not confirm that a camera
app or the capture hook is installed, loaded, or showing a preview.
The shared protocol and runtime paths are in
VPhoneGuestComponents/VCamCaptured/VCamFrameProtocol.h. All camera runtime
files live in /var/mobile/Media/SimulatedCamera: the shared frame
vphone-vcam-frame.shm, daemon log vphone-vcam.log, hook logs
vcamcaptured.log and camfix.log, and synthetic photo diagnostic
vphone-synth-photo.bgra. vphoned waits for the mobile home to be ready,
creates the directory with the mobile account as owner, and then opens the
shared file and binds port 1338. If setup fails during early boot, the
listener retries.
Failure observed before the media path change
On 2026-09-24, the old daemon required a shared file and log under a directory
that this VM did not have. Shared memory initialization failed before the
VSOCK listener started. Its diagnostic went to the same nonexistent directory.
The host repeatedly logged POSIX error 54, Connection reset by peer. The
camera transport now uses the mobile media directory directly, without
installation-layout detection. A system log fallback retains diagnostics when
file logging fails.
At the 2026-09-24 transport test, the camera hook dylibs were not installed or
loaded. Since 2026-09-25, cfw install and the vphoned environment update
place both hooks in /usr/lib, and SystemHook loads them without a bootstrap;
see the camera hook note in Research/0_binary_patch_comparison.md. The
remainder of this section describes the earlier state. An initial check of fixed application directories also missed
Camera.app, but a later app registration query found
com.apple.camera in an application container under
/private/var/containers/Bundle/Application. The guest component archive
builds the camera hooks but does not install them; see
VPhoneGuestComponents/README.md. Camera.app preview and still/video capture
were therefore unverified.
Transport acceptance on the media directory
On 2026-09-24, the integrated VM exposed 127.0.0.1:8765 for its guest API.
/var/mobile/Media/SimulatedCamera/vphone-vcam.log showed
listening on vsock 1338, client connected, and published frame counts.
The host Camera menu showed Camera server: connected. Shared memory was
read back through GET /v1/files/content with its path query set to
/var/mobile/Media/SimulatedCamera/vphone-vcam-frame.shm.
This verifies the host-to-guest transport and shared frame publication,
including source selection and stopping. A full camera acceptance test still
needs a supported hook loading path: verify the hook is loaded, open
Camera.app's preview, compare visible frames against Test Pattern and Video
File, then test still and video capture separately. After the relocated path
passed its guest test, the stale
/var/mobile/Library/vphone-vcam-frame.shm and
/var/mobile/Library/vphone-vcam.log files from the earlier build were
removed through the guest API. A subsequent directory listing contained
neither file.
The patch race that froze the whole VM (2026-10-05)
"Opening the camera freezes the VM" had two crashes behind it, both in
cameracaptured, and the freeze itself was never a camera-graph problem:
when the daemon crash-loops and launchd throttles it, every process that
first touches AVCapture — SpringBoard included — blocks in a synchronous
XPC to it, and the whole UI stops answering. The prewarm crash (row 23 of
the CFW table) was the first trigger; the second, still present after that
fix, was ours.
vcc_patch_word rewrites CMCapture shared-cache __text through
vm_protect(RW|COPY) → write → vm_protect(RX). During that window the
whole 16K page has no execute permission, and the pages it touches are live
XPC server code the daemon runs constantly. Two crash reports caught the
race red-handed, both with the patching thread (vcc_install_csp_requires_ master_clock_hook → vcc_scan_and_patch → vcc_patch_word) in the same
process when another thread died fetching an instruction in a page mid-flip
(region snapshot: r-x/rwx, SM=COW):
The capturesource queue is exactly what fires when a client opens the camera: registering for source-change notifications is part of enumeration. So "open camera" both restarts the daemon's traffic (after any crash or restart, the hook reinstall at +3 s re-opens the window) and supplies the thread that dies in it.
The fix (row 25 of the CFW table) stops the world around the flip:
task_threads + thread_suspend of every other thread, the two
vm_protect calls, the word write and the icache invalidation, then
thread_resume. The stopped section takes no locks, allocates nothing and
logs nothing — a frozen thread holding the malloc lock must not deadlock
the patcher — and all vcc_log calls moved after the resume. A mutex
serialises patchers. Kill-and-relaunch stress (killing cameracaptured
with Camera.app auto-reconnecting, the same collision the crashes came
from) is the validation.
Idle cost (2026-10-09)
With no camera client, both camera hooks used to keep timers running for the life of their process:
libvcamcapturedarmed its 30 Hz viewfinder and sink drive when it installed its hooks, three seconds aftercameracapturedstarted. Each tick copied an empty stream list and an empty sink table and returned, which woke the daemon 27,000–28,000 times in 15 idle minutes (30.2/s, 2–3 s of CPU) on an iPhone 27.0 guest. The drive now runs only while it has a consumer (VCamCaptured/Frames/VCamDriveGate.{h,c}):-[FigCameraViewfinderStream openWithDestination:]and every newBWImageQueueSinkNodeorBWRemoteQueueSinkNodestart it, and the first tick that finds no open stream and no live sink stops it. A consumer that appears during a tick keeps it running; start and stop run under the gate's lock. The log showsviewfinder drive started (30 Hz, start #N)andviewfinder drive stopped.make -C VPhoneGuestComponents test-vcam-drive-gatecovers the gate.libcamfixloads into every app-path process that has AVFoundation, which on a fresh guest includes SpringBoard, PosterBoard, AccessibilityUIServer, InputUI and most widget extensions. Each armed the 1 Hz preview-layer scan on its main queue as soon as AVFCapture loaded, walking the whole layer tree every second. The scan now starts with the first vcam-bound session that reaches-[AVCaptureSession _setRunning:YES]; the preview-layer initializer hooks still adopt layers directly. If_setRunning:cannot be hooked, the scan starts at install as before.
After both changes cameracaptured took about one wakeup a second in the same
window, and AccessibilityUIServer and InputUI dropped from about 900 each to
under 10. The numbers are in service_trimming.md.
Camera frames were not checked live after the change: an iPhone 27.0 guest
publishes no synthetic camera at all (libvcamcaptured logs _sSourceList not located on every boot), so nothing can start the drive there. That is a
separate open item (iOS 27 capture sources),
since closed (below).
On 26.6.2 the synthetic source is published and the Camera app starts.
Front-camera clients and data-output frames (2026-10-10)
The synthetic source is published once, as a back camera
(kFigCaptureSourceAttributeKey_Position = 1). Checked on a 26.6.2 guest with
an app that asks for the front camera, records its
AVCaptureVideoDataOutput frames with AVAssetWriter, and serializes the
device's exposure numbers to JSON:
libcamfix now:
- answers an empty front or back lookup (
+defaultDeviceWithDeviceType:mediaType:position:,AVCaptureDeviceDiscoverySession.devices) with the vcam device and reports itspositionas the side the process asked for last. A client that enumerates+devicesWithMediaType:and filters on position never says which side it wants; thepositionkey ofSimulatedCamera/camfix-video.plist(front/back) decides for it. - delivers data-output frames the way a real camera would for that session:
the preset's size (
AVCaptureSessionPreset640x480→ 640x480, 480x640 for a portrait connection), a centre crop of the upright shm frame, turned for 180 / 270 and mirrored when the connection is, in the output's pixel format (BGRA, or 420v for 420v / 420f), in IOSurface-backed buffers carrying an{Exif}attachment. The crop and size plan isCamFix/Frame/CamFixFrameGeometry.c, covered bymake -C VPhoneGuestComponents test-camfix-geometry. - answers the recommended writer settings with H.264 at that size.
- reports aperture 2.2, ISO 100, exposure 1/30 s and 1/30 s frame durations,
and stamps the same numbers, with a brightness value that follows the
frame's mean luma, into each delivered frame's
{Exif}.
With these, the app's front-camera session received frames, recorded and uploaded its clip, and its back-camera photo capture still worked in the same process. Limits: a 420f request gets 420v; delivery does not consult camera authorization (the fallback calls the delegate directly, as before), so a denied-permission path cannot be exercised; an iPhone 27.0 guest still publishes no synthetic source (until the change below).
iOS 27: the camera through the backings provider (2026-10-22)
Issue #707 reported that
on an iOS 27.0.1 guest the Camera shutter and Safari's "Save Image" did
nothing, and blamed missing photolibraryd, camerawriterd and
mediaassetd. None of the three exists on stock iOS 27 (checked on the
mounted 24A435 System volume; assetsd is the Photos writer and is
present). On photo707 (iPhone17,3, 27.0 24A435, default slimmed template,
bundle 2.10.1 from main) PhotoKit performChanges with an image, with
addResourceWithType:fileURL: and UIImageWriteToSavedPhotosAlbum all
saved, and so did Safari's "Save to Photos". The one real failure was the
Camera shutter: iOS 27 published no virtual camera at all, the open item
above.
Where iOS 27 publishes sources
_sSourceList is gone. -[FigCaptureSourceManager copySourcesWithType: clientAuditToken:clientIsBincompat:] makes each client's sources on
request: for every FigCaptureSourceBacking in
+[FigCaptureSourceBackingsProvider sharedCaptureSourceBackingsProvider] .sourceBackings it calls FigVideoCaptureSourceCreateFromBacking (video)
or FigAudioCaptureSourceCreateFromBacking (audio), after filtering on the
backing's attributes.deviceType and position. The shared manager itself
is made from the same array (+sharedCaptureSourceManager's once block ->
-initWithBackings:fineGrainThermalMonitoringEnabled:…). The provider keeps
the backings in an NSMutableArray *_sourceBackings, which
-_addBackingsForSourceInfoDictionaries: fills with
-initWithMediaType:attributes:cachedProperties:formats:missingFormatNames: synchronizedStreamUniqueIDs:unsynchronizedStreamUniqueIDs: — the same
initializer vcc_build_backing() already used for 26.x, which wraps the
attributes dictionary in FigCaptureSourceAttributes. iOS 26.6.2 (23G90)
has the provider class but no FigCaptureSourceManager, and still serves
_sSourceList.
The change
VCamCaptured/Synthetic/VCamProviderSource.m: whereFigCaptureSourceManagerexists, the synthetic backing is appended to the provider's_sourceBackingsonce, when the microphone hook (VCamMicrophoneSource.m) builds the provider the daemon could not. That happens at the daemon's first source query, before the source manager is made.vcc_install_synthetic(the_sSourceListpath) returns early there.CamFix/Photo/CamFixCapturePhoto.m: Camera.app on 27 takes photos with-capturePhotoWithSettings:delegate:, not the moment-capture trio. When the delegate is itsCAMCaptureEngine(it answers_resultsQueueRegisteredStillImageRequests; the ivar is__resultsQueueRegisteredStillImageRequests), the hook now runs the moment-capture driver: the engine's request for the settings' uniqueID, then will/did/finish for it. With onlydidFinishProcessingPhoto:the engine filed the photo under captureID 0, logged "Have not received final callback", and saved nothing. Other delegates keep the single callback.CamFix/Session/CamFixSessionGuards.m: theisRunninglie made Camera.app log "Session is already running. Skipping call to startRunning", so_setRunning:YES, which started the preview-layer scan, never came and the viewfinder stayed black. The lie now starts the scan too.
The daemon only parses the session configuration (cameraCfgs.count=1,
"Wide back BGRA/1280x720"); it never builds a graph or asks the vendor for
the device, so preview and photos come from libcamfix in the client, as
the hook was designed.
Validation
On photo707 (iPhone17,3, 27.0 24A435):
- With a 1280x720 BGRA test frame written into the shared frame file,
Camera.app showed the frame in its viewfinder, and each shutter press saved
a 1280x720 JPEG of it to
DCIM/100APPLEwith the library thumbnail updated (three in a row). - With the host streaming its test pattern (Features -> Source: Test Pattern -> Start Streaming), the viewfinder moved with the stream and the shutter saved the streamed frame.
- A third-party app got
vphone:vcam:0from+defaultDeviceWithMediaType:, ran anAVCaptureSessionand received a 24,635-byte photo fromAVCapturePhotoOutput. - Voice Memos recorded and saved a 14 s memo with the camera backing in the provider.
- No crash reports from Camera, cameracaptured or Voice Memos.
On cam26 (iPhone17,3, 26.6.2 23G90), the source still goes into
_sSourceList (appended source — list 0 -> 1; no provider line). Camera.app
there also shoots with -capturePhotoWithSettings:delegate:: with the
previous libcamfix the shutter saved nothing, with this one it saved the
streamed frame, through the same callback sequence.
Camera.app video recording did not work on either at this point; see the next section.
Restarting vphoned (or replacing the shared frame file) leaves the host streaming into the old mapping: the host notices a dead connection only when a send fails. Restart the VM, then start streaming again.
Camera.app video recording (2026-10-22)
Camera.app's VIDEO mode records with -[AVCaptureMovieFileOutput startRecordingToOutputFileURL:recordingDelegate:] into
DCIM/.MISC/Incoming/<id>.MOV and stops with -stopRecording; its
CAMCaptureEngine saves the file once the delegate gets
-captureOutput:didFinishRecordingToOutputFileAtURL:fromConnections:error:.
A vcam session's graph never runs, so the output never started: the timer
stayed at 00:00:00, no file was written, and with the request still open the
engine refused photos until Camera.app was relaunched.
CamFix/Movie/CamFixMovieRecording.m takes both calls for a vcam-bound
movie output and never calls the original:
- A recorder on its own queue writes the shm frame at 30 Hz with AVAssetWriter: H.264, 1280x720 (the frame as photos get it), in a QuickTime movie at the requested URL, timed from the first frame.
- After the first frame the delegate gets
-captureOutput:didStartRecordingToOutputFileAtURL:fromConnections:; after-stopRecording, once the writer has finished, the didFinish callback with no error (or with one if the writer failed or no frame arrived). -isRecording,-recordedDurationand-outputFileURLanswer for the recorder while it runs.- When the session has an audio input the daemon records no sound either, so an AVAudioEngine input tap records the microphone into an AAC track, timed on the same uptime clock as the frames. The tap's buffer is copied into a CMSampleBuffer on the tap's thread, since the engine reuses it.
iOS 26.6.2's Camera.app lists VIDEO only when -[CAMCaptureCapabilities isVideoSupported] is set. -initWithHostProcess: fills the _videoSupported
ivar behind it from AVGestaltGetBoolAnswer(AVGQVideoCameraCapability),
which is NO on a guest, and other CameraUI code reads the ivar directly. The
hook sets the ivar after the initializer when the vcam device exists. iOS 27
dropped the ivar: -[CAMUserPreferences supportedCaptureModesForCapabilities: emulatingImagePicker:] always adds photo and video, and
-isSupportedMode:withDevice: answers both with the camera. The hook does not
install there.
Validation, with the host streaming its test pattern:
No Camera, cameracaptured or assetsd crash reports on either. Not done: the
movie is landscape with no rotation metadata, like the photos; pause/resume,
slo-mo, time-lapse and cinematic modes; -startRecordingMovieWithSettings:
(the moment-capture movie path), which Camera.app did not call here.
Session registration during teardown (2026-10-29)
AVFoundation calls the virtual-camera isRunning getter while removing inputs
in AVCaptureSession dealloc. Registering that dying session in the weak table
aborts in objc_storeWeak → weak_register_no_lock.
Keep tracking in the getter: some clients skip startRunning when it returns
YES. cfx_load_live_session uses objc_storeWeakOrNil and an ARC weak load to
skip dying sessions and hold live ones through insertion. Its argument remains
__unsafe_unretained so entry does not retain an object already in deallocation.
Reproduce with make -C VPhoneGuestComponents build-camfix-session-reproducer.
Install .build/siblings/camera-session-reproducer/SessionProbe.ipa and run
org.example.vphone.SessionProbe. It creates and releases 100 camera sessions,
observing running on alternate cycles, without starting capture.
On iOS 27.0.1 (24A446), the stock 2.13.4 camera libraries abort; changing only libcamfix completes all 100 cycles. Camera.app also saved photos and an 8.32 s 1280×720 H.264 movie with 251 distinct decoded frames, then reopened and saved another photo without a crash report. These checks cover one firmware pairing. The VPhone Debug build and bundle admission passed with Xcode 27.2.
make -C VPhoneGuestComponents test-camfix-session-reference checks nil, live
and deallocating sessions, plus release after weak-table insertion. CI runs it
with the other guest-component tests.