NaturalCam/Tutorials/Camera Control
Get your camera app into iOS's Camera Control list
Shipping a LockedCameraCapture extension is necessary and
not sufficient. This is the rest of it — what
CameraCaptureIntent actually does, why the intent has to be
compiled into three targets, how to pass settings to an
extension that shares nothing with your app, and
the entitlement that does not exist.
On an iPhone 16 there is a list at Settings › Camera › Camera Control › Launch Camera, and for a long time ours read Camera, Claude, Code Scanner, Instagram, Magnifier. Every guide says the same thing: ship a Locked Camera Capture extension and you will appear in it. We shipped one. We did not appear in it.
What follows is the part that is not written down anywhere, including the three dead ends that cost a day. If you are here because your extension builds, embeds, installs, and the list still does not name you, skip to the intent — that is almost certainly your answer.
1. The extension is what makes you eligible. Without one, iOS will not offer you at all.
2. A CameraCaptureIntent is what makes you
reachable. A plain AppIntent can only foreground
the app, and foregrounding needs an unlocked phone — which is the one
thing the Lock Screen cannot offer.
3. That intent must be compiled into the app, the widget extension and the capture extension. The system matches it by type across all three. A missing copy is a Control that never resolves — and it fails silently.
Three routes, one extension
The Lock Screen's camera button, a press of the Camera Control on a locked
phone, and the entry in that Settings list are not three features. They are
one extension, reached three ways. Adopt
LockedCameraCaptureExtension and all three light up together;
skip it and none of them can.
import ExtensionKit
import LockedCameraCapture
import SwiftUI
@main
struct NaturalCamCaptureExtension: LockedCameraCaptureExtension {
var body: some LockedCameraCaptureExtensionScene {
LockedCameraCaptureUIScene { session in
LockedCaptureRootView(directory: session.sessionContentURL)
}
}
}
Apple's template hands you a UIImagePickerController here. Take
it and you have put the system camera behind your own name, which is the
opposite of the point. We show the app's own viewfinder — the same
CameraView, told that the photo library is out of reach and
that the shot goes into the session's directory instead.
The entitlement that does not exist
Search for why your extension is not being offered and you will find advice
to add com.apple.developer.locked-camera-capture to its
entitlements. Do not. Xcode will tell you why:
Entitlement com.apple.developer.locked-camera-capture not found and could not be included in profile. This likely is not a valid entitlement and should be removed from your entitlements file.
Apple's own DTS confirms it in the developer forums: the entitlement is not real. There is no matching capability in Xcode's Signing & Capabilities, and none in the developer portal, because there is nothing to enable. You can check this yourself — iOS ships the definition of the extension point, and it is readable inside any simulator runtime:
$ plutil -p ".../RuntimeRoot/System/Library/ExtensionKit/\
ExtensionPoints/com.apple.securecapture.appexpt"
{
"com.apple.securecapture" => {
"EXExtensionPointIsPublic" => true
"EXRequiredEntitlements" => {
"com.apple.private.capture-extension-host" => true
}
"EXRequiresSceneHosting" => true
"EXSandboxProfileName" => "secure-capture-extension"
}
}
EXExtensionPointIsPublic is true: third parties
may implement this. The one entitlement listed,
com.apple.private.capture-extension-host, is required of the
host — iOS's own process that hosts your extension — not of you.
There is no capability, no entitlement, no App Store Connect setting and no approval to request. You also do not need to publish: extension registration happens at install time and does not care whether the signature is development or App Store. A local build from Xcode registers exactly the same way a TestFlight build does.
CameraCaptureIntent: the piece that was missing
Here is the distinction that costs people a day. A Control on the Lock Screen carries an intent, and which kind of intent decides which process iOS runs:
| Intent type | Unlocked | Locked |
|---|---|---|
AppIntent + openAppWhenRun | Opens the app | Asks for an unlock |
CameraCaptureIntent | Opens the app | Opens the capture extension |
A plain AppIntent is not wrong so much as incapable: the only
thing it can do is foreground the app, and that requires the phone to be
unlocked. CameraCaptureIntent is the system's own
launch a camera hook, and it is the same hook the Camera Control
button uses. The whole implementation is this short:
import AppIntents
struct NaturalCamCaptureIntent: CameraCaptureIntent {
typealias AppContext = CaptureContext
static let title: LocalizedStringResource = "Open Camera"
static let description = IntentDescription(
"Opens NaturalCam's viewfinder, ready to shoot."
)
@MainActor
func perform() async throws -> some IntentResult {
.result()
}
}
perform() does nothing, and that is correct. It runs only in
the app's process and only when the device is already unlocked — the app
restores its own look from its own preferences, and the extension reads the
context when it launches. The intent's job is to exist and to
declare a type.
The Control then carries it instead of a plain intent:
@main
struct NaturalCamControls: ControlWidget {
// Frozen, and it has to stay that way. iOS remembers a placed Control by
// this string — changing it does not move the Control, it makes the
// existing one stop resolving and quietly vanish off the Lock Screen of
// everyone who had put it there.
static let kind = "com.solutionforest.NaturalCam.OpenCamera"
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: Self.kind) {
ControlWidgetButton(action: NaturalCamCaptureIntent()) {
Label("NaturalCam", systemImage: "camera")
}
}
.displayName("NaturalCam")
.description("Open the viewfinder, ready to shoot.")
}
}
The three-target rule
This is the one that fails silently, so it is worth stating plainly: the intent type must be compiled into all three targets — the app, the widget extension that draws the Control, and the capture extension. The system discovers it by matching the type across the three processes. A missing copy is not a compile error; it is a Control that never resolves and a list that never names you.
In an XcodeGen project that is a source entry per target. Note that the widget takes the intent and the context type only — an extension that draws a single button must not drag a camera framework in behind it:
NaturalCamControls:
type: app-extension
sources:
- Sources/Intents/OpenCameraIntent.swift
- Sources/Intents/NaturalCamCaptureIntent.swift
# Only the raw context, not the mapping beside it: the widget must not
# drag the rest of Core into an extension that draws one button.
- Sources/Core/CaptureContext.swift
- Sources/Widget
NaturalCamCapture:
type: extensionkit-extension
sources:
- path: Sources
excludes: [Widget, Intents, App/NaturalCamApp.swift]
# The capture intent has to be present in all three targets or the
# system never discovers it. The rest of Intents stays out: App
# Shortcuts are the app's alone.
- path: Sources/Intents/NaturalCamCaptureIntent.swift
Talking to an extension that shares nothing
A locked capture extension is sandboxed hard. While it is active it has
no network access, cannot read or write the App Group's
shared container, and its own container is erased when the system suspends
it. No UserDefaults, no files, no App Group. Which raises an
obvious problem: the Lock Screen viewfinder would open on factory defaults,
ignoring every choice the user made in the app.
The intent's AppContext is the one channel that survives. It is
small — treat 4 kB as the ceiling — so it carries the settings that
decide what the viewfinder looks like and nothing else:
struct CaptureContext: Codable, Equatable, Sendable {
/// The film look to open on, as a profile `id`.
var profileID: String
/// Raw values of `Handedness`, `DialSize`, `DialReach` and
/// `SelfTimerDuration`. Stored as strings/ints so this type stays
/// Foundation-only.
var handednessRaw: String
var dialSizeRaw: String
var dialReachRaw: String
var selfTimerRaw: Int
}
The raw values are deliberate. This file is the only piece of the shared
core the widget extension compiles, and it must not pull
CoreGraphics and the film profiles in behind it. The mapping
back to the real enums lives in a separate file that the app and the capture
extension compile and the widget does not.
The app writes the context when it goes to the background — the moment the settings are final and the extension might be next to run:
.onChange(of: scenePhase) { _, phase in
if phase != .active { syncCaptureContext() }
}
private func syncCaptureContext() {
let context = CaptureContext( /* current settings */ )
Task { try? await NaturalCamCaptureIntent.updateAppContext(context) }
}
And the extension reads it on launch. The read is asynchronous and the view is already on screen by the time it lands, so hold it in state and pass it down rather than reading it inside the shared view:
private struct LockedCaptureRootView: View {
let directory: URL
@State private var context: CaptureContext?
var body: some View {
CameraView(
chrome: .locked,
destination: SessionContentDestination(directory: directory),
captureContext: context
)
.task {
guard context == nil else { return }
context = try? await NaturalCamCaptureIntent.appContext
}
}
}
What the Info.plist needs
An ExtensionKit extension declares its point in
EXAppExtensionAttributes, not in the older
NSExtension dictionary. XcodeGen only fills the latter in by
itself, so this one is written out:
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>
<string>com.apple.securecapture</string>
</dict>
<!-- An extension is asked for the camera in its own right;
the app's usage string does not cover it. -->
<key>NSCameraUsageDescription</key>
<string>NaturalCam captures Bayer RAW photos with the camera.</string>
Two things bite here. Without CFBundlePackageType you have a
bundle iOS does not recognise as an extension at all. And the extension
needs its own NSCameraUsageDescription — it is a separate
process being asked for the camera in its own right, even though it
inherits the permission decision from the app. If the app has never
been granted camera access, iOS opens the app instead of the extension.
Finally, the extension's bundle identifier has to sit under the app's. The system pairs an extension to its host by prefix, and a mismatch is rejected at install:
com.example.MyCamera ← app com.example.MyCamera.Capture ← capture extension
If the list still does not name you
Every one of these is necessary. We know, because we were missing the last one and everything else looked correct:
| Check | Where |
|---|---|
Extension point com.apple.securecapture | Extension Info.plist |
CFBundlePackageType is XPC! | Extension Info.plist |
Own NSCameraUsageDescription | Extension Info.plist |
| Bundle ID prefixed by the app's | Build settings |
Embedded at .app/Extensions/ | Built product |
| Camera permission granted to the app | Device |
A CameraCaptureIntent exists | Code |
| That intent compiled into all three targets | Project |
Two habits that saved us once we adopted them. Verify what is actually in the built bundle rather than what you believe you configured:
$ find MyApp.app -name "*.appex" MyApp.app/PlugIns/MyControls.appex MyApp.app/Extensions/MyCapture.appex $ plutil -p MyApp.app/Extensions/MyCapture.appex/Info.plist
And when the answer is still not obvious, read the device's own log rather
than guessing: Console.app → your iPhone → filter
securecapture, then reinstall. iOS says outright
whether it registered your extension or rejected it, and why.
Adding the entitlement. It does not exist. It broke signing and cost half a day.
Hunting for the capability. There is no Locked Camera Capture in Xcode's capability list, because there is nothing to add.
Assuming publishing was required. It is not — a development build registers the extension the same way.
The actual answer was the intent, and the three targets that all have to compile it.
Where this lives in NaturalCam
Every listing above is code that ships. If you would rather read it in place:
Sources/Intents/NaturalCamCaptureIntent.swift the intent Sources/Widget/NaturalCamControls.swift the Control that carries it Sources/Capture/NaturalCamCaptureExtension.swift the extension entry point Sources/Capture/Info.plist the plist keys above Sources/Core/CaptureContext.swift the shared context project.yml the three-target wiring
NaturalCam is a camera that takes one Bayer RAW frame from one locked physical lens and develops it with Apple's processing switched off. The Lock Screen viewfinder is the same viewfinder — which is the entire reason it was worth adopting the extension rather than handing the system camera our name.