diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7333ea0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,49 @@ +# Copilot Instructions for CameraController + +## Build & Test + +This is an Xcode project (no SPM Package.swift). Build and test via `xcodebuild`: + +```sh +# Build +xcodebuild -project CameraController.xcodeproj -scheme CameraController -sdk macosx build + +# Run all tests +xcodebuild -project CameraController.xcodeproj -scheme CameraController -sdk macosx test + +# Run a single test +xcodebuild -project CameraController.xcodeproj -scheme CameraController -sdk macosx \ + -only-testing:CameraControllerTests/StringTests/testExtractProductAndVendor test +``` + +**Linting:** SwiftLint is required. Run `swiftlint` from the repo root. + +## Architecture + +CameraController is a macOS menu-bar app that controls USB camera settings via the UVC (USB Video Class) protocol. It has three targets: + +- **CameraController** – Main app. SwiftUI views rendered in an `NSPopover` from the status bar. Uses `Combine` for reactive state. +- **UVC** – Local framework (imported as `import UVC`). Handles all low-level USB/IOKit communication with cameras. Sends UVC control requests over `IOUSBInterfaceInterface190`. +- **Helper** – Login item helper app (`com.itaysoft.CameraController.Helper`) for launch-at-login via `SMLoginItemSetEnabled`. + +### Key data flow + +1. `DevicesManager` (singleton) discovers cameras via `AVCaptureDevice.DiscoverySession` and monitors connect/disconnect. +2. `CaptureDevice` wraps an `AVCaptureDevice` and creates a `UVCDevice` (from the UVC framework) to get USB-level access. +3. `DeviceController` exposes camera properties (brightness, contrast, exposure, etc.) as observable `CaptureDeviceProperty` objects that the SwiftUI views bind to. +4. `CaptureDeviceProperty` types (`NumberCaptureDeviceProperty`, `BoolCaptureDeviceProperty`, `BitmapCaptureDeviceProperty`, `MultipleCaptureDeviceProperty`) wrap `UVCControl` subclasses and handle min/max/default/current values. +5. `UVCControl` (and subclasses `UVCIntControl`, `UVCBoolControl`, `UVCBitmapControl`) perform the actual USB control requests to read/write camera settings. + +### Profiles + +`ProfileManager` persists per-device settings as JSON via `UserDefaults`, allowing users to save and restore camera configurations. + +## Conventions + +- **Singletons** use `static let shared` pattern (e.g., `DevicesManager.shared`, `UserSettings.shared`). +- **Classes are `final`** unless inheritance is needed. +- **User preferences** are stored in `UserDefaults` with manual key strings, wrapped in `@Published` properties in `UserSettings`. +- **Vendored code** lives in `CameraController/Vendored/` (e.g., LetsMove for app relocation prompts). +- **Auto-updates** use the Sparkle framework (`SPUStandardUpdaterController`). +- **macOS minimum:** 10.15 (Catalina). +- **File headers** follow the Xcode template format with file name, project, author, and copyright. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b45d1f7..60b8f06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,9 @@ on: branches: - master +permissions: + contents: read + jobs: test: name: Run tests @@ -15,9 +18,9 @@ jobs: steps: - name: Checkout - uses: actions/checkout@master + uses: actions/checkout@v6 - name: Install Certificates - uses: apple-actions/import-codesign-certs@v1 + uses: apple-actions/import-codesign-certs@v7 with: p12-file-base64: ${{ secrets.CERTIFICATES_P12 }} p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }} diff --git a/CameraController.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CameraController.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..367949b --- /dev/null +++ b/CameraController.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b", + "version" : "2.9.2" + } + } + ], + "version" : 3 +} diff --git a/CameraController/Devices/CaptureDevice.swift b/CameraController/Devices/CaptureDevice.swift index 6c8832d..8989a22 100644 --- a/CameraController/Devices/CaptureDevice.swift +++ b/CameraController/Devices/CaptureDevice.swift @@ -20,7 +20,12 @@ final class CaptureDevice: Hashable, ObservableObject { init(avDevice: AVCaptureDevice) { self.avDevice = avDevice self.name = avDevice.localizedName - self.uvcDevice = try? UVCDevice(device: avDevice) + do { + self.uvcDevice = try UVCDevice(device: avDevice) + } catch { + print("Unable to configure UVC device \(avDevice.localizedName): \(error)") + self.uvcDevice = nil + } self.controller = DeviceController(properties: uvcDevice?.properties) } diff --git a/CameraController/Devices/DeviceController.swift b/CameraController/Devices/DeviceController.swift index e833121..c46b283 100644 --- a/CameraController/Devices/DeviceController.swift +++ b/CameraController/Devices/DeviceController.swift @@ -60,12 +60,15 @@ final class DeviceController: ObservableObject { contrast.write() saturation.write() sharpness.write() + hue.write() + hueAuto.write() whiteBalanceAuto.write() whiteBalance.write() powerLineFrequency.write() backlightCompensation.write() zoomAbsolute.write() panTiltAbsolute.write() + rollAbsolute.write() focusAuto.write() focusAbsolute.write() } diff --git a/CameraController/Devices/DeviceManager.swift b/CameraController/Devices/DeviceManager.swift index 8e51285..8523b50 100644 --- a/CameraController/Devices/DeviceManager.swift +++ b/CameraController/Devices/DeviceManager.swift @@ -56,6 +56,9 @@ final class DevicesManager: ObservableObject { NotificationCenter.default.removeObserver(self, name: NSNotification.Name.AVCaptureDeviceWasConnected, object: nil) + NotificationCenter.default.removeObserver(self, + name: NSNotification.Name.AVCaptureDeviceWasDisconnected, + object: nil) } @objc @@ -74,15 +77,13 @@ final class DevicesManager: ObservableObject { return } - let index = devices.firstIndex { (captureDevice) -> Bool in + guard let index = devices.firstIndex(where: { (captureDevice) -> Bool in captureDevice.avDevice == device - } - - guard index != nil else { + }) else { return } - devices.remove(at: index!) + devices.remove(at: index) if device.uniqueID == selectedDevice?.avDevice?.uniqueID { selectedDevice = nil diff --git a/CameraController/Devices/Profile.swift b/CameraController/Devices/Profile.swift index d7184f6..8271041 100644 --- a/CameraController/Devices/Profile.swift +++ b/CameraController/Devices/Profile.swift @@ -8,28 +8,53 @@ import Foundation -struct Profile: Codable, Hashable { +struct Profile: Codable, Hashable, Identifiable { + let id: UUID let name: String let isDefault: Bool let settings: DeviceSettings? init(name: String, settings: DeviceSettings) { + self.id = UUID() self.name = name self.settings = settings self.isDefault = false } init() { + self.id = UUID() self.name = "Camera Default" self.settings = nil self.isDefault = true } + init(id: UUID, name: String, isDefault: Bool, settings: DeviceSettings?) { + self.id = id + self.name = name + self.isDefault = isDefault + self.settings = settings + } + + enum CodingKeys: String, CodingKey { + case id + case name + case isDefault + case settings + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.name = try container.decode(String.self, forKey: .name) + self.isDefault = try container.decode(Bool.self, forKey: .isDefault) + self.settings = try container.decodeIfPresent(DeviceSettings.self, forKey: .settings) + } + static func == (lhs: Profile, rhs: Profile) -> Bool { - return lhs.name == rhs.name + return lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(name) + hasher.combine(id) } } diff --git a/CameraController/Devices/Properties/BitmapCaptureDeviceProperty.swift b/CameraController/Devices/Properties/BitmapCaptureDeviceProperty.swift index 2ef33a1..ba9a897 100644 --- a/CameraController/Devices/Properties/BitmapCaptureDeviceProperty.swift +++ b/CameraController/Devices/Properties/BitmapCaptureDeviceProperty.swift @@ -38,9 +38,10 @@ final class BitmapCaptureDeviceProperty: ObservableObject { func reset() { control.current = control.defaultValue + internalValue = control.defaultValue } func write() { - selected = control.current + control.current = internalValue } } diff --git a/CameraController/Devices/Properties/BoolCaptureDeviceProperty.swift b/CameraController/Devices/Properties/BoolCaptureDeviceProperty.swift index 4e8d759..051c315 100644 --- a/CameraController/Devices/Properties/BoolCaptureDeviceProperty.swift +++ b/CameraController/Devices/Properties/BoolCaptureDeviceProperty.swift @@ -40,9 +40,10 @@ final class BoolCaptureDeviceProperty: ObservableObject { func reset() { control.isEnabled = control.defaultValue + internalValue = control.defaultValue } func write() { - isEnabled = control.isEnabled + control.isEnabled = internalValue } } diff --git a/CameraController/Devices/Properties/MultipleCaptureDeviceProperty.swift b/CameraController/Devices/Properties/MultipleCaptureDeviceProperty.swift index 39ee499..2b96603 100644 --- a/CameraController/Devices/Properties/MultipleCaptureDeviceProperty.swift +++ b/CameraController/Devices/Properties/MultipleCaptureDeviceProperty.swift @@ -66,19 +66,19 @@ final class MultipleCaptureDeviceProperty: ObservableObject { resolution2 = Float(control.resolution2) defaultValue1 = Float(control.defaultValue1) defaultValue2 = Float(control.defaultValue2) - intervalValue1 = Float(control.defaultValue1) - intervalValue2 = Float(control.defaultValue2) - sliderValue1 = Float(control.current1) - sliderValue2 = Float(control.current2) + intervalValue1 = Float(control.current1) + intervalValue2 = Float(control.current2) } func reset() { control.current1 = control.defaultValue1 control.current2 = control.defaultValue2 + intervalValue1 = Float(control.defaultValue1) + intervalValue2 = Float(control.defaultValue2) } func write() { - sliderValue1 = Float(control.current1) - sliderValue2 = Float(control.current2) + control.current1 = Int(intervalValue1) + control.current2 = Int(intervalValue2) } } diff --git a/CameraController/Devices/Properties/NumberCaptureDeviceProperty.swift b/CameraController/Devices/Properties/NumberCaptureDeviceProperty.swift index 535b5ab..cafb6a9 100644 --- a/CameraController/Devices/Properties/NumberCaptureDeviceProperty.swift +++ b/CameraController/Devices/Properties/NumberCaptureDeviceProperty.swift @@ -52,19 +52,20 @@ final class NumberCaptureDeviceProperty: SliderCapableProperty, ObservableObject maximum = Float(control.maximum) resolution = Float(control.resolution) defaultValue = Float(control.defaultValue) - sliderValue = Float(control.current) + internalValue = Float(control.current) } func reset() { control.current = control.defaultValue + internalValue = Float(control.defaultValue) } func update() { let newValue = control.getCurrent() - sliderValue = Float(newValue) + internalValue = Float(newValue) } func write() { - sliderValue = Float(control.current) + control.current = Int(internalValue) } } diff --git a/CameraController/Managers/ProfileManager.swift b/CameraController/Managers/ProfileManager.swift index 19d950a..876ddac 100644 --- a/CameraController/Managers/ProfileManager.swift +++ b/CameraController/Managers/ProfileManager.swift @@ -17,14 +17,23 @@ final class ProfileManager: ObservableObject { private init() { if let savedProfiles = UserDefaults.standard.object(forKey: "profiles") as? Data { let decoder = JSONDecoder() - if let profiles = try? decoder.decode([Profile].self, from: savedProfiles) { + do { + let profiles = try decoder.decode([Profile].self, from: savedProfiles) self.profiles = profiles + } catch { + print("Unable to decode saved profiles: \(error)") } } } func saveProfile(_ name: String, _ settings: DeviceSettings) { - let newProfile = Profile(name: name, settings: settings) + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + print("Profile name cannot be empty") + return + } + + let newProfile = Profile(name: trimmedName, settings: settings) profiles.append(newProfile) saveProfiles() } @@ -43,15 +52,21 @@ final class ProfileManager: ObservableObject { return } - let newProfile = Profile(name: profile.name, settings: settings) + let newProfile = Profile(id: profile.id, + name: profile.name, + isDefault: profile.isDefault, + settings: settings) profiles[index] = newProfile saveProfiles() } private func saveProfiles() { let encoder = JSONEncoder() - if let encoded = try? encoder.encode(profiles) { + do { + let encoded = try encoder.encode(profiles) UserDefaults.standard.set(encoded, forKey: "profiles") + } catch { + print("Unable to encode profiles: \(error)") } } } diff --git a/CameraController/Settings/UserSettings.swift b/CameraController/Settings/UserSettings.swift index e1a81d1..e4604fa 100644 --- a/CameraController/Settings/UserSettings.swift +++ b/CameraController/Settings/UserSettings.swift @@ -12,11 +12,11 @@ import ServiceManagement final class UserSettings: ObservableObject { static let shared = UserSettings() + private static let loginHelperIdentifier = "com.itaysoft.CameraController.Helper" @Published var openAtLogin: Bool { didSet { - let success = SMLoginItemSetEnabled("com.itaysoft.CameraController.Helper" as CFString, openAtLogin) - if success { + if setOpenAtLogin(openAtLogin) { UserDefaults.standard.set(openAtLogin, forKey: "login") } } @@ -73,4 +73,27 @@ final class UserSettings: ObservableObject { checkForUpdatesOnStartup = UserDefaults.standard.bool(forKey: "checkForUpdatesOnStartup") mirrorPreview = UserDefaults.standard.bool(forKey: "mirrorPreview") } + + private func setOpenAtLogin(_ isEnabled: Bool) -> Bool { + if #available(macOS 13.0, *) { + let service = SMAppService.loginItem(identifier: Self.loginHelperIdentifier) + do { + if isEnabled { + try service.register() + } else { + try service.unregister() + } + return true + } catch { + print("Unable to update login item: \(error)") + return false + } + } else { + let success = SMLoginItemSetEnabled(Self.loginHelperIdentifier as CFString, isEnabled) + if !success { + print("Unable to update login item") + } + return success + } + } } diff --git a/CameraController/Views/Preview/CameraPreviewInternal.swift b/CameraController/Views/Preview/CameraPreviewInternal.swift index c71b4a0..04ed612 100644 --- a/CameraController/Views/Preview/CameraPreviewInternal.swift +++ b/CameraController/Views/Preview/CameraPreviewInternal.swift @@ -22,20 +22,39 @@ final class CameraPreviewInternal: NSView { super.init(frame: frameRect) - setupPreviewLayer(captureSession) + setup() Task { configureDevice(device) // lock configuration to keep device.activeFormat - do { - try captureDevice?.lockForConfiguration() + do { + try captureDevice?.lockForConfiguration() captureSession.startRunning() - captureDevice?.unlockForConfiguration() - } catch { - // Handle error. - } + captureDevice?.unlockForConfiguration() + } catch { + print("Unable to start camera preview: \(error)") + } } + observeWindowNotifications() + } + + required init?(coder: NSCoder) { + captureSession = AVCaptureSession() + + super.init(coder: coder) + + setup() + observeWindowNotifications() + } + + private func setup() { + wantsLayer = true + setupPreviewLayer(captureSession) + layer?.addSublayer(previewLayer) + } + + private func observeWindowNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(windowClosed), name: .windowClose, @@ -57,10 +76,6 @@ final class CameraPreviewInternal: NSView { previewLayer.videoGravity = .resizeAspect } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - deinit { NotificationCenter.default.removeObserver(self) } @@ -68,7 +83,6 @@ final class CameraPreviewInternal: NSView { override func layout() { super.layout() previewLayer.frame = bounds - layer?.addSublayer(previewLayer) } func stopRunning() { @@ -89,7 +103,7 @@ final class CameraPreviewInternal: NSView { captureSession.startRunning() captureDevice?.unlockForConfiguration() } catch { - // Handle error. + print("Unable to update camera preview: \(error)") } } } @@ -108,6 +122,7 @@ final class CameraPreviewInternal: NSView { do { captureInput = try AVCaptureDeviceInput(device: device) } catch { + print("Unable to create camera preview input: \(error)") return } diff --git a/CameraController/Views/Settings/Preferences/CameraSection.swift b/CameraController/Views/Settings/Preferences/CameraSection.swift index 081dc61..abae70c 100644 --- a/CameraController/Views/Settings/Preferences/CameraSection.swift +++ b/CameraController/Views/Settings/Preferences/CameraSection.swift @@ -22,12 +22,22 @@ struct CameraSection: View { Picker(selection: $manager.selectedDevice, label: Text("")) { Text("None").tag(nil as CaptureDevice?) ForEach(manager.devices, id: \.self) { device in - Text(device.name).tag(device as CaptureDevice?) + Text(displayName(for: device)).tag(device as CaptureDevice?) } }.frame(width: 200) } } } + + private func displayName(for device: CaptureDevice) -> String { + let duplicateNames = manager.devices.filter { $0.name == device.name } + guard duplicateNames.count > 1, + let uniqueID = device.avDevice?.uniqueID else { + return device.name + } + + return "\(device.name) (\(uniqueID.suffix(6)))" + } } #if DEBUG diff --git a/CameraController/Views/Settings/Profile/ProfileRow.swift b/CameraController/Views/Settings/Profile/ProfileRow.swift index a11e054..6c28ed7 100644 --- a/CameraController/Views/Settings/Profile/ProfileRow.swift +++ b/CameraController/Views/Settings/Profile/ProfileRow.swift @@ -59,7 +59,10 @@ struct ProfileRow: View { case .defaultProfile: device.controller?.resetDefault() case .custom(let profile): - device.controller?.set(profile.settings!) + guard let settings = profile.settings else { + return + } + device.controller?.set(settings) } } diff --git a/CameraController/Views/Settings/Profile/ProfilesView.swift b/CameraController/Views/Settings/Profile/ProfilesView.swift index c96521b..d626cc2 100644 --- a/CameraController/Views/Settings/Profile/ProfilesView.swift +++ b/CameraController/Views/Settings/Profile/ProfilesView.swift @@ -19,7 +19,7 @@ struct ProfilesView: View { VStack(spacing: Constants.Style.controlsSpacing) { ProfileRow(name: "Camera Default", profile: .defaultProfile) Divider() - ForEach(profileManager.profiles, id: \.self) { profile in + ForEach(profileManager.profiles, id: \.id) { profile in ProfileRow(name: profile.name, profile: .custom(profile)) } } diff --git a/CameraControllerTests/StringTests.swift b/CameraControllerTests/StringTests.swift index 3d56eb3..73bcbbd 100644 --- a/CameraControllerTests/StringTests.swift +++ b/CameraControllerTests/StringTests.swift @@ -7,6 +7,7 @@ // import XCTest +import IOKit.usb @testable import CameraController @testable import UVC @@ -25,4 +26,96 @@ class StringTests: XCTestCase { XCTAssertThrowsError(try modelInfo.extractCameraInformation()) } + + func testProfileDecodesLegacyDataWithoutId() throws { + let data = Data(""" + [{"name":"Legacy","isDefault":false,"settings":null}] + """.utf8) + + let profiles = try JSONDecoder().decode([Profile].self, from: data) + + XCTAssertEqual(profiles.count, 1) + XCTAssertEqual(profiles.first?.name, "Legacy") + XCTAssertNotNil(profiles.first?.id) + } + + func testProfilesWithDuplicateNamesAreDistinct() { + let first = Profile(name: "Same Name", settings: makeDeviceSettings()) + let second = Profile(name: "Same Name", settings: makeDeviceSettings()) + + XCTAssertNotEqual(first, second) + } + + func testUVCDescriptorParserFindsVideoControlIds() { + let descriptor = parseDescriptor([ + 9, 2, 38, 0, 1, 1, 0, 0x80, 50, + 9, 4, 3, 0, 0, 0x0E, 0x01, 0, 0, + 12, 0x24, 0x01, 0x10, 0x01, 20, 0, 0, 0, 0, 0, 0, + 4, 0x24, 0x02, 7, + 4, 0x24, 0x05, 9 + ]) + + XCTAssertEqual(descriptor.interfaceID, 3) + XCTAssertEqual(descriptor.cameraTerminalID, 7) + XCTAssertEqual(descriptor.processingUnitID, 9) + } + + func testUVCDescriptorParserRejectsInvalidConfigLength() { + let descriptor = parseDescriptor([ + 9, 2, 4, 0, 1, 1, 0, 0x80, 50 + ]) + + XCTAssertEqual(descriptor.interfaceID, -1) + XCTAssertEqual(descriptor.cameraTerminalID, -1) + XCTAssertEqual(descriptor.processingUnitID, -1) + } + + func testUVCDescriptorParserRejectsZeroLengthDescriptor() { + let descriptor = parseDescriptor([ + 9, 2, 12, 0, 1, 1, 0, 0x80, 50, + 0, 4, 0 + ]) + + XCTAssertEqual(descriptor.interfaceID, -1) + XCTAssertEqual(descriptor.cameraTerminalID, -1) + XCTAssertEqual(descriptor.processingUnitID, -1) + } + + func testUVCDescriptorParserRejectsTruncatedDescriptor() { + let descriptor = parseDescriptor([ + 9, 2, 12, 0, 1, 1, 0, 0x80, 50, + 9, 4, 0 + ]) + + XCTAssertEqual(descriptor.interfaceID, -1) + XCTAssertEqual(descriptor.cameraTerminalID, -1) + XCTAssertEqual(descriptor.processingUnitID, -1) + } + + private func makeDeviceSettings() -> DeviceSettings { + return DeviceSettings(exposureMode: 0, + exposureTime: 0, + gain: 0, + brightness: 0, + contrast: 0, + saturation: 0, + sharpness: 0, + whiteBalanceAuto: false, + whiteBalance: 0, + powerline: 0, + backlightCompensation: 0, + zoom: 0, + pan: 0, + tilt: 0, + focusAuto: false, + focus: 0) + } + + private func parseDescriptor(_ descriptorBytes: [UInt8]) -> UVCDescriptor { + var descriptorBytes = descriptorBytes + return descriptorBytes.withUnsafeMutableBytes { buffer in + let descriptor = buffer.baseAddress!.assumingMemoryBound(to: IOUSBConfigurationDescriptor.self) + return descriptor.proccessDescriptor() + } + } } diff --git a/UVC/Controls/UVCControl.swift b/UVC/Controls/UVCControl.swift index 4b5f139..371339c 100644 --- a/UVC/Controls/UVCControl.swift +++ b/UVC/Controls/UVCControl.swift @@ -38,7 +38,7 @@ public class UVCControl { length: length, requestType: requestType) } catch { - // Should not return 0, but working on improving this + print("Unable to read UVC control \(uvcSelector): \(error)") return 0 } } @@ -53,6 +53,7 @@ public class UVCControl { value: value) return true } catch { + print("Unable to write UVC control \(uvcSelector): \(error)") return false } } @@ -68,6 +69,12 @@ public class UVCControl { guard uvcUnit >= 0 else { throw UVCError.invalidUnitId } + guard uvcInterface >= 0 else { + throw UVCError.invalidInterfaceId + } + guard length > 0, length <= MemoryLayout.size else { + throw UVCError.invalidRequestLength + } var value = value diff --git a/UVC/Controls/UVCIntControl.swift b/UVC/Controls/UVCIntControl.swift index c6c04b8..67037da 100644 --- a/UVC/Controls/UVCIntControl.swift +++ b/UVC/Controls/UVCIntControl.swift @@ -52,7 +52,8 @@ public final class UVCIntControl: UVCControl { } public func getCurrent() -> Int { - return getDataFor(type: .getCurrent, length: uvcSize) + _current = getDataFor(type: .getCurrent, length: uvcSize) + return _current } func updateMinimum() { diff --git a/UVC/Controls/UVCMultipleIntControl.swift b/UVC/Controls/UVCMultipleIntControl.swift index 18719ea..367e78a 100644 --- a/UVC/Controls/UVCMultipleIntControl.swift +++ b/UVC/Controls/UVCMultipleIntControl.swift @@ -50,12 +50,12 @@ public final class UVCMultipleIntControl: UVCControl { } private func set1(_ val1: Int) -> Bool { - let newValue = (val1*3600) << 32 + (_current2*3600) + let newValue = ((val1 * 3600) << 32) + (_current2 * 3600) return setData(value: newValue, length: uvcSize) } private func set2(_ val2: Int) -> Bool { - let newValue = (_current1*3600) << 32 + (val2*3600) + let newValue = ((_current1 * 3600) << 32) + (val2 * 3600) return setData(value: newValue, length: uvcSize) } @@ -87,7 +87,7 @@ public final class UVCMultipleIntControl: UVCControl { var msgLength = [UInt8](repeating: 0, count: 8) for index in 0...7 { - msgLength[index] = UInt8(0x0000FF & value >> Int((7 - index) * 8)) + msgLength[index] = UInt8((value >> Int((7 - index) * 8)) & 0x0000FF) } return msgLength } diff --git a/UVC/Extensions/AVCaptureDevice+USB.swift b/UVC/Extensions/AVCaptureDevice+USB.swift index fefab89..80c589b 100644 --- a/UVC/Extensions/AVCaptureDevice+USB.swift +++ b/UVC/Extensions/AVCaptureDevice+USB.swift @@ -12,12 +12,24 @@ import IOKit.usb extension AVCaptureDevice { - private func getIOService() throws -> io_service_t { - var camera: io_service_t = 0 + private var ioMainPort: mach_port_t { + if #available(macOS 12.0, *) { + return kIOMainPortDefault + } else { + return kIOMasterPortDefault + } + } + + private func usbMatchingDictionary() throws -> NSMutableDictionary { let cameraInformation = try self.modelID.extractCameraInformation() let dictionary: NSMutableDictionary = IOServiceMatching("IOUSBDevice") as NSMutableDictionary dictionary["idVendor"] = cameraInformation.vendorId dictionary["idProduct"] = cameraInformation.productId + return dictionary + } + + private func getIOService() throws -> io_service_t { + var camera: io_service_t = 0 // adding other keys to this dictionary like kUSBProductString, kUSBVendorString, etc don't // seem to have any affect on using IOServiceGetMatchingService to get the correct camera, @@ -25,7 +37,12 @@ extension AVCaptureDevice { // and fetch their property dicts and then match against the more specific values var iter: io_iterator_t = 0 - if IOServiceGetMatchingServices(kIOMasterPortDefault, dictionary, &iter) == kIOReturnSuccess { + if IOServiceGetMatchingServices(ioMainPort, try usbMatchingDictionary(), &iter) == kIOReturnSuccess { + defer { + if iter != 0 { + IOObjectRelease(iter) + } + } var cameraCandidate: io_service_t cameraCandidate = IOIteratorNext(iter) while cameraCandidate != 0 { @@ -53,13 +70,18 @@ extension AVCaptureDevice { } } } + IOObjectRelease(cameraCandidate) cameraCandidate = IOIteratorNext(iter) } } // if we haven't found a camera after looping through the iterator, fallback on GetMatchingService method if camera == 0 { - camera = IOServiceGetMatchingService(kIOMasterPortDefault, dictionary) + camera = IOServiceGetMatchingService(ioMainPort, try usbMatchingDictionary()) + } + + guard camera != 0 else { + throw UVCError.noMatchingDevice } return camera @@ -69,8 +91,7 @@ extension AVCaptureDevice { let camera = try self.getIOService() defer { - let code: kern_return_t = IOObjectRelease(camera) - assert( code == kIOReturnSuccess ) + IOObjectRelease(camera) } var interfaceRef: UnsafeMutablePointer>? var configDesc: IOUSBConfigurationDescriptorPtr? @@ -92,6 +113,9 @@ extension AVCaptureDevice { print("unable to get number of configurations") return } + guard numConfig > 0 else { + return + } returnCode = deviceInterface.pointee.pointee.GetConfigurationDescriptorPtr(deviceInterface, 0, &configDesc) if returnCode != kIOReturnSuccess { @@ -99,11 +123,16 @@ extension AVCaptureDevice { return } } - guard interfaceRef != nil else { throw NSError(domain: #function, code: #line, userInfo: nil) } + guard let interface = interfaceRef else { + throw UVCError.controlInterfaceUnavailable + } + guard let configDesc = configDesc else { + throw UVCError.configurationDescriptorUnavailable + } - let descriptor = configDesc!.proccessDescriptor() + let descriptor = configDesc.proccessDescriptor() - return USBDevice(interface: interfaceRef.unsafelyUnwrapped, + return USBDevice(interface: interface, descriptor: descriptor) } } diff --git a/UVC/Extensions/IOUSBConfigurationDescriptorPtr+UVC.swift b/UVC/Extensions/IOUSBConfigurationDescriptorPtr+UVC.swift index cd178c9..63b2375 100644 --- a/UVC/Extensions/IOUSBConfigurationDescriptorPtr+UVC.swift +++ b/UVC/Extensions/IOUSBConfigurationDescriptorPtr+UVC.swift @@ -15,9 +15,17 @@ extension IOUSBConfigurationDescriptorPtr { var cameraTerminalID = -1 var interfaceID = -1 - let remaining = self.pointee.wTotalLength - UInt16(self.pointee.bLength) + let totalLength = Int(self.pointee.wTotalLength) + let descriptorLength = Int(self.pointee.bLength) + guard descriptorLength > 0, totalLength >= descriptorLength else { + return UVCDescriptor(processingUnitID: processingUnitID, + cameraTerminalID: cameraTerminalID, + interfaceID: interfaceID) + } + + let remaining = totalLength - descriptorLength var pointer = UnsafeMutablePointer(OpaquePointer(self)) - pointer = pointer.advanced(by: Int(self.pointee.bLength)) + pointer = pointer.advanced(by: descriptorLength) browseDescriptor(remaining, pointer, &processingUnitID, &cameraTerminalID, &interfaceID) @@ -26,7 +34,8 @@ extension IOUSBConfigurationDescriptorPtr { interfaceID: interfaceID) } - private func browseDescriptor(_ memory: UInt16, _ pointer: UnsafeMutablePointer, + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func browseDescriptor(_ memory: Int, _ pointer: UnsafeMutablePointer, _ processingUnitID: inout Int, _ cameraTerminalID: inout Int, _ interfaceID: inout Int) { @@ -35,18 +44,31 @@ extension IOUSBConfigurationDescriptorPtr { while remaining > 0 { var descriptorPointer = InterfaceDescriptorPointer(OpaquePointer(currentPointer)) + let descriptorLength = Int(descriptorPointer.pointee.bLength) + guard descriptorLength > 0, descriptorLength <= remaining else { + break + } if descriptorPointer.pointee.bDescriptorType == kUSBInterfaceDesc { let intDesc = UnsafeMutablePointer(OpaquePointer(descriptorPointer)) if !(intDesc.pointee.bInterfaceClass == UVCConstants.classVideo && intDesc.pointee.bInterfaceSubClass == UVCConstants.subclassVideoControl) { - currentPointer = currentPointer.advanced(by: Int(intDesc.pointee.bLength)) + remaining -= descriptorLength + currentPointer = currentPointer.advanced(by: descriptorLength) continue } - currentPointer = currentPointer.advanced(by: Int(intDesc.pointee.bLength)) + remaining -= descriptorLength + currentPointer = currentPointer.advanced(by: descriptorLength) + guard remaining > 0 else { + break + } descriptorPointer = InterfaceDescriptorPointer(OpaquePointer(currentPointer)) + let headerLength = Int(descriptorPointer.pointee.bLength) + guard headerLength > 0, headerLength <= remaining else { + break + } if descriptorPointer.pointee.bDescriptorType != UVCConstants.descriptorTypeInterface { break @@ -54,16 +76,22 @@ extension IOUSBConfigurationDescriptorPtr { let internalDescriptor = UnsafeMutablePointer(OpaquePointer(descriptorPointer)) if internalDescriptor.pointee.bDescriptorSubType == UVCConstants.subclassVideoControl { - let littleEndian = Int(internalDescriptor.pointee.wTotalLength).littleEndian - internalDescriptor.pointee.wTotalLength = UInt16(littleEndian) + let controlLength = Int(internalDescriptor.pointee.wTotalLength) + guard controlLength >= headerLength else { + break + } - remaining -= internalDescriptor.pointee.wTotalLength - currentPointer = currentPointer.advanced(by: Int(internalDescriptor.pointee.bLength)) - var remainingMemory = internalDescriptor.pointee.wTotalLength - - UInt16(internalDescriptor.pointee.bLength) + let boundedControlLength = min(controlLength, remaining) + remaining -= boundedControlLength + currentPointer = currentPointer.advanced(by: headerLength) + var remainingMemory = boundedControlLength - headerLength while remainingMemory > 0 { descriptorPointer = InterfaceDescriptorPointer(OpaquePointer(currentPointer)) + let descriptorLength = Int(descriptorPointer.pointee.bLength) + guard descriptorLength > 0, descriptorLength <= remainingMemory else { + break + } if descriptorPointer.pointee.bDescriptorType != UVCConstants.descriptorTypeInterface { break } @@ -77,17 +105,17 @@ extension IOUSBConfigurationDescriptorPtr { return } - remainingMemory -= UInt16(descriptorPointer.pointee.bLength) - currentPointer = currentPointer.advanced(by: Int(descriptorPointer.pointee.bLength)) + remainingMemory -= descriptorLength + currentPointer = currentPointer.advanced(by: descriptorLength) } } else { - remaining -= UInt16(descriptorPointer.pointee.bLength) - currentPointer = currentPointer.advanced(by: Int(descriptorPointer.pointee.bLength)) + remaining -= headerLength + currentPointer = currentPointer.advanced(by: headerLength) } break } else { - remaining -= UInt16(descriptorPointer.pointee.bLength) - currentPointer = currentPointer.advanced(by: Int(descriptorPointer.pointee.bLength)) + remaining -= descriptorLength + currentPointer = currentPointer.advanced(by: descriptorLength) } } } @@ -99,9 +127,15 @@ extension IOUSBConfigurationDescriptorPtr { let unitType = UVCConstants.DescriptorSubtype(rawValue: descriptorPointer.pointee.bDescriptorSubType) switch unitType { case .processingUnit: + guard descriptorPointer.pointee.bLength >= 4 else { + break + } let puPointer = ProcessingUnitDescriptorPointer(OpaquePointer(currentPointer)) processingUnitID = Int(puPointer.pointee.bUnitID) case .inputTerminal: + guard descriptorPointer.pointee.bLength >= 4 else { + break + } let ctPointer = CameraTerminalDescriptorPointer(OpaquePointer(currentPointer)) cameraTerminalID = Int(ctPointer.pointee.bTerminalID) case .none: diff --git a/UVC/Extensions/UnsafeMutablePointer+interface.swift b/UVC/Extensions/UnsafeMutablePointer+interface.swift index 9be60cc..6098c6e 100644 --- a/UVC/Extensions/UnsafeMutablePointer+interface.swift +++ b/UVC/Extensions/UnsafeMutablePointer+interface.swift @@ -22,24 +22,22 @@ extension UnsafeMutablePointer where Pointee == UnsafeMutablePointer { func iterate(interfaceRequest: IOUSBFindInterfaceRequest, - handle: (UnsafeMutablePointer>) throws -> Void) rethrows { + handle: (UnsafeMutablePointer>) throws -> Void) throws { var iterator: io_iterator_t = 0 var interfaceRequest = interfaceRequest try withUnsafeMutablePointer(to: &interfaceRequest, { mutatingPointer in guard pointee.pointee.CreateInterfaceIterator(self, mutatingPointer, &iterator) == kIOReturnSuccess else { - return + throw UVCError.interfaceIteratorCreationFailed } defer { - let code: kern_return_t = IOObjectRelease(iterator) - assert( code == kIOReturnSuccess ) + if iterator != 0 { + IOObjectRelease(iterator) + } } while true { let object: io_service_t = IOIteratorNext(iterator) - defer { - let code: kern_return_t = IOObjectRelease(object) - assert( code == kIOReturnSuccess ) - } guard 0 < object else { break } + defer { IOObjectRelease(object) } try object.ioCreatePluginInterfaceFor(service: kIOUSBInterfaceUserClientTypeID, handle: handle) } diff --git a/UVC/Extensions/io_service_t+Interface.swift b/UVC/Extensions/io_service_t+Interface.swift index a57b173..03909f4 100644 --- a/UVC/Extensions/io_service_t+Interface.swift +++ b/UVC/Extensions/io_service_t+Interface.swift @@ -10,12 +10,15 @@ import Foundation extension io_service_t { func ioCreatePluginInterfaceFor(service: CFUUID, - handle: (PluginInterfacePointer) throws -> Void) rethrows { + handle: (PluginInterfacePointer) throws -> Void) throws { var ref: UnsafeMutablePointer?>? var score: Int32 = 0 guard IOCreatePlugInInterfaceForService(self, service, kIOCFPlugInInterfaceID, - &ref, &score) == kIOReturnSuccess, score == 0 else { return } - defer { _ = ref?.pointee?.pointee.Release(ref) } - try ref?.withMemoryRebound(to: UnsafeMutablePointer.self, capacity: 1, handle) + &ref, &score) == kIOReturnSuccess, + let ref = ref else { + throw UVCError.pluginInterfaceCreationFailed + } + defer { _ = ref.pointee?.pointee.Release(ref) } + try ref.withMemoryRebound(to: UnsafeMutablePointer.self, capacity: 1, handle) } } diff --git a/UVC/Models/UVCErrors.swift b/UVC/Models/UVCErrors.swift index 6bd5d1c..a9f5b27 100644 --- a/UVC/Models/UVCErrors.swift +++ b/UVC/Models/UVCErrors.swift @@ -11,4 +11,12 @@ import Foundation enum UVCError: Error { case requestError case invalidUnitId + case invalidInterfaceId + case invalidRequestLength + case noMatchingDevice + case pluginInterfaceCreationFailed + case interfaceIteratorCreationFailed + case deviceInterfaceUnavailable + case controlInterfaceUnavailable + case configurationDescriptorUnavailable }