NaturalCam

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.

Tutorial 03 · System integration

14 August 2026

About 11 minutes

AppIntents · iOS 18+

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.

The short version

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.

Nothing to buy, enable or wait for

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:

What the system does with the button you place.
Intent typeUnlockedLocked
AppIntent + openAppWhenRunOpens the appAsks for an unlock
CameraCaptureIntentOpens the appOpens 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:

The full set, in the order worth checking.
CheckWhere
Extension point com.apple.securecaptureExtension Info.plist
CFBundlePackageType is XPC!Extension Info.plist
Own NSCameraUsageDescriptionExtension Info.plist
Bundle ID prefixed by the app'sBuild settings
Embedded at .app/Extensions/Built product
Camera permission granted to the appDevice
A CameraCaptureIntent existsCode
That intent compiled into all three targetsProject

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.

What we got wrong, so you do not have to

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.