camera: macOS port via darwin/ split (no shared-file pragmas)

Reuse the AVFoundation Swift files between iOS and macOS without
sprinkling `#if canImport(UIKit)` through them. The split is:

  darwin/Camera/                 platform-shared (AVFoundation only)
    CameraPlugin                 channel + instance map
    CameraInstance               session + outputs + texture
    CameraSession                AVCaptureSession + runtime-error obs
    CaptureDevice                front/back discovery
    PhotoOutput                  AVCapturePhotoOutput
    PreviewSink                  CVPixelBuffer → FlutterTexture
    VideoRecorder                AVAssetWriter
    DeviceOrientation            wire-string enum

  ios/Classes/Camera/            iOS-only impls + extensions
    AudioSession                 AVAudioSession.upgradeForRecording
    DeviceOrientationBridge      UIDevice.orientation listener
    CameraSession+iOS            AVCaptureSessionWasInterrupted obs
                                 + InterruptionReason decode + the
                                 application-audio-session flags
                                 (all iOS-only on AVCaptureSession)
    CameraSettings               UIApplication.openSettingsURLString
    FlutterRegistrar+iOS         method-form of textures/messenger

  macos/Classes/Camera/          macOS no-op stubs (same surface)
    AudioSession                 no-op (no AVAudioSession on macOS)
    DeviceOrientationBridge      no-op (desktops don't rotate)
    CameraSession+macOS          no-op setupPlatform()
    CameraSettings               NSWorkspace → System Settings'
                                 Privacy_Camera pane
    FlutterRegistrar+macOS       property-form of textures/messenger

`CameraSession.init` now calls `setupPlatform()` which each platform
provides via an extension — keeps the iOS-only interruption observer
and the `automaticallyConfiguresApplicationAudioSession` /
`usesApplicationAudioSession` flags (both iOS-only on AVCaptureSession)
out of the shared file. Flash-mode in PhotoOutput uses
`if #available(macOS 11/13, *)` rather than `#if`, since those are
plain version gates not platform splits.

The shared files compile into the iOS pod from `ios/Classes/Camera-shared/`
and into the macOS pod from `macos/Classes/Camera-shared/`, each a
mirror populated by a `prepare_command` in the podspec:

    rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared

Symlinks and `../` source globs both fail — Pathname.glob bails on
symlinks, and CocoaPods silently drops paths that escape the pod
directory. The mirror destinations are .gitignore'd.

macOS UxPlugin now registers CameraPlugin alongside the others.
This commit is contained in:
agra
2026-05-13 18:53:46 +03:00
parent 16f986ab37
commit 14565ebd7a
22 changed files with 282 additions and 106 deletions

View File

@@ -0,0 +1,481 @@
import AVFoundation
#if canImport(UIKit)
import Flutter
#else
import FlutterMacOS
#endif
/// `ux/camera` + `ux/camera/events` registrar. Routes channel calls
/// to per-handle [CameraInstance]s. Enforces device + audio claims
/// across instances so a second controller against an already-held
/// camera or audio session fails fast with the right error code.
///
/// Phase 2 scope: no video recording. `startVideoRecording` /
/// `stopVideoRecording` return `unsupported_format`.
public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler {
private weak var textureRegistry: FlutterTextureRegistry?
private var instances: [Int: CameraInstance] = [:]
private var nextHandle: Int = 1
// Multi-instance contention bookkeeping.
private var devicesInUse: Set<String> = []
private var audioInUse: Bool = false
private var eventSink: FlutterEventSink?
public func register(with registrar: FlutterPluginRegistrar) {
// `uxTextures` / `uxMessenger` are per-platform shims
// see `FlutterRegistrar+iOS.swift` / `+macOS.swift`. iOS has
// them as methods, macOS as properties; the extensions paper
// over that so this call site stays platform-agnostic.
textureRegistry = registrar.uxTextures
let methods = FlutterMethodChannel(
name: "ux/camera",
binaryMessenger: registrar.uxMessenger
)
methods.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
let events = FlutterEventChannel(
name: "ux/camera/events",
binaryMessenger: registrar.uxMessenger
)
events.setStreamHandler(self)
}
// MARK: - FlutterStreamHandler
public func onListen(
withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink
) -> FlutterError? {
eventSink = events
return nil
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
eventSink = nil
return nil
}
// MARK: - Method dispatch
private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "availableCameras":
availableCameras(result: result)
case "create":
create(args: call.arguments, result: result)
case "initialize":
withInstance(call.arguments, result: result) { instance in
self.requestPermissions(audio: instance.audioClaimed) { granted, kind in
guard granted else {
result(FlutterError(
code: "permission_denied",
message: kind,
details: nil
))
return
}
instance.sessionQueueAsync {
instance.initialize()
DispatchQueue.main.async { result(nil) }
}
}
}
case "dispose":
withInstance(call.arguments, result: result) { instance in
// Release the device + audio claim *immediately* on .main
// so a follow-up `create` for the same camera can succeed
// even while the slow session teardown
// (stopRunning / removeInput / removeOutput) is still in
// flight on the per-instance sessionQueue. Two
// AVCaptureSessions can briefly coexist against the same
// device AVFoundation serializes the underlying hardware
// access. Without this, a quick page-pop re-push hits
// `device_busy` because the old claim hasn't been
// released yet.
self.releaseClaim(for: instance)
self.instances.removeValue(forKey: instance.handle)
instance.sessionQueueAsync {
instance.dispose()
DispatchQueue.main.async { result(nil) }
}
}
case "setDescription":
guard let args = call.arguments as? [String: Any],
let cameraId = args["cameraId"] as? String else {
result(badArgs("setDescription"))
return
}
withInstance(args, result: result) { instance in
// Contention check against other instances. The instance's
// current cameraId is held by us in `devicesInUse`; only a
// foreign holder should block. (A no-op flip same id
// also passes.)
let oldId = instance.currentCameraId
if oldId != cameraId, self.devicesInUse.contains(cameraId) {
result(FlutterError(
code: "device_busy",
message: cameraId,
details: nil
))
return
}
// Tentatively claim the new id before the async swap so a
// concurrent create can't race us. Roll back on failure.
self.devicesInUse.insert(cameraId)
instance.sessionQueueAsync {
do {
let size = try instance.setDescription(cameraId: cameraId)
DispatchQueue.main.async {
if let oldId, oldId != cameraId {
self.devicesInUse.remove(oldId)
}
result([
"previewSize": [
"width": size.width,
"height": size.height,
],
"previewRotationQuarterTurns": 0,
])
}
} catch let error as NSError {
DispatchQueue.main.async {
if oldId != cameraId {
self.devicesInUse.remove(cameraId)
}
result(FlutterError(
code: "init_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
case "setFlashMode":
guard let args = call.arguments as? [String: Any],
let modeName = args["mode"] as? String else {
result(badArgs("setFlashMode"))
return
}
withInstance(args, result: result) { instance in
instance.sessionQueueAsync {
instance.setFlashMode(flashMode(from: modeName))
DispatchQueue.main.async { result(nil) }
}
}
case "lockCaptureOrientation":
guard let args = call.arguments as? [String: Any],
let raw = args["orientation"] as? String else {
result(badArgs("lockCaptureOrientation"))
return
}
withInstance(args, result: result) { instance in
let o = DeviceOrientationFlutter.parse(raw)
instance.sessionQueueAsync {
instance.lockCaptureOrientation(o)
DispatchQueue.main.async { result(nil) }
}
}
case "unlockCaptureOrientation":
withInstance(call.arguments, result: result) { instance in
instance.sessionQueueAsync {
instance.unlockCaptureOrientation()
DispatchQueue.main.async { result(nil) }
}
}
case "takePicture":
guard let args = call.arguments as? [String: Any],
let raw = args["snapshotOrientation"] as? String else {
result(badArgs("takePicture"))
return
}
withInstance(args, result: result) { instance in
let snapshot = DeviceOrientationFlutter.parse(raw)
instance.sessionQueueAsync {
instance.takePicture(snapshot: snapshot) { outcome in
switch outcome {
case .success(let path):
result(["path": path])
case .failure(let error):
result(FlutterError(
code: "take_picture_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
case "startVideoRecording":
guard let args = call.arguments as? [String: Any],
let raw = args["snapshotOrientation"] as? String else {
result(badArgs("startVideoRecording"))
return
}
withInstance(args, result: result) { instance in
let snapshot = DeviceOrientationFlutter.parse(raw)
instance.sessionQueueAsync {
do {
try instance.startVideoRecording(snapshot: snapshot)
DispatchQueue.main.async { result(nil) }
} catch let error as NSError {
DispatchQueue.main.async {
result(FlutterError(
code: "recorder_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
case "stopVideoRecording":
withInstance(call.arguments, result: result) { instance in
instance.sessionQueueAsync {
instance.stopVideoRecording { outcome in
// `outcome` arrives on recorderQueue from
// VideoRecorder.deliver. Bounce to main for
// the FlutterResult.
DispatchQueue.main.async {
switch outcome {
case .success(let url):
result(["path": url.path])
case .failure(let error):
result(FlutterError(
code: "recorder_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
}
case "audioPermissionStatus":
let granted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
result(granted)
case "openSettings":
DispatchQueue.main.async {
// Per-platform helper: iOS opens app-specific Settings
// via UIApplication; macOS opens the Camera privacy
// pane via NSWorkspace. See `CameraSettings.swift` in
// each platform's Classes/Camera/ folder.
CameraSettings.openAppSettings()
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
// MARK: - Method implementations
private func availableCameras(result: @escaping FlutterResult) {
let cameras = CaptureDevice.discover().map { $0.toWire() }
result(cameras)
}
private func create(args: Any?, result: @escaping FlutterResult) {
guard let args = args as? [String: Any],
let cameraId = args["cameraId"] as? String,
let enableAudio = args["enableAudio"] as? Bool else {
result(badArgs("create"))
return
}
guard let registry = textureRegistry else {
result(FlutterError(
code: "init_failed",
message: "Texture registry unavailable",
details: nil
))
return
}
if devicesInUse.contains(cameraId) {
result(FlutterError(
code: "device_busy",
message: cameraId,
details: nil
))
return
}
if enableAudio && audioInUse {
result(FlutterError(
code: "audio_busy",
message: nil,
details: nil
))
return
}
let handle = nextHandle
nextHandle += 1
let instance = CameraInstance(handle: handle)
instance.onEvent = { [weak self] payload in
self?.eventSink?(payload)
}
instances[handle] = instance
devicesInUse.insert(cameraId)
if enableAudio { audioInUse = true }
instance.sessionQueueAsync {
do {
try instance.create(
cameraId: cameraId,
enableAudio: enableAudio,
registry: registry
)
let size = instance.previewSize
DispatchQueue.main.async {
result([
"handle": handle,
"textureId": instance.textureId,
"previewSize": [
"width": size.width,
"height": size.height,
],
// iOS pre-rotates frames via the data-output
// connection's videoOrientation, so the Flutter
// Texture displays upright as-is.
"previewRotationQuarterTurns": 0,
])
}
} catch let error as NSError {
DispatchQueue.main.async {
// Can't rely on `releaseClaim(for: instance)` here
// if `configureSession` threw before `instance.device`
// was set, `instance.currentCameraId` is nil and the
// claim we inserted on line above would leak. Drop the
// ids we know we inserted explicitly.
self.devicesInUse.remove(cameraId)
if enableAudio { self.audioInUse = false }
self.instances.removeValue(forKey: handle)
result(FlutterError(
code: "init_failed",
message: error.localizedDescription,
details: nil
))
}
}
}
}
// MARK: - Helpers
private func withInstance(
_ args: Any?,
result: @escaping FlutterResult,
body: @escaping (CameraInstance) -> Void
) {
guard let map = args as? [String: Any],
let handle = (map["handle"] as? NSNumber)?.intValue else {
result(badArgs("missing handle"))
return
}
guard let instance = instances[handle] else {
result(FlutterError(
code: "disposed",
message: "No camera for handle \(handle)",
details: nil
))
return
}
body(instance)
}
private func releaseClaim(for instance: CameraInstance) {
if let id = instance.currentCameraId {
devicesInUse.remove(id)
}
if instance.audioClaimed {
audioInUse = false
}
}
/// Request camera (always) and microphone (when [audio]) access.
/// Completion fires on `.main` with `(true, "")` on camera grant.
/// A denied microphone is **not** a fatal failure the recording
/// pipeline tolerates a missing audio output cleanly (no audio
/// track in the file), and the user is more annoyed by "camera
/// won't even open" than by "video has no sound." Camera denial
/// is the only hard failure path here.
private func requestPermissions(
audio: Bool,
_ completion: @escaping (Bool, String) -> Void
) {
requestAccess(for: .video) { videoGranted in
guard videoGranted else {
completion(false, "camera")
return
}
guard audio else {
completion(true, "")
return
}
// Prompt for mic if we haven't asked yet; result doesn't
// gate initialize either way. CameraInstance.configureSession
// already tolerates a missing mic (the audio device input
// throws on `AVCaptureDeviceInput(device:)`, the catch
// swallows, and audioDeviceInput stays nil which
// [CameraInstance.startVideoRecording] reads as "skip
// adding an audio input to the writer").
self.requestAccess(for: .audio) { _ in
completion(true, "")
}
}
}
private func requestAccess(
for mediaType: AVMediaType,
_ completion: @escaping (Bool) -> Void
) {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
DispatchQueue.main.async { completion(true) }
case .notDetermined:
AVCaptureDevice.requestAccess(for: mediaType) { granted in
DispatchQueue.main.async { completion(granted) }
}
default:
DispatchQueue.main.async { completion(false) }
}
}
}
// MARK: - Free functions
private func badArgs(_ where_: String) -> FlutterError {
return FlutterError(
code: "bad_args",
message: "Bad arguments for \(where_)",
details: nil
)
}
private func flashMode(from raw: String) -> AVCaptureDevice.FlashMode {
switch raw {
case "always": return .on
case "off": return .off
default: return .off
}
}