Add darwin runtime device observer support (#670)

* wip

* wip

* Organize

* Remove unnecessary change in camera_darwin.go filtering

* wip

* Make observer stop safe during startup

* wip IsAvailable impl

* Fix non-darwin builds

* Lock bg loop to main thread and add comment

* Remove fmt prints

* Simplify isAvailable; Add timeout for Read darwin

* Match comment with code

* Change to singleton pattern; Add clearer safer state machine states; Change language from Stop to Destroy; Add new error for when observer is unavailable;

* Add stubs for linux

* Move cancel() up so its not dead code sometimes

* Add stubs for Windows too

* Remove StopObserver usage

* Add camera tests

* Add device observer tests

* Fix multiple destroy calls bug; Call setup in start

* Improve isAvailable

* Improve string handling in device observer c

* Add error handling in example

* Add comment about setup vs start

* Rename and organize device observer darwin

* Explicitly case initial state for setup

* Fix potential destroy goroutine leak; Use only modern build tag; Return err not nil for stubs; Improve comments

* Close startDone channel on device observer stop not wait
This commit is contained in:
sean yu
2025-12-28 13:33:36 -08:00
committed by GitHub
parent 7d8cbdbf23
commit 03900dcb1b
12 changed files with 1455 additions and 9 deletions
+70
View File
@@ -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)
}
}
}
@@ -30,6 +30,12 @@
#import "AVFoundationBind.h"
#include <string.h>
// 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)) { \
@@ -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
@@ -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 <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>
#import <string.h>
#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<NSKeyValueChangeKey,id> *)change
context:(void *)pContext {
if (![keyPath isEqualToString:@"devices"]) return;
NSArray<AVCaptureDevice *> *refOldDevices = change[NSKeyValueChangeOldKey];
NSArray<AVCaptureDevice *> *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;
}
}
+386
View File
@@ -0,0 +1,386 @@
package avfoundation
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework AVFoundation -framework Foundation -framework CoreMedia -framework CoreVideo
#include <stdlib.h>
#include <string.h>
#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
}
+297
View File
@@ -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")
}
}
+4 -3
View File
@@ -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 {
+143 -6
View File
@@ -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
}
+138
View File
@@ -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)
}
}
+15
View File
@@ -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 {
+52
View File
@@ -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)
}
}
}
+16
View File
@@ -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{