From 14565ebd7a22ac089d4fc63b47476bb4b6e2c4fd Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 13 May 2026 18:53:46 +0300 Subject: [PATCH] camera: macOS port via darwin/ split (no shared-file pragmas) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 7 +- .../Camera/CameraInstance.swift | 4 ++ .../Camera/CameraPlugin.swift | 23 ++++-- .../Camera/CameraSession.swift | 71 ++++--------------- .../Camera/CaptureDevice.swift | 0 darwin/Camera/DeviceOrientation.swift | 37 ++++++++++ .../Camera/PhotoOutput.swift | 11 ++- .../Camera/PreviewSink.swift | 4 ++ .../Camera/VideoRecorder.swift | 0 ios/Classes/Camera/AudioSession.swift | 4 ++ ios/Classes/Camera/CameraSession+iOS.swift | 59 +++++++++++++++ ios/Classes/Camera/CameraSettings.swift | 14 ++++ .../Camera/DeviceOrientationBridge.swift | 49 +++---------- ios/Classes/Camera/FlutterRegistrar+iOS.swift | 12 ++++ ios/ux.podspec | 6 ++ macos/Classes/Camera/AudioSession.swift | 9 +++ .../Classes/Camera/CameraSession+macOS.swift | 22 ++++++ macos/Classes/Camera/CameraSettings.swift | 16 +++++ .../Camera/DeviceOrientationBridge.swift | 19 +++++ .../Camera/FlutterRegistrar+macOS.swift | 11 +++ macos/Classes/UxPlugin.swift | 1 + macos/ux.podspec | 9 ++- 22 files changed, 282 insertions(+), 106 deletions(-) rename {ios/Classes => darwin}/Camera/CameraInstance.swift (99%) rename {ios/Classes => darwin}/Camera/CameraPlugin.swift (95%) rename {ios/Classes => darwin}/Camera/CameraSession.swift (50%) rename {ios/Classes => darwin}/Camera/CaptureDevice.swift (100%) create mode 100644 darwin/Camera/DeviceOrientation.swift rename {ios/Classes => darwin}/Camera/PhotoOutput.swift (89%) rename {ios/Classes => darwin}/Camera/PreviewSink.swift (97%) rename {ios/Classes => darwin}/Camera/VideoRecorder.swift (100%) create mode 100644 ios/Classes/Camera/CameraSession+iOS.swift create mode 100644 ios/Classes/Camera/CameraSettings.swift create mode 100644 ios/Classes/Camera/FlutterRegistrar+iOS.swift create mode 100644 macos/Classes/Camera/AudioSession.swift create mode 100644 macos/Classes/Camera/CameraSession+macOS.swift create mode 100644 macos/Classes/Camera/CameraSettings.swift create mode 100644 macos/Classes/Camera/DeviceOrientationBridge.swift create mode 100644 macos/Classes/Camera/FlutterRegistrar+macOS.swift diff --git a/.gitignore b/.gitignore index c2640f3..41b7ae9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,9 @@ .pub/ build/ -.cxx/ \ No newline at end of file +.cxx/ + +# Mirror destinations populated by `prepare_command` in the iOS/macOS +# podspecs from `darwin/Camera/` — see those podspecs for context. +ios/Classes/Camera-shared/ +macos/Classes/Camera-shared/ \ No newline at end of file diff --git a/ios/Classes/Camera/CameraInstance.swift b/darwin/Camera/CameraInstance.swift similarity index 99% rename from ios/Classes/Camera/CameraInstance.swift rename to darwin/Camera/CameraInstance.swift index 70d1039..2d04f28 100644 --- a/ios/Classes/Camera/CameraInstance.swift +++ b/darwin/Camera/CameraInstance.swift @@ -1,5 +1,9 @@ import AVFoundation +#if canImport(UIKit) import Flutter +#else +import FlutterMacOS +#endif import Foundation /// One per `UxCameraController` on the Dart side. Owns its diff --git a/ios/Classes/Camera/CameraPlugin.swift b/darwin/Camera/CameraPlugin.swift similarity index 95% rename from ios/Classes/Camera/CameraPlugin.swift rename to darwin/Camera/CameraPlugin.swift index 687f18d..172cc49 100644 --- a/ios/Classes/Camera/CameraPlugin.swift +++ b/darwin/Camera/CameraPlugin.swift @@ -1,6 +1,9 @@ import AVFoundation +#if canImport(UIKit) import Flutter -import UIKit +#else +import FlutterMacOS +#endif /// `ux/camera` + `ux/camera/events` registrar. Routes channel calls /// to per-handle [CameraInstance]s. Enforces device + audio claims @@ -22,11 +25,15 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { private var eventSink: FlutterEventSink? public func register(with registrar: FlutterPluginRegistrar) { - textureRegistry = registrar.textures() + // `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.messenger() + binaryMessenger: registrar.uxMessenger ) methods.setMethodCallHandler { [weak self] call, result in self?.handle(call, result: result) @@ -34,7 +41,7 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { let events = FlutterEventChannel( name: "ux/camera/events", - binaryMessenger: registrar.messenger() + binaryMessenger: registrar.uxMessenger ) events.setStreamHandler(self) } @@ -267,9 +274,11 @@ public class CameraPlugin: NSObject, NativePlugin, FlutterStreamHandler { case "openSettings": DispatchQueue.main.async { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } + // 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) } diff --git a/ios/Classes/Camera/CameraSession.swift b/darwin/Camera/CameraSession.swift similarity index 50% rename from ios/Classes/Camera/CameraSession.swift rename to darwin/Camera/CameraSession.swift index 84ca352..f3d6687 100644 --- a/ios/Classes/Camera/CameraSession.swift +++ b/darwin/Camera/CameraSession.swift @@ -3,13 +3,20 @@ import Foundation /// Thin wrapper around `AVCaptureSession` that owns the lifecycle /// helpers and observes the runtime-error / interruption -/// notifications, surfacing them through closures so -/// [CameraInstance] doesn't repeat the boilerplate. +/// notifications (iOS only — macOS has no equivalent), surfacing them +/// through closures so [CameraInstance] doesn't repeat the boilerplate. /// /// All `AVCaptureSession` mutations (input/output add/remove, start, /// stop) must run on the caller's `sessionQueue`. This class doesn't /// enforce that; the contract is that /// [CameraInstance.sessionQueue.async { … }] wraps every call site. +/// +/// Platform-specific init bits — the application-audio-session flags +/// and the interruption observers — live in `setupPlatform()` whose +/// real implementation is in `ios/Classes/Camera/CameraSession+iOS.swift`. +/// The macOS counterpart (`macos/Classes/Camera/CameraSession+macOS.swift`) +/// is a no-op: macOS has no `AVAudioSession` and no +/// `AVCaptureSessionWasInterrupted` notification. final class CameraSession { let av: AVCaptureSession @@ -21,26 +28,18 @@ final class CameraSession { /// Called on `.main` when the session is interrupted (e.g. video /// device taken by another foreground client, audio session /// interruption, or app backgrounded with `usesApplicationAudioSession`). - /// String describes the reason — `"videoDeviceInUseByAnotherClient"`, - /// `"audioDeviceInUseByAnotherClient"`, `"videoDeviceNotAvailableInBackground"`, etc. + /// iOS only — never fires on macOS. var onInterrupted: ((String) -> Void)? - /// Called on `.main` when an earlier interruption ends. + /// Called on `.main` when an earlier interruption ends. iOS only. var onResumed: (() -> Void)? - private var runtimeErrorObserver: NSObjectProtocol? - private var interruptedObserver: NSObjectProtocol? - private var resumedObserver: NSObjectProtocol? + var runtimeErrorObserver: NSObjectProtocol? + var interruptedObserver: NSObjectProtocol? + var resumedObserver: NSObjectProtocol? init() { av = AVCaptureSession() - - // Telegram + camera_avfoundation both set this — keeps - // AVFoundation from yanking our audio session category out - // from under the app. - av.automaticallyConfiguresApplicationAudioSession = false - av.usesApplicationAudioSession = true - runtimeErrorObserver = NotificationCenter.default.addObserver( forName: .AVCaptureSessionRuntimeError, object: av, @@ -50,24 +49,7 @@ final class CameraSession { ?? NSError(domain: "ux.camera", code: -1) self?.onRuntimeError?(error) } - - interruptedObserver = NotificationCenter.default.addObserver( - forName: .AVCaptureSessionWasInterrupted, - object: av, - queue: .main - ) { [weak self] note in - let reason = note.userInfo?[AVCaptureSessionInterruptionReasonKey] - as? Int ?? 0 - self?.onInterrupted?(reasonName(for: reason)) - } - - resumedObserver = NotificationCenter.default.addObserver( - forName: .AVCaptureSessionInterruptionEnded, - object: av, - queue: .main - ) { [weak self] _ in - self?.onResumed?() - } + setupPlatform() } deinit { @@ -97,26 +79,3 @@ final class CameraSession { if av.isRunning { av.stopRunning() } } } - -/// Decode the integer reason that comes with -/// `AVCaptureSessionWasInterrupted`. Used in the event payload sent -/// to Dart. -private func reasonName(for code: Int) -> String { - guard let reason = AVCaptureSession.InterruptionReason(rawValue: code) else { - return "unknown" - } - switch reason { - case .videoDeviceNotAvailableInBackground: - return "videoDeviceNotAvailableInBackground" - case .audioDeviceInUseByAnotherClient: - return "audioDeviceInUseByAnotherClient" - case .videoDeviceInUseByAnotherClient: - return "videoDeviceInUseByAnotherClient" - case .videoDeviceNotAvailableWithMultipleForegroundApps: - return "videoDeviceNotAvailableWithMultipleForegroundApps" - case .videoDeviceNotAvailableDueToSystemPressure: - return "videoDeviceNotAvailableDueToSystemPressure" - @unknown default: - return "unknown" - } -} diff --git a/ios/Classes/Camera/CaptureDevice.swift b/darwin/Camera/CaptureDevice.swift similarity index 100% rename from ios/Classes/Camera/CaptureDevice.swift rename to darwin/Camera/CaptureDevice.swift diff --git a/darwin/Camera/DeviceOrientation.swift b/darwin/Camera/DeviceOrientation.swift new file mode 100644 index 0000000..d86d30d --- /dev/null +++ b/darwin/Camera/DeviceOrientation.swift @@ -0,0 +1,37 @@ +import AVFoundation + +/// Mirrors Flutter's `DeviceOrientation` — the four cardinal values +/// that travel over the `ux/camera` channel as wire strings. `public` +/// so the host app's XCTest target can verify the +/// `DeviceOrientationFlutter` / `AVCaptureVideoOrientation` mapping +/// without `@testable import`. +/// +/// The enum is platform-shared; the listener that turns physical +/// rotation into a stream of these values is platform-specific — +/// see `ios/Classes/Camera/DeviceOrientationBridge.swift` for the +/// iOS implementation and `macos/Classes/Camera/DeviceOrientationBridge.swift` +/// for the macOS one (no-op; desktops don't rotate). +public enum DeviceOrientationFlutter: String { + case portraitUp + case landscapeLeft + case portraitDown + case landscapeRight + + /// Parse a wire string. Returns `.portraitUp` for unknown inputs + /// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`). + public static func parse(_ raw: String?) -> DeviceOrientationFlutter { + return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp + } + + /// AVFoundation video orientation. Translates Flutter's portrait- + /// relative convention to AVFoundation's hardware-relative one. + /// Used to drive `AVCaptureConnection.videoOrientation`. + public var avVideoOrientation: AVCaptureVideoOrientation { + switch self { + case .portraitUp: return .portrait + case .portraitDown: return .portraitUpsideDown + case .landscapeLeft: return .landscapeRight + case .landscapeRight: return .landscapeLeft + } + } +} diff --git a/ios/Classes/Camera/PhotoOutput.swift b/darwin/Camera/PhotoOutput.swift similarity index 89% rename from ios/Classes/Camera/PhotoOutput.swift rename to darwin/Camera/PhotoOutput.swift index c93fb33..b3730e2 100644 --- a/ios/Classes/Camera/PhotoOutput.swift +++ b/darwin/Camera/PhotoOutput.swift @@ -45,8 +45,15 @@ final class PhotoOutput { } let settings = AVCapturePhotoSettings() - if avOutput.supportedFlashModes.contains(flashMode) { - settings.flashMode = flashMode + // `supportedFlashModes` arrived in macOS 11; `flashMode` setter + // in macOS 13. iOS has both since iOS 10. Gated via Swift + // availability so we don't have to bump the macOS deployment + // target just to use a flash that almost no Mac has anyway. + if #available(macOS 11.0, *), + avOutput.supportedFlashModes.contains(flashMode) { + if #available(macOS 13.0, *) { + settings.flashMode = flashMode + } } let delegate = PhotoCaptureDelegate { [weak self] result in diff --git a/ios/Classes/Camera/PreviewSink.swift b/darwin/Camera/PreviewSink.swift similarity index 97% rename from ios/Classes/Camera/PreviewSink.swift rename to darwin/Camera/PreviewSink.swift index 2b30cd7..dabc1c4 100644 --- a/ios/Classes/Camera/PreviewSink.swift +++ b/darwin/Camera/PreviewSink.swift @@ -1,6 +1,10 @@ import AVFoundation import CoreVideo +#if canImport(UIKit) import Flutter +#else +import FlutterMacOS +#endif /// Single-slot latest-pixel-buffer sink that feeds a `FlutterTexture`. /// diff --git a/ios/Classes/Camera/VideoRecorder.swift b/darwin/Camera/VideoRecorder.swift similarity index 100% rename from ios/Classes/Camera/VideoRecorder.swift rename to darwin/Camera/VideoRecorder.swift diff --git a/ios/Classes/Camera/AudioSession.swift b/ios/Classes/Camera/AudioSession.swift index d6c7746..f668520 100644 --- a/ios/Classes/Camera/AudioSession.swift +++ b/ios/Classes/Camera/AudioSession.swift @@ -10,6 +10,10 @@ import AVFoundation /// `automaticallyConfiguresApplicationAudioSession = false` + /// `usesApplicationAudioSession = true` (set in [CameraSession.init]) /// so AVCaptureSession doesn't yank the category back. +/// +/// iOS-only. The macOS counterpart lives in +/// `macos/Classes/Camera/AudioSession.swift` and is a no-op — +/// `AVCaptureSession` manages its own audio routing on macOS. enum AudioSession { /// Upgrade the shared category to include `.playAndRecord` (and /// the given options union'd with whatever's already set). No-op diff --git a/ios/Classes/Camera/CameraSession+iOS.swift b/ios/Classes/Camera/CameraSession+iOS.swift new file mode 100644 index 0000000..8d2559a --- /dev/null +++ b/ios/Classes/Camera/CameraSession+iOS.swift @@ -0,0 +1,59 @@ +import AVFoundation +import Foundation + +/// iOS-only platform setup for [CameraSession]: enables the +/// "application owns the AVAudioSession" flags (so AVCaptureSession +/// doesn't yank our `.playAndRecord` category back), and subscribes +/// to `AVCaptureSessionWasInterrupted` / `…InterruptionEnded` +/// notifications (which only fire on iOS — `AVCaptureSession.InterruptionReason` +/// isn't even declared on macOS). +extension CameraSession { + func setupPlatform() { + // Telegram + camera_avfoundation both set these — keeps + // AVFoundation from yanking our audio session category out + // from under the app. iOS-only properties on AVCaptureSession. + av.automaticallyConfiguresApplicationAudioSession = false + av.usesApplicationAudioSession = true + + interruptedObserver = NotificationCenter.default.addObserver( + forName: .AVCaptureSessionWasInterrupted, + object: av, + queue: .main + ) { [weak self] note in + let code = note.userInfo?[AVCaptureSessionInterruptionReasonKey] + as? Int ?? 0 + self?.onInterrupted?(reasonName(for: code)) + } + + resumedObserver = NotificationCenter.default.addObserver( + forName: .AVCaptureSessionInterruptionEnded, + object: av, + queue: .main + ) { [weak self] _ in + self?.onResumed?() + } + } +} + +/// Decode the integer reason that comes with +/// `AVCaptureSessionWasInterrupted`. Used in the event payload sent +/// to Dart. +private func reasonName(for code: Int) -> String { + guard let reason = AVCaptureSession.InterruptionReason(rawValue: code) else { + return "unknown" + } + switch reason { + case .videoDeviceNotAvailableInBackground: + return "videoDeviceNotAvailableInBackground" + case .audioDeviceInUseByAnotherClient: + return "audioDeviceInUseByAnotherClient" + case .videoDeviceInUseByAnotherClient: + return "videoDeviceInUseByAnotherClient" + case .videoDeviceNotAvailableWithMultipleForegroundApps: + return "videoDeviceNotAvailableWithMultipleForegroundApps" + case .videoDeviceNotAvailableDueToSystemPressure: + return "videoDeviceNotAvailableDueToSystemPressure" + @unknown default: + return "unknown" + } +} diff --git a/ios/Classes/Camera/CameraSettings.swift b/ios/Classes/Camera/CameraSettings.swift new file mode 100644 index 0000000..a1633cd --- /dev/null +++ b/ios/Classes/Camera/CameraSettings.swift @@ -0,0 +1,14 @@ +import UIKit + +/// Deep-link the user into this app's Settings entry. iOS exposes a +/// dedicated `openSettingsURLString` that lands directly on the +/// per-app permission pane. Called from the shared +/// `CameraPlugin.handle("openSettings")`. +enum CameraSettings { + @MainActor + static func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } +} diff --git a/ios/Classes/Camera/DeviceOrientationBridge.swift b/ios/Classes/Camera/DeviceOrientationBridge.swift index 6d21290..26b1d45 100644 --- a/ios/Classes/Camera/DeviceOrientationBridge.swift +++ b/ios/Classes/Camera/DeviceOrientationBridge.swift @@ -1,18 +1,19 @@ import AVFoundation import UIKit -/// Translates between Flutter's `DeviceOrientation` (4 enum values -/// shipped as strings across the channel) and AVFoundation's -/// `AVCaptureVideoOrientation`, and bridges physical-device rotation -/// notifications to a closure callback. -/// -/// Observed orientation source is `UIDevice.current.orientation` — -/// independent of any UI orientation lock, so this fires even while -/// the app's window is portrait-locked. `.faceUp` / `.faceDown` are -/// ignored (no useful direction). +/// Bridges physical-device rotation notifications to a closure callback. +/// Observed source is `UIDevice.current.orientation` — independent of +/// any UI orientation lock, so this fires even while the app's window +/// is portrait-locked. `.faceUp` / `.faceDown` are ignored (no useful +/// direction). /// /// `UIDevice.beginGeneratingDeviceOrientationNotifications()` must be /// called on main, balanced with `end…()`; this class enforces both. +/// +/// iOS-only. The macOS counterpart in +/// `macos/Classes/Camera/DeviceOrientationBridge.swift` is a no-op — +/// desktops don't rotate, so `current` stays at the initial +/// `portraitUp` and the listener never fires. final class DeviceOrientationBridge { typealias Listener = (DeviceOrientationFlutter) -> Void @@ -74,23 +75,7 @@ final class DeviceOrientationBridge { deinit { stop() } } -/// Mirrors Flutter's `DeviceOrientation` — the four cardinal values -/// that travel over the `ux/camera` channel as wire strings. `public` -/// so the host app's XCTest target can verify the -/// `DeviceOrientationFlutter` / `AVCaptureVideoOrientation` mapping -/// without `@testable import`. -public enum DeviceOrientationFlutter: String { - case portraitUp - case landscapeLeft - case portraitDown - case landscapeRight - - /// Parse a wire string. Returns `.portraitUp` for unknown inputs - /// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`). - public static func parse(_ raw: String?) -> DeviceOrientationFlutter { - return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp - } - +extension DeviceOrientationFlutter { /// `UIDeviceOrientation` → Flutter convention is a direct 1:1 by /// name. Despite the AV-side flip, `UIDeviceOrientation.landscapeLeft` /// and Flutter's `landscapeLeft` describe the same physical pose @@ -105,16 +90,4 @@ public enum DeviceOrientationFlutter: String { default: return nil } } - - /// AVFoundation video orientation. Translates Flutter's portrait- - /// relative convention to AVFoundation's hardware-relative one. - /// Used to drive `AVCaptureConnection.videoOrientation`. - public var avVideoOrientation: AVCaptureVideoOrientation { - switch self { - case .portraitUp: return .portrait - case .portraitDown: return .portraitUpsideDown - case .landscapeLeft: return .landscapeRight - case .landscapeRight: return .landscapeLeft - } - } } diff --git a/ios/Classes/Camera/FlutterRegistrar+iOS.swift b/ios/Classes/Camera/FlutterRegistrar+iOS.swift new file mode 100644 index 0000000..2d0074e --- /dev/null +++ b/ios/Classes/Camera/FlutterRegistrar+iOS.swift @@ -0,0 +1,12 @@ +import Flutter + +/// Bridges the per-platform difference in `FlutterPluginRegistrar`: +/// `textures` and `messenger` are **methods** on the iOS variant +/// (`func textures() -> ...`) but **properties** on the macOS variant +/// (`var textures: ...`). Shared `CameraPlugin` calls +/// `registrar.uxTextures` and `registrar.uxMessenger` so the call +/// site stays platform-agnostic. +extension FlutterPluginRegistrar { + var uxTextures: FlutterTextureRegistry { textures() } + var uxMessenger: FlutterBinaryMessenger { messenger() } +} diff --git a/ios/ux.podspec b/ios/ux.podspec index 133a247..1813207 100644 --- a/ios/ux.podspec +++ b/ios/ux.podspec @@ -6,6 +6,12 @@ Pod::Spec.new do |s| s.license = { :file => '../LICENSE' } s.author = { 'Swipelab' => 'hello@swipelab.co' } s.source = { :path => '.' } + # Mirror the shared `darwin/Camera/` Swift files into a local + # `Classes/Camera-shared/` so CocoaPods picks them up via the + # normal glob — neither symlinks nor `../` escapes work + # (Pathname.glob bails on both). The mirror runs on every + # `pod install`; the destination is `.gitignore`'d. + s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared' s.source_files = 'Classes/**/*.swift' s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo', 'Photos', 'PhotosUI'] s.dependency 'Flutter' diff --git a/macos/Classes/Camera/AudioSession.swift b/macos/Classes/Camera/AudioSession.swift new file mode 100644 index 0000000..9edf866 --- /dev/null +++ b/macos/Classes/Camera/AudioSession.swift @@ -0,0 +1,9 @@ +import Foundation + +/// macOS counterpart of `ios/Classes/Camera/AudioSession.swift`. +/// `AVAudioSession` is an iOS/tvOS/watchOS concept — macOS apps have +/// no such global category state. `AVCaptureSession` configures its +/// own audio routing on macOS, so this is a pure no-op. +enum AudioSession { + static func upgradeForRecording() {} +} diff --git a/macos/Classes/Camera/CameraSession+macOS.swift b/macos/Classes/Camera/CameraSession+macOS.swift new file mode 100644 index 0000000..e43d7f4 --- /dev/null +++ b/macos/Classes/Camera/CameraSession+macOS.swift @@ -0,0 +1,22 @@ +import AVFoundation +import Foundation + +/// macOS counterpart of `CameraSession+iOS.swift`. Intentionally a +/// no-op: +/// +/// - `automaticallyConfiguresApplicationAudioSession` / +/// `usesApplicationAudioSession` are iOS-only properties on +/// `AVCaptureSession`. macOS doesn't have an app-wide +/// `AVAudioSession`, so there's nothing to "configure". +/// - `AVCaptureSessionWasInterrupted` and `InterruptionReason` are +/// iOS/tvOS/watchOS only — desktop AVCaptureSessions never get +/// "interrupted by a phone call" or "displaced by another app's +/// exclusive camera hold" in the way iOS does. +/// +/// `onInterrupted` and `onResumed` on [CameraSession] are present +/// for surface parity but stay silent on macOS. +extension CameraSession { + func setupPlatform() { + // Intentionally empty. + } +} diff --git a/macos/Classes/Camera/CameraSettings.swift b/macos/Classes/Camera/CameraSettings.swift new file mode 100644 index 0000000..df4d832 --- /dev/null +++ b/macos/Classes/Camera/CameraSettings.swift @@ -0,0 +1,16 @@ +import AppKit + +/// macOS counterpart of the iOS `CameraSettings.openAppSettings`. +/// There's no per-app deep link on macOS; we go straight to the +/// Camera privacy pane in System Settings. The URL scheme has been +/// stable since macOS 10.14. +enum CameraSettings { + @MainActor + static func openAppSettings() { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera" + ) { + NSWorkspace.shared.open(url) + } + } +} diff --git a/macos/Classes/Camera/DeviceOrientationBridge.swift b/macos/Classes/Camera/DeviceOrientationBridge.swift new file mode 100644 index 0000000..55d94f2 --- /dev/null +++ b/macos/Classes/Camera/DeviceOrientationBridge.swift @@ -0,0 +1,19 @@ +import Foundation + +/// macOS counterpart of `ios/Classes/Camera/DeviceOrientationBridge.swift`. +/// Desktops don't rotate; the bridge is shaped identically to the iOS +/// one so [CameraInstance] can call it without `#if` blocks, but +/// `current` stays at `.portraitUp` and the listener never fires. +final class DeviceOrientationBridge { + typealias Listener = (DeviceOrientationFlutter) -> Void + + private(set) var current: DeviceOrientationFlutter = .portraitUp + + func start(listener: @escaping Listener) { + // Intentionally empty — no rotation events on macOS. + } + + func stop() { + // Intentionally empty — nothing to unwind. + } +} diff --git a/macos/Classes/Camera/FlutterRegistrar+macOS.swift b/macos/Classes/Camera/FlutterRegistrar+macOS.swift new file mode 100644 index 0000000..3aba98a --- /dev/null +++ b/macos/Classes/Camera/FlutterRegistrar+macOS.swift @@ -0,0 +1,11 @@ +import FlutterMacOS + +/// macOS counterpart of `ios/Classes/Camera/FlutterRegistrar+iOS.swift`. +/// On macOS, `FlutterPluginRegistrar.textures` and `.messenger` are +/// properties (no parens) rather than methods. Shared `CameraPlugin` +/// uses `registrar.uxTextures` and `registrar.uxMessenger` for +/// surface parity. +extension FlutterPluginRegistrar { + var uxTextures: FlutterTextureRegistry { textures } + var uxMessenger: FlutterBinaryMessenger { messenger } +} diff --git a/macos/Classes/UxPlugin.swift b/macos/Classes/UxPlugin.swift index 7408393..5ac6dba 100644 --- a/macos/Classes/UxPlugin.swift +++ b/macos/Classes/UxPlugin.swift @@ -9,6 +9,7 @@ public class UxPlugin: NSObject, FlutterPlugin { FilePlugin(), ClipboardPlugin(), GalleryPlugin(), + CameraPlugin(), ] for plugin in plugins { plugin.register(with: registrar) diff --git a/macos/ux.podspec b/macos/ux.podspec index 4df5f94..2105eca 100644 --- a/macos/ux.podspec +++ b/macos/ux.podspec @@ -1,12 +1,17 @@ Pod::Spec.new do |s| s.name = 'ux' - s.version = '0.6.0' - s.summary = 'UX Kit — Flutter plugin: file share/open via Quick Look.' + s.version = '0.9.0' + s.summary = 'UX Kit — Flutter plugin: keyboard, sensor, file, QR scanner, and camera.' s.homepage = 'https://swipelab.co/ux.html' s.license = { :file => '../LICENSE' } s.author = { 'Swipelab' => 'hello@swipelab.co' } s.source = { :path => '.' } + # See the matching note in `ios/ux.podspec` — mirror the shared + # `darwin/Camera/` Swift files into a local `Classes/Camera-shared/` + # at install time so CocoaPods picks them up via the normal glob. + s.prepare_command = 'rm -rf Classes/Camera-shared && cp -R ../darwin/Camera Classes/Camera-shared' s.source_files = 'Classes/**/*.swift' + s.frameworks = ['AVFoundation', 'CoreMedia', 'CoreVideo'] s.dependency 'FlutterMacOS' s.osx.deployment_target = '10.15' s.swift_version = '5.0'