Supporting Continuity Camera in your macOS app
At a glance
| Item | Summary |
|---|---|
| Purpose | Use an iPhone as a macOS camera/microphone, follow system-preferred device changes, and expose supported video effects. |
| App architecture | ContentView owns one main-actor Camera observable object that combines capture-session ownership, device selection, and UI-facing state. |
| Main patterns | MVVM-style observable model, Combine/KVO observers, NSViewRepresentable preview adapter. |
| Project style | Small flat model/observer layer plus a Views folder; no separate service or protocol abstraction. |
Project structure
ContinuityCam/
├── ContinuityCamApp.swift
├── ContentView.swift
├── Camera.swift
├── DeviceObservers.swift
└── Views/
├── CameraPreview.swift
├── ConfigurationView.swift
└── MaterialView.swift
Camera.swift includes its supporting Device and VideoFormat value types and format extension. The split is by UI, camera model, and observation concern rather than a deep layer hierarchy.
Overall architecture
flowchart LR
App["ContinuityCamApp"] --> View["ContentView"]
View --> Camera["Camera ObservableObject"]
Camera --> Session["AVCaptureSession"]
Camera --> Preferred["PreferredCameraObserver"]
Camera --> Effects["VideoEffectsObserver"]
Camera --> Preview["CameraPreview adapter"]
Config["ConfigurationView"] --> Camera
Reference code
ContinuityCam/ContentView.swift:10 — the root view creates one camera model and supplies its preview/configuration projections (abridged).
struct ContentView: View {
@StateObject var camera = Camera()
var body: some View {
HStack(spacing: 0) {
camera.preview
.onAppear { Task { await camera.start() } }
ConfigurationView(camera: camera)
}
}
}The source combines model and service responsibilities in Camera. “MVVM-style” is an inference from a view-owned observable model, not an explicit project label.
Ownership and state
classDiagram
ContentView *-- Camera
Camera *-- AVCaptureSession
Camera *-- PreferredCameraObserver
Camera *-- VideoEffectsObserver
Camera *-- CameraPreview
Camera --> Device : publishes selections
Camera --> VideoFormat : publishes selections
Ownership evidence
ContinuityCam/Camera.swift:29 — session and observer ownership sits inside Camera (abridged).
private(set) var isSetup = false
private(set) var isAuthorized = false
private(set) var isRunning = false
private let session = AVCaptureSession()
private let preferredCameraObserver = PreferredCameraObserver()
private let videoEffectsObserver = VideoEffectsObserver()
private var subscriptions = Set<AnyCancellable>()| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
ContentView |
Camera |
@StateObject, retained for view lifetime |
Camera mutates its published state. |
Camera |
Capture session and inputs | Creates and reconfigures | Private setup/device-selection methods. |
Camera |
Observer objects and subscriptions | Creates and retains | Observers publish KVO changes; Camera maps them into UI state. |
CameraPreview |
Preview layer | Adapter receives the private session | Nested CaptureVideoPreview configures its backing layer once. |
Class and protocol design
classDiagram
Camera --> PreferredCameraObserver : subscribes
Camera --> VideoEffectsObserver : subscribes
PreferredCameraObserver --> AVCaptureDevice : KVO
VideoEffectsObserver --> AVCaptureDevice : KVO
CameraPreview ..|> NSViewRepresentable
Reference code
ContinuityCam/Camera.swift:97 — KVO bridge objects feed the observable model through Combine (abridged).
preferredCameraObserver.$systemPreferredCamera
.dropFirst().removeDuplicates().compactMap({ $0 })
.sink { [weak self] captureDevice in
guard let self = self, self.isSetup else { return }
Task { await self.systemPreferredCameraChanged(to: captureDevice) }
}.store(in: &subscriptions)
videoEffectsObserver.$isCenterStageEnabled
.receive(on: RunLoop.main)
.assign(to: \.isCenterStageEnabled, on: self)
.store(in: &subscriptions)There is no app-defined protocol boundary. Instead, small observer classes bridge static AVCaptureDevice KVO into Combine, while CameraPreview bridges AppKit into SwiftUI.
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
| Capture session, inputs, discovery sessions | private |
Views cannot mutate the capture graph. | Keeps configuration inside one owner. |
state, device arrays, effect support |
@Published private(set) |
Views observe but cannot publish derived system state. | Makes Camera the source of truth. |
| Selected device/format properties | internal writable @Published |
Configuration UI can bind directly and trigger model subscriptions. | Deliberate two-way UI intent boundary. |
| Observer KVO results | @Published private(set) |
Only KVO callbacks update system-derived values. | Prevents consumers from simulating framework state. |
CameraPreview.session |
private let |
Session is only used during native-view construction. | Limits the adapter to rendering. |
ContinuityCam/DeviceObservers.swift:41:
class PreferredCameraObserver: NSObject, ObservableObject {
private let systemPreferredKeyPath = "systemPreferredCamera"
@Published private(set) var systemPreferredCamera: AVCaptureDevice?
}Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Authorization, discovery, session input changes | Camera |
One main-actor owner coordinates capture and published selections. |
| Preferred-camera/effect KVO | DeviceObservers.swift |
Keeps Objective-C observation mechanics out of the model’s main flow. |
| Device/format controls | ConfigurationView |
SwiftUI binds to the model’s explicit writable selections. |
| AppKit preview layer | CameraPreview |
NSViewRepresentable isolates native layer setup. |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| Observable model | ContinuityCam/Camera.swift:12 |
One source of UI-facing capture state; also carries service responsibilities. |
| Observer bridge | ContinuityCam/DeviceObservers.swift:20 |
Converts static KVO properties into typed published values. |
| Adapter | ContinuityCam/Views/CameraPreview.swift:10 |
Embeds an AVCaptureVideoPreviewLayer in SwiftUI. |
| Reactive intent | ContinuityCam/Camera.swift:109 |
Published selections trigger reconfiguration through subscriptions. |
Naming conventions
- Role nouns are direct:
Camera,CameraPreview,ConfigurationView,PreferredCameraObserver. - Boolean state uses
is...Enabled,is...Supported,isSetup, andisRunning. - Methods distinguish setup (
setupInputs) from selection (selectCaptureDevice) and derived-state updates (updateVideoEffectsState). - Primary SwiftUI/AppKit adapters live under
Views; model-supporting value types remain withCamera.
Architecture takeaways
- A small sample can use one observable camera owner without a separate service layer when capture scope is narrow.
- Use
private(set)for system-derived UI state and writable published properties only for real user intent. - Isolate KVO and native-view bridging in small adapter objects/files.
- The sample demonstrates preview and configuration; it does not implement photo/movie output persistence.
Source map
| Source file | Relevant symbols |
|---|---|
ContinuityCam/ContentView.swift |
Root camera ownership |
ContinuityCam/Camera.swift |
Session, published state, device selection |
ContinuityCam/DeviceObservers.swift |
KVO-to-Combine bridges |
ContinuityCam/Views/CameraPreview.swift |
SwiftUI/AppKit preview adapter |
ContinuityCam/Views/ConfigurationView.swift |
User intent bindings |