Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@ on:
branches:
- master

permissions:
contents: read

jobs:
test:
name: Run tests
runs-on: macOS-latest

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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion CameraController/Devices/CaptureDevice.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
3 changes: 3 additions & 0 deletions CameraController/Devices/DeviceController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
11 changes: 6 additions & 5 deletions CameraController/Devices/DeviceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
31 changes: 28 additions & 3 deletions CameraController/Devices/Profile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
23 changes: 19 additions & 4 deletions CameraController/Managers/ProfileManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)")
}
}
}
27 changes: 25 additions & 2 deletions CameraController/Settings/UserSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -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
}
}
}
Loading