Skip to content

refactor(camera): manage a device-wide feed without app resets - #148

Open
gwdp wants to merge 18 commits into
expofrom
gwdp/camera-refactor
Open

gwdp wants to merge 18 commits into
expofrom
gwdp/camera-refactor

Conversation

@gwdp

@gwdp gwdp commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

ENG-26998

Why

The camera is a device-wide capability. Enabling or disabling its feed should behave like connecting or disconnecting camera hardware while apps and SpringBoard continue running.

How

  • Load the camera through the capability loader for all apps, including apps opened outside serve-sim.
  • Enable connects the selected feed; Disable disconnects it without restarting apps or changing camera permissions.
  • Split camera commands, helper lifecycle, media handling, and UI into focused modules.

Test Plan

  • CI
  • Tested image feeds and Enable/Disable on EAS Simulator at the tip of the stack.

Devin Review

@gwdp
gwdp added this pull request to stack #150 September 11, 2026 17:07
@gwdp gwdp changed the title gwdp/camera refactor refactor(camera): manage a device-wide feed without app resets Sep 11, 2026
@gwdp
gwdp marked this pull request as ready for review September 11, 2026 17:15
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch from 25777f8 to d791f67 Compare September 11, 2026 18:48
@linear-code

linear-code Bot commented Sep 11, 2026

Copy link
Copy Markdown

ENG-26327

ENG-26998

@gwdp
gwdp force-pushed the gwdp/camera-refactor branch 2 times, most recently from 37a3d58 to 5e2a013 Compare September 11, 2026 19:37
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch from 5e2a013 to d4507d6 Compare September 12, 2026 00:52
@gwdp
gwdp removed this pull request from stack #150 September 14, 2026 16:40
@gwdp
gwdp added this pull request to stack #160 September 14, 2026 16:40
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch 2 times, most recently from cda5b25 to d09d1fa Compare September 21, 2026 22:07
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch 3 times, most recently from 661f259 to 9ef6db4 Compare September 21, 2026 23:06
Base automatically changed from gwdp/trampoline-c-tests to expo September 21, 2026 23:07
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch 2 times, most recently from 56a6098 to bb31f75 Compare September 21, 2026 23:42
szdziedzic added a commit that referenced this pull request Sep 22, 2026
gwdp pointed out that #148, #102, and #53 import setCapabilityEnabled.
Restore it and its only callee with a @public tag that names those PRs,
so knip stays clean and the first PR to land makes them live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
szdziedzic added a commit that referenced this pull request Sep 22, 2026
gwdp pointed out that #148, #102, and #53 import setCapabilityEnabled.
Restore it and its only callee with a @public tag that names those PRs,
so knip stays clean and the first PR to land makes them live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
szdziedzic added a commit that referenced this pull request Sep 22, 2026
* Run knip in CI and delete the dead code it found

knip was configured but never ran in CI, so unused exports piled up.
Tune the config so test files count as entry points, exports used only
in their own file are not reported, and the Swift-only node-swift
dependency is declared. Then delete what remains: five unused response
helpers in middleware-utils, an unused capability toggle and its only
caller, legacy device-frame constants, and three unused client helpers.
Add a Knip job to the EAS Checks workflow and to `bun run check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Say that check runs knip too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep setCapabilityEnabled and capabilityDefinition for open PRs

gwdp pointed out that #148, #102, and #53 import setCapabilityEnabled.
Restore it and its only callee with a @public tag that names those PRs,
so knip stays clean and the first PR to land makes them live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
szdziedzic added a commit that referenced this pull request Sep 22, 2026
* Run knip in CI and delete the dead code it found

knip was configured but never ran in CI, so unused exports piled up.
Tune the config so test files count as entry points, exports used only
in their own file are not reported, and the Swift-only node-swift
dependency is declared. Then delete what remains: five unused response
helpers in middleware-utils, an unused capability toggle and its only
caller, legacy device-frame constants, and three unused client helpers.
Add a Knip job to the EAS Checks workflow and to `bun run check`.



* Say that check runs knip too



* Keep setCapabilityEnabled and capabilityDefinition for open PRs

gwdp pointed out that #148, #102, and #53 import setCapabilityEnabled.
Restore it and its only callee with a @public tag that names those PRs,
so knip stays clean and the first PR to land makes them live.



---------


(cherry picked from commit df029c8)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@gwdp

gwdp commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

/devin review

@gwdp
gwdp requested a review from szdziedzic September 23, 2026 20:51
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch from bb31f75 to 45a2030 Compare September 23, 2026 20:51

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 5 potential issues.

Devin Review

Comment thread packages/serve-sim/src/index.ts

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Validation evidence needs follow-up

The native test evidence omits the simulator and Xcode version. The CLI and UI changes also lack command output and visual evidence required by the review guide.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Change exceeds review scope guidance

This PR combines native lifecycle, wire format, CLI, ownership, and UI changes across roughly 2,700 lines. The contribution guidance requests one behavior change under roughly 400 lines.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/serve-sim/src/camera-command.ts Outdated
Comment on lines 102 to +106
const reply = await fetchCameraStatus();
if (cancelled) return;
const alive = !!reply?.alive;
const replyBundles = Array.isArray(reply?.bundleIds) ? reply.bundleIds : null;
const foregroundIsInjected =
!!bundleId && (replyBundles ? replyBundles.includes(bundleId) : injectedBundleIds.has(bundleId));
const replySource = reply?.source ?? null;
const replyHasRealSource = replySource && replySource !== "placeholder";
const attachedToCurrentHelper =
injected && alive && foregroundIsInjected && !!replyHasRealSource
&& (attachedHelperPid == null || reply?.helperPid === attachedHelperPid);
setPillState((prev) => nextCameraPillState(prev, attachedToCurrentHelper));
if (!alive) {
setInjected((prevInjected) => {
if (!prevInjected) return prevInjected;
setInjectedBundleIds(new Set());
setAttachedHelperPid(null);
appliedMirrorRef.current = "off";
return false;
});
} else if (injected && attachedHelperPid != null && reply?.helperPid !== attachedHelperPid) {
setInjected(false);
setInjectedBundleIds(new Set());
setAttachedHelperPid(null);
appliedMirrorRef.current = "off";
} else if (alive && Array.isArray(reply?.bundleIds)) {
const next = reply.bundleIds;
setInjectedBundleIds((prev) => {
if (prev.size === next.length && next.every((b) => prev.has(b))) return prev;
return new Set(next);
});
}
setPillState((prev) => nextCameraPillState(prev, alive));
setEnabled(alive);

@devin-ai-integration devin-ai-integration Bot Sep 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Active status tracks only helper liveness

The poll maps reply.alive directly to Active. It cannot distinguish a running helper from a published and connected camera capability.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@gwdp
gwdp force-pushed the gwdp/camera-refactor branch from 45a2030 to ccfade7 Compare September 23, 2026 20:57

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 new potential issues.

Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Rollback plan is missing

The persisted ownership schema and shared-memory protocol both change, but the PR provides no rollback guidance required by AGENTS.md.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/serve-sim/src/camera-runtime.ts
Comment thread packages/serve-sim/src/camera-runtime.ts
Comment thread packages/serve-sim/src/camera-runtime.ts Outdated
@gwdp
gwdp force-pushed the gwdp/camera-refactor branch from 99bceac to 80b6a8a Compare September 23, 2026 23:07

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 4 new potential issues.

Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Change scope lacks unaffected-device evidence

This PR combines camera, CoreMotion, lifecycle, CLI, and UI changes. It provides no required tap, drag, and scroll evidence for unaffected devices.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread packages/serve-sim/README.md
Comment thread packages/serve-sim/Sources/SimCameraInjector/SimCamSwizzles.m Outdated
Comment thread packages/serve-sim/src/camera-runtime.ts
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +404 to +407
BOOL undecided = !SimCamSessionHasFakeCamera(self);
if (undecided) SimCamPromoteOpenConfigToNative(self);
[self simcam_addInput:input];
if (undecided) [self simcam_handOverHeldOutputs];

@devin-ai-integration devin-ai-integration Bot Sep 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Mixed camera sessions lose microphone samples

When a fake-camera session adds an audio output before its microphone input, undecided prevents handing the output to AVFoundation. simcam_addOutput only tracks that output, so its audio delegate receives no microphone samples.

Learn more

Outputs on a session without a native input are stored in simcam_addOutput instead of being added to AVFoundation. When a fake camera is present, the new gate leaves these outputs tracked after a real microphone input arrives. The fake output registry registers only video data delegates, so an audio data output cannot receive native microphone samples. The same gate exists in simcam_addInputWithNoConnections.

Example: An app adds a fake video input, an AVCaptureAudioDataOutput, and then an AVCaptureDeviceInput for its microphone. The audio output remains in SimCamSessionTrackedOutputs; recording receives no audio buffers, although the microphone input was added.

Recommended fix: When a real input joins a fake-camera session, move held audio outputs into AVFoundation while retaining fake video outputs in the synthetic session. Handle both real-input methods and preserve outputs added without connections.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

Devin Review

alive = [NSHashTable hashTableWithOptions:
NSPointerFunctionsStrongMemory | NSPointerFunctionsObjectPointerPersonality];
});
@synchronized (alive) { [alive addObject:connection]; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Camera connections accumulate after output teardown

When apps repeatedly create camera outputs, SimCamKeepFakeConnectionAlive retains each connection forever. Releasing an output cannot remove its connection from alive, so memory grows throughout the app's lifetime.

Learn more

A fake connection is created once per output by SimCamFakeConnectionForOutput. The output retains it through an associated object, but this table retains it independently. After the output deallocates, the connection's weak output reference clears while the table keeps the connection for the rest of the app process. Repeated creation and disposal of capture outputs therefore grows the table even when no camera session remains.

Example: An app opens and closes a camera screen 1,000 times, creating a new video output each time. The 1,000 abandoned connections remain in alive; the expected retained count after teardown is zero.

Recommended fix: Give SimCamFakeConnection a safe teardown path that avoids the uninitialized superclass state, then remove the permanent strong table and let its associated output own it. Verify output deallocation and repeated capture-session creation on the supported simulator runtime.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

Devin Review

Comment on lines +554 to +558
if (!SimCamSessionHasFakeCamera(self)) {
[self simcam_handOverHeldOutputs];
objc_setAssociatedObject(self, &kSimCamSessionFakeRunKey, @NO, OBJC_ASSOCIATION_RETAIN);
[self simcam_startRunning];
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Native photo captures become synthetic

After device-wide loading, simcam_capturePhotoWithSettings: intercepts photo captures without checking whether the output belongs to a fake session. Native photo outputs receive synthetic images, even while the fake camera is disconnected.

Learn more

The injector now loads into every eligible app, including apps using native AVFoundation sessions. simcam_capturePhotoWithSettings: always synthesizes a photo without checking whether its output is attached to a fake session. The new native-session branch does not protect photo capture, because capture calls go directly to the output swizzle.

Example: An app with a native photo output calls capturePhotoWithSettings: after the feed is disabled. Instead of using AVFoundation's native capture, the output delivers the synthetic black fallback photo.

Recommended fix: Gate photo-output interception on attachment to a fake session and forward other captures to the original implementation. Apply the same native-session gate to other globally swizzled output operations that manufacture fake results.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +559 to +560
if (gShmHeader && (!atomic_load_explicit(&gShmHeader->active, memory_order_acquire) ||
kill((pid_t)gShmHeader->ownerPid, 0) != 0)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Zombie helper leaves camera connected

If the helper dies unreaped, SimCamRefreshDevice treats its PID as alive and never disconnects the old mapping. Apps keep the stale camera and cannot connect to a restarted helper.

Learn more

An unexpected helper exit cannot clear the shared header's active flag. A zombie process still passes kill(pid, 0) until reaped, so the monitor leaves its old shared-memory mapping attached and never checks the newly named region. hasProcessExited already handles this condition for host-side helper management.

Example: The helper is killed, remains a zombie, and a new helper opens the same shared-memory name. The injected app continues treating the old mapping as connected and misses the new feed.

Recommended fix: Detect zombie state as well as PID existence on the injected side, or use a liveness signal that does not depend on reaping; ensure the monitor closes the old mapping before retrying attachment.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

OBJC_ASSOCIATION_RETAIN);
SimCamSetPosition(output, SimCamPositionOf(self));
SimCamMarkOutputAttachedToFakeSession(self, output);
SimCamRegisterFakeVideoOutput(output);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unstarted camera sessions receive samples

When an output already has a delegate, SimCamRegisterFakeVideoOutput registers it before startRunning. The pump ignores session running state and sends samples to an unstarted session.

Learn more

SimCamRegisterFakeVideoOutput adds outputs immediately when they already have a delegate. startPumpingIfNeeded starts on registration, and its delivery loop checks connection generation but not whether the owning session is running. The new registration path thus delivers samples before the app starts capture.

Example: An app assigns a sample delegate, adds the output, and pauses before calling startRunning. The delegate receives synthetic samples during the pause.

Recommended fix: Track the owning fake session and gate queued delivery on its running state; also stop or unregister delivery when that session stops.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

This branch has not been deployed

No deployments
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