Files
ux/darwin/Camera/CameraPlugin.swift
agra 14565ebd7a 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.
2026-05-13 18:53:46 +03:00

482 lines
18 KiB
Swift

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
}
}