diff --git a/examples/device_observer_darwin/main.go b/examples/device_observer_darwin/main.go new file mode 100644 index 0000000..763e43e --- /dev/null +++ b/examples/device_observer_darwin/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/driver/camera" +) + +func main() { + fmt.Println("This example demonstrates query-based camera device discovery on Darwin.") + fmt.Println("The background observer automatically updates the manager's device list") + fmt.Println("when cameras are connected or disconnected.") + + // Calling StartObserver without calling SetupObserver prior implicitly calls SetupObserver + // due to state machine internals. We make SetupObserver and StartObserver distinct because + // not all downstream programs will want to start pumping the NSRunLoop to handle events immediately. + err := camera.StartObserver() + if err != nil { + fmt.Printf("failed to start observer: %v\n", err) + } + defer func() { + err := camera.DestroyObserver() + if err != nil { + fmt.Printf("failed to destroy observer: %v\n", err) + } + }() + + scanner := bufio.NewScanner(os.Stdin) + queryCount := 0 + + queryDevices(0) + + for { + fmt.Print("\nPress Enter to query (or 'q' to exit): ") + + if !scanner.Scan() { + break + } + + input := strings.TrimSpace(scanner.Text()) + if strings.ToLower(input) == "q" { + break + } + + queryCount++ + queryDevices(queryCount) + } +} + +func queryDevices(count int) { + if count > 0 { + fmt.Printf("Query #%d\n", count) + } + + devices := driver.GetManager().Query(driver.FilterVideoRecorder()) + + if len(devices) == 0 { + fmt.Println("No video devices found.") + } else { + fmt.Printf("Found %d video device(s):\n", len(devices)) + for i, d := range devices { + info := d.Info() + fmt.Printf(" %d. %s [%s]\n", i+1, info.Name, info.Label) + } + } +} diff --git a/pkg/avfoundation/AVFoundationBind/AVFoundationBind.m b/pkg/avfoundation/AVFoundationBind/AVFoundationBind.m index c4f9cd9..c317fe7 100644 --- a/pkg/avfoundation/AVFoundationBind/AVFoundationBind.m +++ b/pkg/avfoundation/AVFoundationBind/AVFoundationBind.m @@ -30,6 +30,12 @@ #import "AVFoundationBind.h" #include +// AVFoundationBind.m is the entry point for cgo compilation (included by avfoundation_darwin.go). +// Including DeviceObserver.m here compiles both into a single compilation unit, +// making all symbols available to the linker. +#include "DeviceObserver.m" + + #define CHK(condition, status) \ do { \ if(!(condition)) { \ diff --git a/pkg/avfoundation/AVFoundationBind/DeviceObserver.h b/pkg/avfoundation/AVFoundationBind/DeviceObserver.h new file mode 100644 index 0000000..f7be87c --- /dev/null +++ b/pkg/avfoundation/AVFoundationBind/DeviceObserver.h @@ -0,0 +1,68 @@ +// MIT License +// +// Copyright (c) 2019-2020 Pion +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +#ifndef DEVICEOBSERVER_H +#define DEVICEOBSERVER_H + +#include "AVFoundationBind.h" + +typedef const char* STATUS; + +typedef struct { + char uid[MAX_DEVICE_UID_CHARS + 1]; // +1 for null terminator + char name[MAX_DEVICE_NAME_CHARS + 1]; +} DeviceInfo; + +typedef enum { + DeviceEventConnected = 0, + DeviceEventDisconnected = 1 +} DeviceEventType; + +// Callback function type for device events +// userData: user-provided context pointer +// eventType: connected or disconnected +// device: device info +typedef void (*DeviceEventCallback)(void *userData, DeviceEventType eventType, DeviceInfo *device); + +// Initialize the device observer with a callback +// Returns NULL on success, error string on failure +STATUS DeviceObserverInit(DeviceEventCallback callback, void *userData); + +// Start observing device events (notifications will be delivered via the run loop) +STATUS DeviceObserverStart(void); + +// Stop observing device events +STATUS DeviceObserverStop(void); + +// Cleanup the device observer +STATUS DeviceObserverDestroy(void); + +// Get current list of video devices +// devices: output array (must have space for MAX_DEVICES) +// count: output count of devices found +STATUS DeviceObserverGetDevices(DeviceInfo *devices, int *count); + +// Run the run loop for a specified duration (in seconds) +// This allows the observer to receive notifications +STATUS DeviceObserverRunFor(double seconds); + +#endif // DEVICEOBSERVER_H diff --git a/pkg/avfoundation/AVFoundationBind/DeviceObserver.m b/pkg/avfoundation/AVFoundationBind/DeviceObserver.m new file mode 100644 index 0000000..13ba4bb --- /dev/null +++ b/pkg/avfoundation/AVFoundationBind/DeviceObserver.m @@ -0,0 +1,260 @@ +// MIT License +// +// Copyright (c) 2019-2020 Pion +// +// 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: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// 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. + +// Naming Convention (let "name" as an actual variable name): +// - mName: "name" is a member of an Objective C object +// - pName: "name" is a C pointer +// - refName: "name" is an Objective C object reference + +#import +#import +#import +#import "DeviceObserver.h" + + +extern void goDeviceEventCallback(void *pUserData, int eventType, DeviceInfo *pDevice); + +void deviceEventBridge(void *pUserData, DeviceEventType eventType, DeviceInfo *pDevice) { + goDeviceEventCallback(pUserData, (int)eventType, pDevice); +} + +@interface DeviceObserverDelegate : NSObject { + DeviceEventCallback mCallback; + void *mUserData; + AVCaptureDeviceDiscoverySession *mDiscoverySession; + BOOL mObserving; +} +@end + +@implementation DeviceObserverDelegate + +- (instancetype)initWithCallback:(DeviceEventCallback)callback userData:(void *)pUserData { + self = [super init]; + if (self) { + mCallback = callback; + mUserData = pUserData; + mObserving = NO; + + NSArray *refDeviceTypes = @[ + AVCaptureDeviceTypeBuiltInWideAngleCamera, + AVCaptureDeviceTypeExternal + ]; + + mDiscoverySession = [[AVCaptureDeviceDiscoverySession + discoverySessionWithDeviceTypes:refDeviceTypes + mediaType:AVMediaTypeVideo + position:AVCaptureDevicePositionUnspecified] retain]; + } + return self; +} + +- (void)startObserving { + if (mObserving) return; + + [mDiscoverySession addObserver:self + forKeyPath:@"devices" + options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew) + context:nil]; + + mObserving = YES; +} + +- (void)stopObserving { + if (!mObserving) return; + + [mDiscoverySession removeObserver:self forKeyPath:@"devices"]; + mObserving = NO; +} + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)pContext { + + if (![keyPath isEqualToString:@"devices"]) return; + + NSArray *refOldDevices = change[NSKeyValueChangeOldKey]; + NSArray *refNewDevices = change[NSKeyValueChangeNewKey]; + + if ([refOldDevices isKindOfClass:[NSNull class]]) refOldDevices = @[]; + if ([refNewDevices isKindOfClass:[NSNull class]]) refNewDevices = @[]; + + // Build sets of device UIDs for comparison + NSMutableSet *refOldUIDs = [NSMutableSet set]; + NSMutableDictionary *refOldDeviceMap = [NSMutableDictionary dictionary]; + for (AVCaptureDevice *refDevice in refOldDevices) { + [refOldUIDs addObject:refDevice.uniqueID]; + refOldDeviceMap[refDevice.uniqueID] = refDevice; + } + + NSMutableSet *refNewUIDs = [NSMutableSet set]; + NSMutableDictionary *refNewDeviceMap = [NSMutableDictionary dictionary]; + for (AVCaptureDevice *refDevice in refNewDevices) { + [refNewUIDs addObject:refDevice.uniqueID]; + refNewDeviceMap[refDevice.uniqueID] = refDevice; + } + + // Find added devices + NSMutableSet *refAddedUIDs = [refNewUIDs mutableCopy]; + [refAddedUIDs minusSet:refOldUIDs]; + + // Find removed devices + NSMutableSet *refRemovedUIDs = [refOldUIDs mutableCopy]; + [refRemovedUIDs minusSet:refNewUIDs]; + + // Notify about added devices + for (NSString *uid in refAddedUIDs) { + AVCaptureDevice *refDevice = refNewDeviceMap[uid]; + DeviceInfo info; + memset(&info, 0, sizeof(info)); + strlcpy(info.uid, refDevice.uniqueID.UTF8String, sizeof(info.uid)); + strlcpy(info.name, refDevice.localizedName.UTF8String, sizeof(info.name)); + + if (mCallback) { + mCallback(mUserData, DeviceEventConnected, &info); + } + } + + // Notify about removed devices + for (NSString *uid in refRemovedUIDs) { + AVCaptureDevice *refDevice = refOldDeviceMap[uid]; + DeviceInfo info; + memset(&info, 0, sizeof(info)); + strlcpy(info.uid, refDevice.uniqueID.UTF8String, sizeof(info.uid)); + strlcpy(info.name, refDevice.localizedName.UTF8String, sizeof(info.name)); + + if (mCallback) { + mCallback(mUserData, DeviceEventDisconnected, &info); + } + } + + [refAddedUIDs release]; + [refRemovedUIDs release]; +} + +- (void)dealloc { + [self stopObserving]; + [mDiscoverySession release]; + [super dealloc]; +} + +@end + +// Global observer instance +static DeviceObserverDelegate *refObserver = nil; + +STATUS DeviceObserverInit(DeviceEventCallback callback, void *pUserData) { + @autoreleasepool { + if (refObserver != nil) { + return "observer already initialized"; + } + + refObserver = [[DeviceObserverDelegate alloc] initWithCallback:callback userData:pUserData]; + if (refObserver == nil) { + return "failed to create observer"; + } + + return STATUS_OK; + } +} + +STATUS DeviceObserverStart(void) { + @autoreleasepool { + if (refObserver == nil) { + return "observer not initialized"; + } + + [refObserver startObserving]; + return STATUS_OK; + } +} + +STATUS DeviceObserverStop(void) { + @autoreleasepool { + if (refObserver == nil) { + return "observer not initialized"; + } + + [refObserver stopObserving]; + return STATUS_OK; + } +} + +STATUS DeviceObserverDestroy(void) { + @autoreleasepool { + if (refObserver == nil) { + return "observer not initialized"; + } + + [refObserver stopObserving]; + [refObserver release]; + refObserver = nil; + + return STATUS_OK; + } +} + +STATUS DeviceObserverGetDevices(DeviceInfo *pDevices, int *pCount) { + @autoreleasepool { + if (pDevices == NULL || pCount == NULL) { + return "invalid arguments"; + } + + // Use discovery session for device enumeration + NSArray *refDeviceTypes = @[ + AVCaptureDeviceTypeBuiltInWideAngleCamera, + AVCaptureDeviceTypeExternal + ]; + + AVCaptureDeviceDiscoverySession *refSession = [AVCaptureDeviceDiscoverySession + discoverySessionWithDeviceTypes:refDeviceTypes + mediaType:AVMediaTypeVideo + position:AVCaptureDevicePositionUnspecified]; + + int i = 0; + for (AVCaptureDevice *refDevice in refSession.devices) { + if (i >= MAX_DEVICES) break; + + memset(&pDevices[i], 0, sizeof(DeviceInfo)); + strlcpy(pDevices[i].uid, refDevice.uniqueID.UTF8String, sizeof(pDevices[i].uid)); + strlcpy(pDevices[i].name, refDevice.localizedName.UTF8String, sizeof(pDevices[i].name)); + i++; + } + + *pCount = i; + return STATUS_OK; + } +} + +STATUS DeviceObserverRunFor(double seconds) { + @autoreleasepool { + // Add a timer to keep the run loop alive + NSTimer *refTimer = [NSTimer scheduledTimerWithTimeInterval:seconds + target:[NSDate class] + selector:@selector(date) + userInfo:nil + repeats:NO]; + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:seconds]]; + [refTimer invalidate]; + return STATUS_OK; + } +} diff --git a/pkg/avfoundation/device_observer_darwin.go b/pkg/avfoundation/device_observer_darwin.go new file mode 100644 index 0000000..4f81a5d --- /dev/null +++ b/pkg/avfoundation/device_observer_darwin.go @@ -0,0 +1,386 @@ +package avfoundation + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreMedia -framework CoreVideo +#include +#include +#include "AVFoundationBind/DeviceObserver.h" + +extern void deviceEventBridge(void *userData, DeviceEventType eventType, DeviceInfo *device); + +static const char* DeviceObserverInitWithBridge() { + return DeviceObserverInit(deviceEventBridge, NULL); +} +*/ +import "C" +import ( + "fmt" + "runtime" + "sync" + "unsafe" +) + +type observerStateType int + +const ( + observerInitial observerStateType = iota + observerSetup // KVO initialized on main thread but not pumping run loop + observerStarting // Starting run loop (transitioning to running) + observerRunning // Run loop is actively pumping + observerDestroyed // Destroyed and cannot be restarted +) + +// deviceObserver manages the AVFoundation device observer lifecycle with the singleton pattern. +// The observer is single-use. Once DestroyObserver is called, it cannot be restarted. +type deviceObserver struct { + // Signals observer to transition to the startup state + signalStart chan struct{} + // Signals observer to destroy and stop pumping the NSRunLoop in the bg routine (if running) + signalDestroy chan struct{} + // Closed when setup state logic completes. + setupDone chan struct{} + // Closed when startup state logic completes. + startDone chan struct{} + // Coordinates waiting for the observer goroutine to complete + wg sync.WaitGroup + + // mu protects all below state fields. + // Must not be held when invoking user callbacks to avoid deadlock (double lock acquisition). + mu sync.Mutex + deviceCache map[string]Device + state observerStateType + onDeviceChange func(Device, DeviceEventType) + setupErr error +} + +var ( + observerSingleton *deviceObserver + observerSingletonOnce sync.Once +) + +func getObserver() *deviceObserver { + observerSingletonOnce.Do(func() { + observerSingleton = &deviceObserver{ + deviceCache: make(map[string]Device), + state: observerInitial, + } + }) + return observerSingleton +} + +type DeviceEventType int + +const ( + DeviceEventConnected DeviceEventType = C.DeviceEventConnected + DeviceEventDisconnected DeviceEventType = C.DeviceEventDisconnected +) + +func SetOnDeviceChange(f func(Device, DeviceEventType)) { + obs := getObserver() + obs.mu.Lock() + defer obs.mu.Unlock() + obs.onDeviceChange = f +} + +func createDevice(uid, name string) Device { + var d Device + d.UID = uid + d.Name = name + + // Copy strings to C char arrays + cUID := C.CString(uid) + defer C.free(unsafe.Pointer(cUID)) + C.strncpy(&d.cDevice.uid[0], cUID, C.MAX_DEVICE_UID_CHARS) + d.cDevice.uid[C.MAX_DEVICE_UID_CHARS] = 0 + + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + C.strncpy(&d.cDevice.name[0], cName, C.MAX_DEVICE_NAME_CHARS) + d.cDevice.name[C.MAX_DEVICE_NAME_CHARS] = 0 + + return d +} + +//export goDeviceEventCallback +func goDeviceEventCallback(userData unsafe.Pointer, eventType C.int, device *C.DeviceInfo) { + uid := C.GoString(&device.uid[0]) + name := C.GoString(&device.name[0]) + + d := createDevice(uid, name) + et := DeviceEventType(eventType) + + obs := getObserver() + obs.mu.Lock() + if eventType == C.DeviceEventConnected { + obs.deviceCache[uid] = d + } else if eventType == C.DeviceEventDisconnected { + delete(obs.deviceCache, uid) + } + cb := obs.onDeviceChange + obs.mu.Unlock() + + if cb != nil { + cb(d, et) + } +} + +// setup initializes the device observer and starts a goroutine locked to a thread for NSRunLoop, +// but does not begin pumping the run loop yet. The goroutine waits idle until start is called. +// This function assumes the caller invoked it from the main thread to set up AVFoundation KVO properly. +func (obs *deviceObserver) setup() error { + obs.mu.Lock() + + switch obs.state { + case observerInitial: + // Continue with setup + case observerSetup, observerStarting, observerRunning: + // Already setup or beyond + obs.mu.Unlock() + return nil + case observerDestroyed: + obs.mu.Unlock() + return fmt.Errorf("device observer is single-use and was destroyed, so it cannot be restarted") + } + + if obs.setupDone != nil { + done := obs.setupDone + obs.mu.Unlock() + <-done + obs.mu.Lock() + err := obs.setupErr + obs.mu.Unlock() + return err + } + + // We're first to setup, initialize the channels + obs.signalStart = make(chan struct{}) + obs.signalDestroy = make(chan struct{}) + obs.setupDone = make(chan struct{}) + obs.startDone = make(chan struct{}) + obs.setupErr = nil + obs.wg.Add(1) + obs.mu.Unlock() + + go func() { + defer obs.wg.Done() + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var err error + if status := C.DeviceObserverInitWithBridge(); status != nil { + err = fmt.Errorf("failed to init observer: %s", C.GoString(status)) + } else if status := C.DeviceObserverStart(); status != nil { + C.DeviceObserverDestroy() // remember to clean up C objects on error + err = fmt.Errorf("failed to start observer: %s", C.GoString(status)) + } + + if err != nil { + obs.mu.Lock() + obs.state = observerInitial + obs.setupErr = err + obs.mu.Unlock() + close(obs.setupDone) + return + } + + // Populate device cache and prepare initial device list for callbacks + var devices [C.MAX_DEVICES]C.DeviceInfo + var count C.int + status := C.DeviceObserverGetDevices(&devices[0], &count) + var initialDevices []Device + obs.mu.Lock() + if status == nil { + obs.deviceCache = make(map[string]Device) + for i := 0; i < int(count); i++ { + uid := C.GoString(&devices[i].uid[0]) + name := C.GoString(&devices[i].name[0]) + dev := createDevice(uid, name) + obs.deviceCache[uid] = dev + initialDevices = append(initialDevices, dev) + } + } + obs.state = observerSetup + obs.mu.Unlock() + + close(obs.setupDone) + + // STATE BOUNDARY: setup phase complete, now entering startup phase + obs.waitForStartAndRun(initialDevices) + }() + + <-obs.setupDone // waits for goroutine to complete setup + obs.mu.Lock() + err := obs.setupErr + obs.mu.Unlock() + return err +} + +// waitForStartAndRun waits for the start signal, then transitions to running state +// and pumps the NSRunLoop. +func (obs *deviceObserver) waitForStartAndRun(initialDevices []Device) { + // Wait for signal to start pumping or destroy + select { + case <-obs.signalDestroy: + C.DeviceObserverStop() + C.DeviceObserverDestroy() + close(obs.startDone) + return + case <-obs.signalStart: + // Transition to running + } + + obs.mu.Lock() + cb := obs.onDeviceChange + obs.state = observerRunning + obs.mu.Unlock() + + close(obs.startDone) + + // Replay current devices + if cb != nil { + for _, dev := range initialDevices { + cb(dev, DeviceEventConnected) + } + } + + // STATE BOUNDARY: startup -> running + for { + select { + case <-obs.signalDestroy: + // STATE BOUNDARY: running -> destroyed + C.DeviceObserverStop() + C.DeviceObserverDestroy() + return + default: + C.DeviceObserverRunFor(0.1) + } + } +} + +// start signals the observer goroutine to begin pumping the run loop. +func (obs *deviceObserver) start() error { + obs.mu.Lock() + + for { + switch obs.state { + case observerInitial: + // Need to setup first + obs.mu.Unlock() + if err := obs.setup(); err != nil { + return err + } + obs.mu.Lock() + continue // re-check state as it may have changed by another goroutine e.g. destroyed + case observerStarting: + // Another goroutine is starting the run loop; wait on same result + done := obs.startDone + obs.mu.Unlock() + <-done + return nil + case observerRunning: + obs.mu.Unlock() + return nil + case observerDestroyed: + obs.mu.Unlock() + return fmt.Errorf("cannot start observer: observer has been destroyed and cannot be restarted") + case observerSetup: + // Proceed to signal start + } + break + } + + obs.state = observerStarting + pump := obs.signalStart + obs.mu.Unlock() + + close(pump) + + <-obs.startDone + return nil +} + +// destroy destroys the device observer and releases all C/Objective-C resources. +// The observer cannot be restarted after being destroyed. +func (obs *deviceObserver) destroy() error { + obs.mu.Lock() + + for { + switch obs.state { + case observerInitial: + obs.state = observerDestroyed + destroy := obs.signalDestroy + obs.mu.Unlock() + if destroy != nil { // may be nil if setup wasn't called + close(destroy) + obs.wg.Wait() + } + return nil + case observerDestroyed: + obs.mu.Unlock() + return nil + case observerSetup, observerRunning: + // Set state to destroyed before unlocking to prevent concurrent destroy + obs.state = observerDestroyed + case observerStarting: + // Wait for transition to running + done := obs.startDone + obs.mu.Unlock() + <-done + obs.mu.Lock() // lock and check state again + continue + } + break + } + + destroy := obs.signalDestroy + obs.mu.Unlock() + + close(destroy) + obs.wg.Wait() + + return nil +} + +// SetupObserver initializes the device observer and starts a goroutine +// locked to a thread for NSRunLoop, but does not begin pumping the run loop yet. +// The goroutine waits idle until StartObserver is called, avoiding CPU overhead. +// Safe to call concurrently and idempotently. +func SetupObserver() error { + return getObserver().setup() +} + +// StartObserver signals the observer goroutine to begin pumping the run loop. +// If SetupObserver has not been called, StartObserver will call it first. +// Safe to call concurrently and idempotently. +func StartObserver() error { + return getObserver().start() +} + +// DestroyObserver destroys the device observer and releases all C/Objective-C resources. +// The observer is single-use and cannot be restarted after being destroyed. +// Safe to call concurrently and idempotently. +func DestroyObserver() error { + return getObserver().destroy() +} + +// LookupCachedDevice returns the cached device that matches the provided UID. +// The returned boolean indicates whether the device was present in the cache. +// Callers should verify IsObserverRunning before relying on the result. +func LookupCachedDevice(uid string) (Device, bool) { + obs := getObserver() + obs.mu.Lock() + defer obs.mu.Unlock() + + dev, ok := obs.deviceCache[uid] + return dev, ok +} + +// IsObserverRunning reports whether the device observer has successfully started +// and populated the in-memory cache. +func IsObserverRunning() bool { + obs := getObserver() + obs.mu.Lock() + defer obs.mu.Unlock() + return obs.state == observerRunning +} diff --git a/pkg/avfoundation/device_observer_test.go b/pkg/avfoundation/device_observer_test.go new file mode 100644 index 0000000..0ea3d6c --- /dev/null +++ b/pkg/avfoundation/device_observer_test.go @@ -0,0 +1,297 @@ +//go:build darwin +// +build darwin + +package avfoundation + +import ( + "testing" +) + +// TestGetObserverSingleton tests that getObserver returns the same instance. +func TestGetObserverSingleton(t *testing.T) { + obs1 := getObserver() + obs2 := getObserver() + + if obs1 != obs2 { + t.Error("getObserver() should return the same singleton instance") + } + + if obs1.deviceCache == nil { + t.Error("Observer device cache should be initialized") + } + + if obs1.state != observerInitial { + t.Errorf("Initial observer state should be observerInitial, got: %v", obs1.state) + } +} + +// TestCreateDevice tests device creation with UID and name. +func TestCreateDevice(t *testing.T) { + testCases := []struct { + name string + uid string + devName string + }{ + { + name: "simple device", + uid: "test-uid-123", + devName: "Test Camera", + }, + { + name: "device with special characters", + uid: "camera_0x1234567890abcdef", + devName: "FaceTime HD Camera", + }, + { + name: "empty strings", + uid: "", + devName: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + device := createDevice(tc.uid, tc.devName) + + if device.UID != tc.uid { + t.Errorf("Expected UID %q, got %q", tc.uid, device.UID) + } + + if device.Name != tc.devName { + t.Errorf("Expected Name %q, got %q", tc.devName, device.Name) + } + }) + } +} + +// TestSetOnDeviceChange tests setting and retrieving the device change callback. +func TestSetOnDeviceChange(t *testing.T) { + // Reset observer state for clean test (hacky) + // In production, the observer is a singleton + obs := getObserver() + obs.mu.Lock() + originalCallback := obs.onDeviceChange + obs.mu.Unlock() + + // Restore original callback at end of test + defer func() { + obs.mu.Lock() + obs.onDeviceChange = originalCallback + obs.mu.Unlock() + }() + + called := false + var capturedDevice Device + var capturedEvent DeviceEventType + + SetOnDeviceChange(func(d Device, e DeviceEventType) { + called = true + capturedDevice = d + capturedEvent = e + }) + + // Verify callback was set + obs.mu.Lock() + if obs.onDeviceChange == nil { + t.Fatal("OnDeviceChange callback was not set") + } + + // Manually trigger callback for testing + testDevice := createDevice("test-uid", "test-name") + testEvent := DeviceEventConnected + cb := obs.onDeviceChange + obs.mu.Unlock() + + if cb != nil { + cb(testDevice, testEvent) + } + + if !called { + t.Error("Callback was not invoked") + } + + if capturedDevice.UID != "test-uid" { + t.Errorf("Expected captured UID %q, got %q", "test-uid", capturedDevice.UID) + } + + if capturedEvent != DeviceEventConnected { + t.Errorf("Expected event %v, got %v", DeviceEventConnected, capturedEvent) + } +} + +// TestLookupCachedDevice tests device cache lookups. +func TestLookupCachedDevice(t *testing.T) { + obs := getObserver() + + // Add a test device to cache + testUID := "lookup-test-uid" + testDevice := createDevice(testUID, "Lookup Test Camera") + + obs.mu.Lock() + obs.deviceCache[testUID] = testDevice + obs.mu.Unlock() + + // Test successful lookup + device, ok := LookupCachedDevice(testUID) + if !ok { + t.Error("Expected to find device in cache") + } + + if device.UID != testUID { + t.Errorf("Expected UID %q, got %q", testUID, device.UID) + } + + // Test failed lookup + _, ok = LookupCachedDevice("non-existent-uid") + if ok { + t.Error("Expected not to find non-existent device in cache") + } + + // Cleanup + obs.mu.Lock() + delete(obs.deviceCache, testUID) + obs.mu.Unlock() +} + +// TestIsObserverRunning tests the observer running state check. +func TestIsObserverRunning(t *testing.T) { + obs := getObserver() + + // Initially should not be running + obs.mu.Lock() + originalState := obs.state + obs.state = observerInitial + obs.mu.Unlock() + + // Restore original state at end + defer func() { + obs.mu.Lock() + obs.state = originalState + obs.mu.Unlock() + }() + + if IsObserverRunning() { + t.Error("Observer should not be running in initial state") + } + + // Set state to running + obs.mu.Lock() + obs.state = observerRunning + obs.mu.Unlock() + + if !IsObserverRunning() { + t.Error("Observer should be running after state set to observerRunning") + } + + // Set state to other states + for _, state := range []observerStateType{observerSetup, observerStarting, observerDestroyed} { + obs.mu.Lock() + obs.state = state + obs.mu.Unlock() + + if IsObserverRunning() { + t.Errorf("Observer should not be running in state %v", state) + } + } +} + +// TestGoDeviceEventCallback tests the C-to-Go device event callback. +func TestGoDeviceEventCallback(t *testing.T) { + obs := getObserver() + + // Clear device cache for clean test + obs.mu.Lock() + obs.deviceCache = make(map[string]Device) + originalCallback := obs.onDeviceChange + obs.mu.Unlock() + + defer func() { + obs.mu.Lock() + obs.onDeviceChange = originalCallback + obs.deviceCache = make(map[string]Device) + obs.mu.Unlock() + }() + + // Set up test callback + var callbackInvoked bool + var capturedDevice Device + var capturedEvent DeviceEventType + + SetOnDeviceChange(func(d Device, e DeviceEventType) { + callbackInvoked = true + capturedDevice = d + capturedEvent = e + }) + + // Note: We cannot directly call goDeviceEventCallback with C types in a Go test + // without CGO setup. Instead, we test the logic that would be executed. + + // Simulate connect event + testUID := "callback-test-uid" + testDevice := createDevice(testUID, "Callback Test Camera") + + obs.mu.Lock() + obs.deviceCache[testUID] = testDevice + cb := obs.onDeviceChange + obs.mu.Unlock() + + if cb != nil { + cb(testDevice, DeviceEventConnected) + } + + if !callbackInvoked { + t.Error("User callback should have been invoked") + } + + if capturedEvent != DeviceEventConnected { + t.Errorf("Expected DeviceEventConnected, got %v", capturedEvent) + } + + // Verify device was added to cache + obs.mu.Lock() + _, exists := obs.deviceCache[testUID] + obs.mu.Unlock() + + if !exists { + t.Error("Device should be in cache after connect event") + } + + // Simulate disconnect event + callbackInvoked = false + obs.mu.Lock() + delete(obs.deviceCache, testUID) + cb = obs.onDeviceChange + obs.mu.Unlock() + + if cb != nil { + cb(testDevice, DeviceEventDisconnected) + } + + if !callbackInvoked { + t.Error("User callback should have been invoked for disconnect") + } + + if capturedEvent != DeviceEventDisconnected { + t.Errorf("Expected DeviceEventDisconnected, got %v", capturedEvent) + } + + if capturedDevice.UID != testUID { + t.Errorf("Expected captured device UID %q, got %q", testUID, capturedDevice.UID) + } + + // Verify device was removed from cache + obs.mu.Lock() + _, exists = obs.deviceCache[testUID] + obs.mu.Unlock() + + if exists { + t.Error("Device should not be in cache after disconnect event") + } +} + +// TestDeviceEventTypes tests the device event type constants, verifying that they are different. +func TestDeviceEventTypes(t *testing.T) { + if DeviceEventConnected == DeviceEventDisconnected { + t.Error("DeviceEventConnected and DeviceEventDisconnected should be different") + } +} diff --git a/pkg/driver/availability/error.go b/pkg/driver/availability/error.go index ef66ff4..cf2b2ee 100644 --- a/pkg/driver/availability/error.go +++ b/pkg/driver/availability/error.go @@ -5,9 +5,10 @@ import ( ) var ( - ErrUnimplemented = NewError("not implemented") - ErrBusy = NewError("device or resource busy") - ErrNoDevice = NewError("no such device") + ErrObserverUnavailable = NewError("observer unavailable (not started or destroyed)") + ErrUnimplemented = NewError("not implemented") + ErrBusy = NewError("device or resource busy") + ErrNoDevice = NewError("no such device") ) type errorString struct { diff --git a/pkg/driver/camera/camera_darwin.go b/pkg/driver/camera/camera_darwin.go index 5acf66f..5d07d93 100644 --- a/pkg/driver/camera/camera_darwin.go +++ b/pkg/driver/camera/camera_darwin.go @@ -9,6 +9,7 @@ import ( "github.com/pion/mediadevices/pkg/avfoundation" "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/driver/availability" "github.com/pion/mediadevices/pkg/frame" "github.com/pion/mediadevices/pkg/io/video" "github.com/pion/mediadevices/pkg/prop" @@ -44,6 +45,72 @@ func Initialize() { } } +// SetupObserver initializes the device observer on the main thread without starting monitoring. +// This allows setup on the main thread (required by macOS) without CPU overhead until StartObserver is called. +// The caller must invoke SetupObserver from the main thread for proper NSRunLoop setup. +// Safe to call concurrently and idempotent; multiple calls are no-ops if already setup. +func SetupObserver() error { + manager := driver.GetManager() + + avfoundation.SetOnDeviceChange(func(device avfoundation.Device, event avfoundation.DeviceEventType) { + switch event { + case avfoundation.DeviceEventConnected: + drivers := manager.Query(func(d driver.Driver) bool { + return d.Info().Label == device.UID + }) + if len(drivers) > 0 { + return + } + + cam := newCamera(device) + manager.Register(cam, driver.Info{ + Label: device.UID, + DeviceType: driver.Camera, + Name: device.Name, + }) + + case avfoundation.DeviceEventDisconnected: + drivers := manager.Query(func(d driver.Driver) bool { + return d.Info().Label == device.UID + }) + for _, d := range drivers { + status := d.Status() + if status != driver.StateClosed { + if err := d.Close(); err != nil { + } + } + manager.Delete(d.ID()) + } + } + }) + + return avfoundation.SetupObserver() +} + +// StartObserver starts the background observer to monitor for device changes. +// If SetupObserver has not been called, StartObserver will call it first. +// Safe to call concurrently and idempotently. +func StartObserver() error { + // Call SetupObserver first to ensure SetOnDeviceChange callback is registered. + // This is safe as observer methods are idempotent and handle concurrency. + if err := SetupObserver(); err != nil { + return err + } + + if err := avfoundation.StartObserver(); err != nil { + return err + } + + return syncVideoRecorders(driver.GetManager()) +} + +// DestroyObserver destroys the device observer and releases all resources. +// The observer is single-use and cannot be restarted after being destroyed. +// Safe to call concurrently and idempotently. +func DestroyObserver() error { + return avfoundation.DestroyObserver() +} + func newCamera(device avfoundation.Device) *camera { return &camera{ device: device, @@ -57,15 +124,20 @@ func (cam *camera) Open() error { } func (cam *camera) Close() error { - if cam.rcClose != nil { - cam.rcClose() - } - if cam.cancel != nil { cam.cancel() + cam.cancel = nil } - - return cam.session.Close() + if cam.rcClose != nil { + cam.rcClose() + cam.rcClose = nil + } + if cam.session != nil { + err := cam.session.Close() + cam.session = nil + return err + } + return nil } func (cam *camera) VideoRecord(property prop.Media) (video.Reader, error) { @@ -106,3 +178,68 @@ func (cam *camera) VideoRecord(property prop.Media) (video.Reader, error) { func (cam *camera) Properties() []prop.Media { return cam.session.Properties() } + +func (cam *camera) IsAvailable() (bool, error) { + if !avfoundation.IsObserverRunning() { + return false, availability.ErrObserverUnavailable + } + + if _, ok := avfoundation.LookupCachedDevice(cam.device.UID); !ok { + return false, availability.ErrNoDevice + } + + // Probe device availability by attempting to open a session + session, err := avfoundation.NewSession(cam.device) + if err != nil { + return false, availability.ErrBusy + } + if session == nil { + panic("session was nil while error was nil") + } + session.Close() + + return true, nil +} + +// syncVideoRecorders keeps the manager in lockstep with the hardware before the first user query. +func syncVideoRecorders(manager *driver.Manager) error { + devices, err := avfoundation.Devices(avfoundation.Video) + if err != nil { + return err + } + + current := make(map[string]struct{}, len(devices)) + for _, device := range devices { + current[device.UID] = struct{}{} + } + + registered := manager.Query(driver.FilterVideoRecorder()) + registeredByLabel := make(map[string]struct{}, len(registered)) + + // drop any registered drivers whose UID isn't currently present + for _, d := range registered { + label := d.Info().Label + registeredByLabel[label] = struct{}{} + if _, ok := current[label]; !ok { + manager.Delete(d.ID()) + delete(registeredByLabel, label) + } + } + + // register any new devices that appeared between the init() call and the observer start + for _, device := range devices { + if _, ok := registeredByLabel[device.UID]; ok { + continue + } + + cam := newCamera(device) + manager.Register(cam, driver.Info{ + Label: device.UID, + DeviceType: driver.Camera, + Name: device.Name, + }) + registeredByLabel[device.UID] = struct{}{} + } + + return nil +} diff --git a/pkg/driver/camera/camera_darwin_test.go b/pkg/driver/camera/camera_darwin_test.go index c2b312b..db8c369 100644 --- a/pkg/driver/camera/camera_darwin_test.go +++ b/pkg/driver/camera/camera_darwin_test.go @@ -1,3 +1,4 @@ +//go:build darwin // +build darwin // $ go test -v . -tags darwin -run="^TestCameraFrameFormatSupport$" @@ -8,6 +9,8 @@ import ( "testing" "github.com/pion/mediadevices/pkg/avfoundation" + "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/driver/availability" "github.com/pion/mediadevices/pkg/frame" "github.com/pion/mediadevices/pkg/prop" ) @@ -61,3 +64,138 @@ func TestCameraFrameFormatSupport(t *testing.T) { } } } + +// TestCameraCloseIdempotency tests that Close can be called multiple times safely. +func TestCameraCloseIdempotency(t *testing.T) { + devices, err := avfoundation.Devices(avfoundation.Video) + if err != nil { + t.Fatal(err) + } + + if len(devices) == 0 { + t.Skip("No video devices available for testing") + } + + cam := newCamera(devices[0]) + if err := cam.Open(); err != nil { + t.Fatal(err) + } + + // Close multiple times should not error + for i := 0; i < 3; i++ { + if err := cam.Close(); err != nil { + t.Errorf("Close call %d failed: %v", i+1, err) + } + } + + // Verify internal state was cleared + if cam.session != nil { + t.Error("Session should be nil after close") + } + if cam.rcClose != nil { + t.Error("rcClose should be nil after close") + } + if cam.cancel != nil { + t.Error("cancel should be nil after close") + } +} + +// TestCameraIsAvailableObserverNotRunning tests IsAvailable when observer is not running. +func TestCameraIsAvailableObserverNotRunning(t *testing.T) { + devices, err := avfoundation.Devices(avfoundation.Video) + if err != nil { + t.Fatal(err) + } + + if len(devices) == 0 { + t.Skip("No video devices available for testing") + } + + cam := newCamera(devices[0]) + + available, err := cam.IsAvailable() + if available { + t.Error("Camera should not be available when observer is not running") + } + + if err != availability.ErrObserverUnavailable { + t.Errorf("Expected ErrObserverUnavailable, got: %v", err) + } +} + +// TestNewCamera tests camera constructor. +func TestNewCamera(t *testing.T) { + testDevice := avfoundation.Device{ + UID: "test-uid", + Name: "Test Camera", + } + + cam := newCamera(testDevice) + + if cam == nil { + t.Fatal("newCamera returned nil") + } + + if cam.device.UID != testDevice.UID { + t.Errorf("Expected device UID %q, got %q", testDevice.UID, cam.device.UID) + } + + if cam.device.Name != testDevice.Name { + t.Errorf("Expected device name %q, got %q", testDevice.Name, cam.device.Name) + } +} + +// TestSyncVideoRecorders tests the syncVideoRecorders function. +func TestSyncVideoRecorders(t *testing.T) { + manager := driver.GetManager() + + // Initial state + initialDrivers := manager.Query(driver.FilterVideoRecorder()) + initialCount := len(initialDrivers) + + // Run sync + err := syncVideoRecorders(manager) + if err != nil { + t.Fatalf("syncVideoRecorders failed: %v", err) + } + + // Verify drivers were synced + afterDrivers := manager.Query(driver.FilterVideoRecorder()) + afterCount := len(afterDrivers) + + // The count should match the actual devices available + devices, err := avfoundation.Devices(avfoundation.Video) + if err != nil { + t.Fatal(err) + } + + if afterCount != len(devices) { + t.Logf("Warning: Expected %d drivers after sync, got %d (initial: %d)", + len(devices), afterCount, initialCount) + } +} + +// TestObserverFunctionsIdempotent tests that observer functions can be called multiple times. +func TestObserverFunctionsIdempotent(t *testing.T) { + // This test may have side effects on the global observer state + // In a real scenario, you'd want to reset the observer between tests + + // SetupObserver should be idempotent + for i := 0; i < 2; i++ { + if err := SetupObserver(); err != nil { + t.Errorf("SetupObserver call %d failed: %v", i+1, err) + } + } + + // StartObserver should be idempotent + for i := 0; i < 2; i++ { + if err := StartObserver(); err != nil { + t.Errorf("StartObserver call %d failed: %v", i+1, err) + } + } + + // Cleanup + if err := DestroyObserver(); err != nil { + t.Errorf("DestroyObserver failed: %v", err) + } +} diff --git a/pkg/driver/camera/camera_linux.go b/pkg/driver/camera/camera_linux.go index 91c9dc0..8cfe287 100644 --- a/pkg/driver/camera/camera_linux.go +++ b/pkg/driver/camera/camera_linux.go @@ -98,6 +98,21 @@ func Initialize() { discover(discovered, "/dev/video*") } +// SetupObserver is a stub implementation for Linux. +func SetupObserver() error { + return availability.ErrUnimplemented +} + +// StartObserver is a stub implementation for Linux. +func StartObserver() error { + return availability.ErrUnimplemented +} + +// DestroyObserver is a stub implementation for Linux. +func DestroyObserver() error { + return availability.ErrUnimplemented +} + func discover(discovered map[string]struct{}, pattern string) { devices, err := filepath.Glob(pattern) if err != nil { diff --git a/pkg/driver/camera/camera_stubs_test.go b/pkg/driver/camera/camera_stubs_test.go new file mode 100644 index 0000000..8cf138a --- /dev/null +++ b/pkg/driver/camera/camera_stubs_test.go @@ -0,0 +1,52 @@ +//go:build linux || windows + +package camera + +import ( + "errors" + "testing" + + "github.com/pion/mediadevices/pkg/driver/availability" +) + +// TestSetupObserver tests the stub implementation of SetupObserver. +func TestSetupObserver(t *testing.T) { + err := SetupObserver() + if !errors.Is(err, availability.ErrUnimplemented) { + t.Errorf("SetupObserver() should return ErrUnimplemented for stub implementation, got: %v", err) + } +} + +// TestStartObserver tests the stub implementation of StartObserver. +func TestStartObserver(t *testing.T) { + err := StartObserver() + if !errors.Is(err, availability.ErrUnimplemented) { + t.Errorf("StartObserver() should return ErrUnimplemented for stub implementation, got: %v", err) + } +} + +// TestDestroyObserver tests the stub implementation of DestroyObserver. +func TestDestroyObserver(t *testing.T) { + err := DestroyObserver() + if !errors.Is(err, availability.ErrUnimplemented) { + t.Errorf("DestroyObserver() should return ErrUnimplemented for stub implementation, got: %v", err) + } +} + +// TestObserverFunctionsIdempotent tests that observer functions can be called multiple times safely. +func TestObserverFunctionsIdempotent(t *testing.T) { + for i := 0; i < 3; i++ { + if err := SetupObserver(); !errors.Is(err, availability.ErrUnimplemented) { + t.Errorf("SetupObserver() call %d should return ErrUnimplemented, got: %v", i+1, err) + } + if err := StartObserver(); !errors.Is(err, availability.ErrUnimplemented) { + t.Errorf("StartObserver() call %d should return ErrUnimplemented, got: %v", i+1, err) + } + } + + for i := 0; i < 3; i++ { + if err := DestroyObserver(); !errors.Is(err, availability.ErrUnimplemented) { + t.Errorf("DestroyObserver() call %d should return ErrUnimplemented, got: %v", i+1, err) + } + } +} diff --git a/pkg/driver/camera/camera_windows.go b/pkg/driver/camera/camera_windows.go index c8d9a43..045a465 100644 --- a/pkg/driver/camera/camera_windows.go +++ b/pkg/driver/camera/camera_windows.go @@ -13,6 +13,7 @@ import ( "unsafe" "github.com/pion/mediadevices/pkg/driver" + "github.com/pion/mediadevices/pkg/driver/availability" "github.com/pion/mediadevices/pkg/frame" "github.com/pion/mediadevices/pkg/io/video" "github.com/pion/mediadevices/pkg/prop" @@ -58,6 +59,21 @@ func Initialize() { C.freeCameraList(&list, &errStr) } +// SetupObserver is a stub implementation for Windows. +func SetupObserver() error { + return availability.ErrUnimplemented +} + +// StartObserver is a stub implementation for Windows. +func StartObserver() error { + return availability.ErrUnimplemented +} + +// DestroyObserver is a stub implementation for Windows. +func DestroyObserver() error { + return availability.ErrUnimplemented +} + func (c *camera) Open() error { c.ch = make(chan []byte) c.cam = &C.camera{