1+ using System . Runtime . InteropServices ;
2+ using System . Runtime . InteropServices . ComTypes ;
13using ObsKit . NET . Core ;
24
35namespace ObsKit . NET . Sources ;
@@ -106,9 +108,12 @@ public WebcamCapture(string name, string? deviceId = null)
106108 }
107109
108110 /// <summary>
109- /// Enumerates the available video capture devices on the system. This works by creating
110- /// a temporary dshow_input source and asking OBS to populate its property list, which is
111- /// the same code path the OBS UI uses for the device dropdown.
111+ /// Enumerates the available video capture devices on the system. On Windows this reads
112+ /// the DirectShow device monikers via <c>ICreateDevEnum</c> / <c>IPropertyBag</c>, which
113+ /// only touches the registry-backed moniker — it never calls <c>CoCreateInstance</c> on
114+ /// the underlying filter. That avoids loading third-party DShow filter DLLs (e.g. Meta
115+ /// Quest's <c>magicdsfilterQuest3.dll</c>) that crash when instantiated headlessly.
116+ /// On Linux/macOS it falls back to the OBS property-list probe, which is safe there.
112117 /// </summary>
113118 /// <param name="includeVirtualDevices">
114119 /// If true, also return virtual / proxy entries that have no DirectShow device path —
@@ -117,8 +122,17 @@ public WebcamCapture(string name, string? deviceId = null)
117122 /// </param>
118123 public static IReadOnlyList < WebcamDeviceInfo > ListDevices ( bool includeVirtualDevices = false )
119124 {
120- // The dshow plugin only populates the device list when an instance exists, so we
121- // create a private (un-saved) source just for the property query and dispose it.
125+ if ( OperatingSystem . IsWindows ( ) )
126+ return ListDevicesWindows ( includeVirtualDevices ) ;
127+
128+ return ListDevicesViaObsProbe ( includeVirtualDevices ) ;
129+ }
130+
131+ // Fallback path used on Linux/macOS. On Windows this code is unsafe in the presence of
132+ // buggy third-party DShow filters because the dshow plugin CoCreates each filter when
133+ // populating its property list.
134+ private static IReadOnlyList < WebcamDeviceInfo > ListDevicesViaObsProbe ( bool includeVirtualDevices )
135+ {
122136 using var probe = Source . CreatePrivate ( TypeIdForPlatform , "__obskit_webcam_probe__" ) ;
123137 var items = probe . GetListPropertyItems ( VideoDeviceIdKey ) ;
124138
@@ -128,11 +142,6 @@ public static IReadOnlyList<WebcamDeviceInfo> ListDevices(bool includeVirtualDev
128142 if ( string . IsNullOrEmpty ( itemValue ) )
129143 continue ;
130144
131- // OBS encodes device ids as "Name:Path" (the dshow plugin escapes any literal
132- // ':' and '#' inside the components, so the first ':' is always the separator).
133- // Entries with an empty path component are virtual / proxy devices (Meta Quest
134- // companion app, NVIDIA Broadcast, OBS Virtual Camera, ...). Skip them unless
135- // the caller opts in.
136145 if ( ! includeVirtualDevices && OperatingSystem . IsWindows ( ) )
137146 {
138147 var sep = itemValue . IndexOf ( ':' ) ;
@@ -145,6 +154,141 @@ public static IReadOnlyList<WebcamDeviceInfo> ListDevices(bool includeVirtualDev
145154 return result ;
146155 }
147156
157+ [ System . Runtime . Versioning . SupportedOSPlatform ( "windows" ) ]
158+ private static IReadOnlyList < WebcamDeviceInfo > ListDevicesWindows ( bool includeVirtualDevices )
159+ {
160+ var result = new List < WebcamDeviceInfo > ( ) ;
161+ object ? devEnumObj = null ;
162+ IEnumMoniker ? enumMoniker = null ;
163+
164+ try
165+ {
166+ var sysDeviceEnumType = Type . GetTypeFromCLSID ( CLSID_SystemDeviceEnum , throwOnError : false ) ;
167+ if ( sysDeviceEnumType == null )
168+ return result ;
169+
170+ devEnumObj = Activator . CreateInstance ( sysDeviceEnumType ) ;
171+ if ( devEnumObj is not ICreateDevEnum devEnum )
172+ return result ;
173+
174+ int hr = devEnum . CreateClassEnumerator ( CLSID_VideoInputDeviceCategory , out enumMoniker , 0 ) ;
175+ // S_FALSE (1) means the category is empty.
176+ if ( hr != 0 || enumMoniker == null )
177+ return result ;
178+
179+ var monikers = new IMoniker [ 1 ] ;
180+ while ( enumMoniker . Next ( 1 , monikers , IntPtr . Zero ) == 0 )
181+ {
182+ var moniker = monikers [ 0 ] ;
183+ if ( moniker == null )
184+ continue ;
185+
186+ try
187+ {
188+ string ? name = ReadStringProperty ( moniker , "FriendlyName" ) ;
189+ if ( string . IsNullOrEmpty ( name ) )
190+ name = ReadStringProperty ( moniker , "Description" ) ;
191+ if ( string . IsNullOrEmpty ( name ) )
192+ continue ;
193+
194+ string ? path = ReadStringProperty ( moniker , "DevicePath" ) ?? string . Empty ;
195+
196+ if ( ! includeVirtualDevices && string . IsNullOrEmpty ( path ) )
197+ continue ;
198+
199+ string deviceId = EncodeDeviceId ( name ! , path ) ;
200+ result . Add ( new WebcamDeviceInfo ( name ! , deviceId ) ) ;
201+ }
202+ finally
203+ {
204+ Marshal . ReleaseComObject ( moniker ) ;
205+ }
206+ }
207+ }
208+ catch
209+ {
210+ // Swallow — enumeration must never throw past this boundary. Callers can deal
211+ // with an empty list (e.g. show a "no devices found" UI) but a thrown exception
212+ // would propagate up through the OBS thread and look like a crash.
213+ }
214+ finally
215+ {
216+ if ( enumMoniker != null )
217+ Marshal . ReleaseComObject ( enumMoniker ) ;
218+ if ( devEnumObj != null )
219+ Marshal . ReleaseComObject ( devEnumObj ) ;
220+ }
221+
222+ return result ;
223+ }
224+
225+ [ System . Runtime . Versioning . SupportedOSPlatform ( "windows" ) ]
226+ private static string ? ReadStringProperty ( IMoniker moniker , string propertyName )
227+ {
228+ object ? bagObj = null ;
229+ try
230+ {
231+ var bagGuid = IID_IPropertyBag ;
232+ moniker . BindToStorage ( null ! , null ! , ref bagGuid , out bagObj ) ;
233+ if ( bagObj is not IPropertyBag bag )
234+ return null ;
235+
236+ object ? value = null ;
237+ int hr = bag . Read ( propertyName , ref value , IntPtr . Zero ) ;
238+ if ( hr != 0 || value == null )
239+ return null ;
240+ return value . ToString ( ) ;
241+ }
242+ catch
243+ {
244+ return null ;
245+ }
246+ finally
247+ {
248+ if ( bagObj != null )
249+ Marshal . ReleaseComObject ( bagObj ) ;
250+ }
251+ }
252+
253+ // Mirror of obs-studio's win-dshow/encode-dstr.hpp::encode_dstr. The OBS dshow plugin
254+ // produces device ids of the form "{name}:{path}" with '#' replaced by "#22" and ':'
255+ // by "#3A" inside each component, so we must produce the exact same string for the
256+ // dshow plugin to recognise the device when we later assign it via SetDevice().
257+ private static string EncodeDeviceId ( string name , string path )
258+ {
259+ string encName = EncodeDshowComponent ( name ) ;
260+ string encPath = EncodeDshowComponent ( path ) ;
261+ return $ "{ encName } :{ encPath } ";
262+ }
263+
264+ private static string EncodeDshowComponent ( string s )
265+ {
266+ if ( string . IsNullOrEmpty ( s ) )
267+ return s ;
268+ return s . Replace ( "#" , "#22" ) . Replace ( ":" , "#3A" ) ;
269+ }
270+
271+ private static readonly Guid CLSID_SystemDeviceEnum = new ( "62BE5D10-60EB-11d0-BD3B-00A0C911CE86" ) ;
272+ private static readonly Guid CLSID_VideoInputDeviceCategory = new ( "860BB310-5D01-11d0-BD3B-00A0C911CE86" ) ;
273+ private static readonly Guid IID_IPropertyBag = new ( "55272A00-42CB-11CE-8135-00AA004BB851" ) ;
274+
275+ [ ComImport , Guid ( "29840822-5B84-11D0-BD3B-00A0C911CE86" ) , InterfaceType ( ComInterfaceType . InterfaceIsIUnknown ) ]
276+ private interface ICreateDevEnum
277+ {
278+ [ PreserveSig ]
279+ int CreateClassEnumerator ( [ In ] in Guid clsidDeviceClass , out IEnumMoniker ? ppEnumMoniker , [ In ] int dwFlags ) ;
280+ }
281+
282+ [ ComImport , Guid ( "55272A00-42CB-11CE-8135-00AA004BB851" ) , InterfaceType ( ComInterfaceType . InterfaceIsIUnknown ) ]
283+ private interface IPropertyBag
284+ {
285+ [ PreserveSig ]
286+ int Read ( [ MarshalAs ( UnmanagedType . LPWStr ) ] string pszPropName , [ In , Out , MarshalAs ( UnmanagedType . Struct ) ] ref object ? pVar , IntPtr pErrorLog ) ;
287+
288+ [ PreserveSig ]
289+ int Write ( [ MarshalAs ( UnmanagedType . LPWStr ) ] string pszPropName , [ In , MarshalAs ( UnmanagedType . Struct ) ] ref object pVar ) ;
290+ }
291+
148292 private static Settings BuildInitialSettings ( string ? deviceId )
149293 {
150294 var settings = new Settings ( ) ;
0 commit comments