├── .gitattributes ├── .gitignore ├── .editorconfig ├── Package.swift ├── Sources ├── TestCamera │ └── main.swift ├── TestAsyncCamera │ └── main.swift └── IsCameraOn │ ├── Utilities.swift │ └── IsCameraOn.swift ├── license └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.build 2 | /Packages 3 | /*.xcodeproj 4 | /.swiftpm 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.9 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "IsCameraOn", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .library( 11 | name: "IsCameraOn", 12 | targets: [ 13 | "IsCameraOn" 14 | ] 15 | ) 16 | ], 17 | targets: [ 18 | .target( 19 | name: "IsCameraOn" 20 | ), 21 | .executableTarget( 22 | name: "TestCamera", 23 | dependencies: [ 24 | "IsCameraOn" 25 | ] 26 | ), 27 | .executableTarget( 28 | name: "TestAsyncCamera", 29 | dependencies: [ 30 | "IsCameraOn" 31 | ] 32 | ) 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /Sources/TestCamera/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IsCameraOn 3 | 4 | print("=== Camera Status Polling Test ===") 5 | // Test built-in camera only 6 | print("📷 Built-in camera status: \(isCameraOn() ? "ON" : "OFF")") 7 | 8 | // Test all cameras (built-in + USB) 9 | print("🔌 All cameras status: \(isCameraOn(includeExternal: true) ? "ON" : "OFF")") 10 | 11 | print("\n➡️ Turn your camera ON (open FaceTime, Photo Booth, etc.) and press Enter...") 12 | _ = readLine() 13 | 14 | print("📷 Built-in camera status: \(isCameraOn() ? "ON" : "OFF")") 15 | print("🔌 All cameras status: \(isCameraOn(includeExternal: true) ? "ON" : "OFF")") 16 | 17 | print("\n➡️ Close camera apps and press Enter...") 18 | _ = readLine() 19 | 20 | print("📷 Built-in camera status: \(isCameraOn() ? "ON" : "OFF")") 21 | print("🔌 All cameras status: \(isCameraOn(includeExternal: true) ? "ON" : "OFF")") 22 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /Sources/TestAsyncCamera/main.swift: -------------------------------------------------------------------------------- 1 | import IsCameraOn 2 | import Foundation 3 | 4 | print("=== AsyncSequence Camera Status Test ===") 5 | print("This test demonstrates the async/await API") 6 | print("") 7 | 8 | func runAsyncTests() async { 9 | // Test 1: Basic monitoring 10 | print("📱 Starting basic camera monitoring (built-in only)...") 11 | let basicTask = Task { 12 | var changeCount = 0 13 | for await isOn in cameraStatusChanges() { 14 | changeCount += 1 15 | let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) 16 | print("[\(timestamp)] Camera status changed (#\(changeCount)): \(isOn ? "📸 ON" : "📴 OFF")") 17 | 18 | // Stop after 5 changes for demo purposes 19 | if changeCount >= 5 { 20 | print("✅ Received 5 status changes, stopping basic monitoring") 21 | break 22 | } 23 | } 24 | } 25 | 26 | // Test 2: Monitoring with external cameras 27 | print("\n🔌 Starting extended monitoring (including external cameras)...") 28 | let extendedTask = Task { 29 | for await isOn in cameraStatusChanges(includeExternal: true) { 30 | let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) 31 | print("[\(timestamp)] All cameras: \(isOn ? "🟢 ACTIVE" : "⚫ INACTIVE")") 32 | 33 | // Demo runs for 30 seconds 34 | try? await Task.sleep(nanoseconds: 100_000_000) // Small delay to avoid flooding 35 | } 36 | } 37 | 38 | // Let tests run for 30 seconds 39 | print("\n⏰ Tests will run for 30 seconds...") 40 | print("💡 Try opening/closing camera apps during this time!") 41 | print(" - Open Photo Booth, FaceTime, or Zoom") 42 | print(" - Connect/disconnect external cameras") 43 | print("") 44 | 45 | try? await Task.sleep(nanoseconds: 30_000_000_000) 46 | basicTask.cancel() 47 | extendedTask.cancel() 48 | print("\n✅ All tests completed!") 49 | } 50 | 51 | Task { 52 | await runAsyncTests() 53 | exit(0) 54 | } 55 | 56 | RunLoop.main.run() 57 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # is-camera-on 2 | 3 | > Check if a Mac camera is on 4 | 5 | Supports built-in cameras (FaceTime HD, iSight) and external physical cameras (USB, Thunderbolt, Continuity Camera, etc.). 6 | 7 | This module can be useful to check if the camera is already in use or notify you if it's turned on when you didn't intend it to be. 8 | 9 | ## Requirements 10 | 11 | macOS 10.15+ 12 | 13 | ## Install 14 | 15 | Add the following to `Package.swift`: 16 | 17 | ```swift 18 | .package(url: "https://github.com/sindresorhus/is-camera-on", from: "3.0.0") 19 | ``` 20 | 21 | [Or add the package in Xcode.](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) 22 | 23 | ## Usage 24 | 25 | ### Checking camera status 26 | 27 | ```swift 28 | import IsCameraOn 29 | 30 | // Check built-in camera only (default) 31 | print(isCameraOn()) 32 | //=> true 33 | 34 | // Check both built-in and external physical cameras 35 | print(isCameraOn(includeExternal: true)) 36 | //=> true 37 | ``` 38 | 39 | ### Monitoring camera status changes 40 | 41 | ```swift 42 | import IsCameraOn 43 | 44 | // Monitor camera status changes with async/await 45 | Task { 46 | for await isOn in cameraStatusChanges() { 47 | print("Camera status: \(isOn ? "ON" : "OFF")") 48 | } 49 | } 50 | ``` 51 | 52 | Notes: 53 | - Emits the current status immediately, then only on changes 54 | - Automatically handles device hot-plugging (cameras being connected/disconnected) 55 | - Uses property listeners for real-time updates (not polling) 56 | 57 | ## Testing 58 | 59 | To run the manual test: 60 | 61 | ```swift 62 | swift run TestCamera 63 | ``` 64 | 65 | ```swift 66 | swift run TestAsyncCamera 67 | ``` 68 | 69 | ## Related 70 | 71 | - [node-is-camera-on](https://github.com/sindresorhus/node-is-camera-on) - Node.js wrapper for this module 72 | - [is-camera-on-cli](https://github.com/sindresorhus/is-camera-on-cli) - CLI for this module 73 | - [macos-wallpaper](https://github.com/sindresorhus/macos-wallpaper) - Manage the desktop wallpaper 74 | - [do-not-disturb](https://github.com/sindresorhus/do-not-disturb) - Control the macOS `Do Not Disturb` feature 75 | - [More…](https://github.com/search?q=user%3Asindresorhus+language%3Aswift+archived%3Afalse&type=repositories) 76 | -------------------------------------------------------------------------------- /Sources/IsCameraOn/Utilities.swift: -------------------------------------------------------------------------------- 1 | import CoreMediaIO 2 | 3 | enum TransportType: UInt32 { 4 | case builtIn = 0x626C746E // 'bltn' 5 | case usb = 0x75736220 // 'usb ' 6 | case virtual = 0x76697274 // 'virt' 7 | } 8 | 9 | struct CameraDevice { 10 | let id: CMIOObjectID 11 | 12 | init(_ id: CMIOObjectID) { 13 | self.id = id 14 | } 15 | 16 | var transportType: TransportType? { 17 | guard 18 | let rawValue = CMIOUtil.getProperty( 19 | from: id, 20 | selector: CMIOObjectPropertySelector(kCMIODevicePropertyTransportType), 21 | as: UInt32.self 22 | ) 23 | else { 24 | return nil 25 | } 26 | 27 | return TransportType(rawValue: rawValue) 28 | } 29 | 30 | var isRunning: Bool { 31 | let raw: UInt32? = CMIOUtil.getProperty( 32 | from: id, 33 | selector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), 34 | as: UInt32.self 35 | ) 36 | 37 | return (raw ?? 0) != 0 38 | } 39 | 40 | var isBuiltIn: Bool { 41 | transportType == .builtIn 42 | } 43 | 44 | // Anything not virtual is considered "physical". 45 | // This covers USB, Thunderbolt/PCIe bridges, Bluetooth/Wi-Fi bridges, etc. 46 | // Conservative: treat unknown transport types as not physical to avoid misclassifying virtual devices 47 | var isPhysical: Bool { 48 | transportType.map { $0 != .virtual } ?? false 49 | } 50 | 51 | var isExternal: Bool { 52 | isPhysical && !isBuiltIn 53 | } 54 | } 55 | 56 | enum CMIOUtil { 57 | static let systemObject = CMIOObjectID(kCMIOObjectSystemObject) 58 | 59 | // Common property addresses 60 | static let deviceIsRunningAddress = CMIOObjectPropertyAddress( 61 | mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), 62 | mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), 63 | mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) 64 | ) 65 | 66 | static let hardwareDevicesAddress = CMIOObjectPropertyAddress( 67 | mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices), 68 | mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), 69 | mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMaster) 70 | ) 71 | 72 | static func hasProperty( 73 | objectID: CMIOObjectID, 74 | selector: CMIOObjectPropertySelector 75 | ) -> Bool { 76 | var address = CMIOObjectPropertyAddress(selector) 77 | return CMIOObjectHasProperty(objectID, &address) 78 | } 79 | 80 | static func getProperty( 81 | from objectID: CMIOObjectID, 82 | selector: CMIOObjectPropertySelector, 83 | as: T.Type 84 | ) -> T? { 85 | guard hasProperty(objectID: objectID, selector: selector) else { 86 | return nil 87 | } 88 | 89 | var address = CMIOObjectPropertyAddress(selector) 90 | var dataSize: UInt32 = 0 91 | 92 | guard CMIOObjectGetPropertyDataSize(objectID, &address, 0, nil, &dataSize) == noErr else { 93 | return nil 94 | } 95 | 96 | guard dataSize > 0 else { 97 | return nil 98 | } 99 | 100 | return withUnsafeTemporaryAllocation(of: UInt8.self, capacity: Int(dataSize)) { buffer -> T? in 101 | var dataUsed: UInt32 = 0 102 | guard 103 | CMIOObjectGetPropertyData(objectID, &address, 0, nil, dataSize, &dataUsed, buffer.baseAddress) == noErr 104 | else { 105 | return nil 106 | } 107 | 108 | // Validate that we got the expected amount of data 109 | guard dataUsed == dataSize, dataUsed == UInt32(MemoryLayout.size) else { 110 | return nil 111 | } 112 | 113 | // Safe to bind memory now 114 | guard let baseAddress = buffer.baseAddress else { return nil } 115 | return baseAddress.withMemoryRebound(to: T.self, capacity: 1) { ptr in 116 | ptr.pointee 117 | } 118 | } 119 | } 120 | 121 | static func getAllDevices() -> [CMIOObjectID] { 122 | var address = hardwareDevicesAddress 123 | 124 | var dataSize: UInt32 = 0 125 | guard 126 | CMIOObjectGetPropertyDataSize(systemObject, &address, 0, nil, &dataSize) == noErr, 127 | dataSize > 0 128 | else { 129 | return [] 130 | } 131 | 132 | let count = Int(dataSize) / MemoryLayout.size 133 | 134 | return withUnsafeTemporaryAllocation(of: CMIOObjectID.self, capacity: count) { buffer in 135 | var dataUsed: UInt32 = 0 136 | guard 137 | CMIOObjectGetPropertyData(systemObject, &address, 0, nil, dataSize, &dataUsed, buffer.baseAddress) == noErr 138 | else { 139 | return [] 140 | } 141 | 142 | // Validate we got the expected data size 143 | let expectedSize = count * MemoryLayout.size 144 | guard dataUsed == UInt32(expectedSize) else { 145 | return [] 146 | } 147 | 148 | return Array(buffer.prefix(count)) 149 | } 150 | } 151 | } 152 | 153 | extension CMIOObjectPropertyAddress { 154 | init(_ selector: CMIOObjectPropertySelector) { 155 | self.init( 156 | mSelector: selector, 157 | mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeWildcard), 158 | mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementWildcard) 159 | ) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Sources/IsCameraOn/IsCameraOn.swift: -------------------------------------------------------------------------------- 1 | import CoreMediaIO 2 | 3 | /** 4 | Checks if a Mac camera is currently on. 5 | 6 | - Parameter includeExternal: If `true`, checks both built-in and external physical cameras. If `false` (default), only checks built-in cameras for backward compatibility. 7 | - Returns: `true` if any camera matching the criteria is active, `false` otherwise. 8 | 9 | ## Examples 10 | 11 | Check only built-in camera (default behavior): 12 | 13 | ```swift 14 | isCameraOn() // Only built-in cameras 15 | ``` 16 | 17 | Check all physical cameras: 18 | 19 | ```swift 20 | isCameraOn(includeExternal: true) // Built-in and external physical cameras 21 | ``` 22 | */ 23 | public func isCameraOn(includeExternal: Bool = false) -> Bool { 24 | for deviceID in CMIOUtil.getAllDevices() { 25 | let device = CameraDevice(deviceID) 26 | 27 | if includeExternal { 28 | if 29 | device.isPhysical, 30 | device.isRunning 31 | { 32 | return true 33 | } 34 | } else { 35 | if 36 | device.isBuiltIn, 37 | device.isRunning 38 | { 39 | return true 40 | } 41 | } 42 | } 43 | 44 | return false 45 | } 46 | 47 | /** 48 | Returns an AsyncStream that emits camera status changes in real-time. 49 | 50 | - Note: Emits the current status immediately, then only on changes. 51 | - Note: The stream does not complete unless cancelled or the monitor stops. You must cancel the task or break from the loop to stop monitoring. 52 | - Returns: An AsyncStream that yields `true` when any camera is on, `false` when all are off. 53 | 54 | ```swift 55 | Task { 56 | for await isOn in cameraStatusChanges() { 57 | print("Camera is now: \(isOn ? "ON" : "OFF")") 58 | } 59 | } 60 | ``` 61 | */ // TODO: Use AsyncSequence when targeting macOS 15. 62 | public func cameraStatusChanges(includeExternal: Bool = false) -> AsyncStream { 63 | AsyncStream { continuation in 64 | let monitor = CameraMonitor( 65 | includeExternal: includeExternal, 66 | continuation: continuation 67 | ) 68 | 69 | continuation.onTermination = { _ in 70 | Task { @MainActor in 71 | await monitor.stop() 72 | } 73 | } 74 | 75 | Task { @MainActor in 76 | await monitor.start() 77 | } 78 | } 79 | } 80 | 81 | /** 82 | Internal monitor that manages camera device listeners and hot-plug detection. 83 | */ 84 | private actor CameraMonitor { 85 | private let includeExternal: Bool 86 | private let continuation: AsyncStream.Continuation 87 | private var devices = Set() 88 | private var listenerBlocks = [CMIOObjectID: CMIOObjectPropertyListenerBlock]() 89 | private var isMonitoring = false 90 | private var lastStatus: Bool? 91 | private var deviceListenerBlock: CMIOObjectPropertyListenerBlock? 92 | 93 | init( 94 | includeExternal: Bool, 95 | continuation: AsyncStream.Continuation 96 | ) { 97 | self.includeExternal = includeExternal 98 | self.continuation = continuation 99 | } 100 | 101 | deinit { 102 | // Cleanup will be handled by the cancellation handler 103 | } 104 | 105 | func start() { 106 | guard !isMonitoring else { 107 | return 108 | } 109 | 110 | isMonitoring = true 111 | 112 | // Monitor for device additions/removals 113 | setupDeviceListListener() 114 | 115 | // Setup initial devices 116 | refreshDevices() 117 | 118 | // Send initial status 119 | checkAndEmitStatus() 120 | } 121 | 122 | func stop() { 123 | guard isMonitoring else { 124 | return 125 | } 126 | 127 | // Set flag first to prevent re-entrancy during teardown 128 | isMonitoring = false 129 | 130 | // Remove all device listeners 131 | for deviceID in devices { 132 | removePropertyListener(for: deviceID) 133 | } 134 | 135 | // Remove device list listener 136 | if let deviceListenerBlock = deviceListenerBlock { 137 | var address = CMIOUtil.hardwareDevicesAddress 138 | 139 | let status = CMIOObjectRemovePropertyListenerBlock( 140 | CMIOUtil.systemObject, 141 | &address, 142 | DispatchQueue.main, 143 | deviceListenerBlock 144 | ) 145 | 146 | if status != noErr { 147 | #if DEBUG 148 | print("Warning: Failed to remove device list listener: \(status)") 149 | #endif 150 | } 151 | 152 | self.deviceListenerBlock = nil 153 | } 154 | 155 | devices.removeAll() 156 | listenerBlocks.removeAll() 157 | continuation.finish() 158 | } 159 | 160 | private func setupDeviceListListener() { 161 | var address = CMIOUtil.hardwareDevicesAddress 162 | 163 | let listenerBlock: CMIOObjectPropertyListenerBlock = { [weak self] _, _ in 164 | Task { [weak self] in 165 | await self?.handleDeviceListChange() 166 | } 167 | } 168 | 169 | deviceListenerBlock = listenerBlock 170 | 171 | let status = CMIOObjectAddPropertyListenerBlock( 172 | CMIOUtil.systemObject, 173 | &address, 174 | DispatchQueue.main, 175 | listenerBlock 176 | ) 177 | 178 | if status != noErr { 179 | #if DEBUG 180 | print("Warning: Failed to add device list listener: \(status)") 181 | #endif 182 | } 183 | } 184 | 185 | private func handleDeviceListChange() { 186 | guard isMonitoring else { 187 | return 188 | } 189 | 190 | refreshDevices() 191 | checkAndEmitStatus() 192 | } 193 | 194 | private func refreshDevices() { 195 | let allDeviceIDs = Set(CMIOUtil.getAllDevices()) 196 | 197 | // Remove listeners for disconnected devices 198 | let removedDevices = devices.subtracting(allDeviceIDs) 199 | for deviceID in removedDevices { 200 | removePropertyListener(for: deviceID) 201 | } 202 | 203 | // Add listeners for new devices 204 | let newDevices = allDeviceIDs.subtracting(devices) 205 | for deviceID in newDevices { 206 | let device = CameraDevice(deviceID) 207 | if shouldMonitorDevice(device) { 208 | addPropertyListener(for: deviceID) 209 | } 210 | } 211 | 212 | // Update tracked devices 213 | let monitoredDevices = allDeviceIDs.filter { deviceID in 214 | let device = CameraDevice(deviceID) 215 | return shouldMonitorDevice(device) 216 | } 217 | devices = Set(monitoredDevices) 218 | } 219 | 220 | private func shouldMonitorDevice(_ device: CameraDevice) -> Bool { 221 | includeExternal ? device.isPhysical : device.isBuiltIn 222 | } 223 | 224 | private func addPropertyListener(for deviceID: CMIOObjectID) { 225 | var address = CMIOUtil.deviceIsRunningAddress 226 | 227 | let listenerBlock: CMIOObjectPropertyListenerBlock = { [weak self] _, _ in 228 | Task { [weak self] in 229 | await self?.checkAndEmitStatus() 230 | } 231 | } 232 | 233 | let status = CMIOObjectAddPropertyListenerBlock( 234 | deviceID, 235 | &address, 236 | DispatchQueue.main, 237 | listenerBlock 238 | ) 239 | if status != noErr { 240 | #if DEBUG 241 | print("Warning: Failed to add device listener: \(status)") 242 | #endif 243 | } 244 | 245 | // Only store block if listener was successfully added 246 | if status == noErr { 247 | listenerBlocks[deviceID] = listenerBlock 248 | } 249 | } 250 | 251 | private func removePropertyListener(for deviceID: CMIOObjectID) { 252 | guard let listenerBlock = listenerBlocks[deviceID] else { 253 | return 254 | } 255 | 256 | var address = CMIOUtil.deviceIsRunningAddress 257 | 258 | let status = CMIOObjectRemovePropertyListenerBlock( 259 | deviceID, 260 | &address, 261 | DispatchQueue.main, 262 | listenerBlock 263 | ) 264 | // Always remove from dictionary to keep it in sync with reality 265 | listenerBlocks.removeValue(forKey: deviceID) 266 | 267 | if status != noErr { 268 | #if DEBUG 269 | print("Warning: Failed to remove device listener: \(status)") 270 | #endif 271 | } 272 | } 273 | 274 | private func checkAndEmitStatus() { 275 | guard isMonitoring else { 276 | return 277 | } 278 | 279 | let currentStatus = devices.contains { deviceID in 280 | CameraDevice(deviceID).isRunning 281 | } 282 | 283 | // Only emit if status actually changed 284 | if lastStatus != currentStatus { 285 | lastStatus = currentStatus 286 | continuation.yield(currentStatus) 287 | } 288 | } 289 | } 290 | --------------------------------------------------------------------------------