Skip to main content

retch_sysinfo/
media.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Active media player and currently playing track detection.
5//!
6//! Provides 100% native detection without forking child processes:
7//! - **Linux**: Direct Unix domain socket connection to `$DBUS_SESSION_BUS_ADDRESS` /
8//!   `/run/user/<uid>/bus` speaking the binary D-Bus wire protocol for MPRIS (`org.mpris.MediaPlayer2.*`).
9//! - **Windows**: Native WinRT COM FFI via `combase.dll` querying
10//!   `Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager`.
11//! - **macOS**: Native Objective-C runtime FFI via `macos_ffi.rs` querying running media apps.
12
13#![allow(clippy::manual_is_multiple_of)]
14
15/// Information about the active media player and currently playing track.
16#[derive(Debug, Clone, PartialEq, Eq, Default)]
17pub struct MediaInfo {
18    /// Normalized media player name (e.g. "Spotify", "VLC media player", "Firefox").
19    pub player: Option<String>,
20    /// Playback status (e.g. "Playing", "Paused", "Stopped").
21    pub status: Option<String>,
22    /// Track artist.
23    pub artist: Option<String>,
24    /// Track title.
25    pub title: Option<String>,
26    /// Track album.
27    pub album: Option<String>,
28}
29
30impl MediaInfo {
31    /// Formats the player line (e.g. `"Spotify (Playing)"` or `"VLC media player"`).
32    pub fn format_player(&self) -> Option<String> {
33        let player = self.player.as_deref()?;
34        if let Some(status) = self.status.as_deref() {
35            if !status.is_empty() {
36                return Some(format!("{player} ({status})"));
37            }
38        }
39        Some(player.to_string())
40    }
41
42    /// Formats the media track line (e.g. `"Artist - Title"` or `"Title"`).
43    pub fn format_media(&self) -> Option<String> {
44        match (&self.artist, &self.title) {
45            (Some(artist), Some(title)) if !artist.is_empty() && !title.is_empty() => {
46                Some(format!("{artist} - {title}"))
47            }
48            (_, Some(title)) if !title.is_empty() => Some(title.clone()),
49            (Some(artist), _) if !artist.is_empty() => Some(artist.clone()),
50            _ => None,
51        }
52    }
53}
54
55/// Detects the active media player and currently playing track.
56///
57/// Returns `(Option<media_string>, Option<player_string>)`.
58pub fn detect_media() -> (Option<String>, Option<String>) {
59    let info = detect_media_info();
60    let media_str = info.as_ref().and_then(|i| i.format_media());
61    let player_str = info.as_ref().and_then(|i| i.format_player());
62    (media_str, player_str)
63}
64
65/// Detects raw `MediaInfo` from the platform's native media subsystem.
66pub fn detect_media_info() -> Option<MediaInfo> {
67    #[cfg(target_os = "linux")]
68    {
69        linux_dbus::detect_mpris_media()
70    }
71
72    #[cfg(target_os = "windows")]
73    {
74        win_media::detect_winrt_media()
75    }
76
77    #[cfg(target_os = "macos")]
78    {
79        macos_media::detect_macos_media()
80    }
81
82    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
83    {
84        None
85    }
86}
87
88// ─── Windows Backend (Native WinRT COM FFI) ──────────────────────────────────
89
90#[cfg(target_os = "windows")]
91pub mod win_media {
92    use super::MediaInfo;
93    use std::ffi::c_void;
94    use std::ptr;
95
96    type HResult = i32;
97    type HString = *mut c_void;
98
99    #[repr(C)]
100    #[derive(Debug, Clone, Copy)]
101    pub struct Guid {
102        pub data1: u32,
103        pub data2: u16,
104        pub data3: u16,
105        pub data4: [u8; 8],
106    }
107
108    #[repr(C)]
109    pub struct HstringHeader {
110        pub flags: u32,
111        pub length: u32,
112        pub padding1: u32,
113        pub padding2: u32,
114        pub data: *const c_void,
115    }
116
117    #[repr(C)]
118    struct IUnknownVtbl {
119        pub query_interface: unsafe extern "system" fn(
120            this: *mut c_void,
121            riid: *const Guid,
122            ppv: *mut *mut c_void,
123        ) -> HResult,
124        pub add_ref: unsafe extern "system" fn(this: *mut c_void) -> u32,
125        pub release: unsafe extern "system" fn(this: *mut c_void) -> u32,
126    }
127
128    #[repr(C)]
129    struct IInspectableVtbl {
130        pub base: IUnknownVtbl,
131        pub get_iids: unsafe extern "system" fn(
132            this: *mut c_void,
133            count: *mut u32,
134            iids: *mut *mut Guid,
135        ) -> HResult,
136        pub get_runtime_class_name:
137            unsafe extern "system" fn(this: *mut c_void, name: *mut HString) -> HResult,
138        pub get_trust_level:
139            unsafe extern "system" fn(this: *mut c_void, trust_level: *mut i32) -> HResult,
140    }
141
142    #[repr(C)]
143    struct IAsyncInfoVtbl {
144        pub base: IInspectableVtbl,
145        pub get_id: unsafe extern "system" fn(this: *mut c_void, id: *mut u32) -> HResult,
146        pub get_status: unsafe extern "system" fn(this: *mut c_void, status: *mut u32) -> HResult,
147        pub get_error_code:
148            unsafe extern "system" fn(this: *mut c_void, error_code: *mut HResult) -> HResult,
149        pub cancel: unsafe extern "system" fn(this: *mut c_void) -> HResult,
150        pub close: unsafe extern "system" fn(this: *mut c_void) -> HResult,
151    }
152
153    #[repr(C)]
154    struct IAsyncOperationVtbl {
155        pub base: IInspectableVtbl,
156        pub put_completed:
157            unsafe extern "system" fn(this: *mut c_void, handler: *mut c_void) -> HResult,
158        pub get_completed:
159            unsafe extern "system" fn(this: *mut c_void, handler: *mut *mut c_void) -> HResult,
160        pub get_results:
161            unsafe extern "system" fn(this: *mut c_void, results: *mut *mut c_void) -> HResult,
162    }
163
164    #[repr(C)]
165    struct IStaticsVtbl {
166        pub base: IInspectableVtbl,
167        pub request_async:
168            unsafe extern "system" fn(this: *mut c_void, operation: *mut *mut c_void) -> HResult,
169    }
170
171    #[repr(C)]
172    struct ISessionManagerVtbl {
173        pub base: IInspectableVtbl,
174        pub get_current_session:
175            unsafe extern "system" fn(this: *mut c_void, result: *mut *mut c_void) -> HResult,
176    }
177
178    #[repr(C)]
179    struct ISessionVtbl {
180        pub base: IInspectableVtbl,
181        pub get_source_app_user_model_id:
182            unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
183        pub try_get_media_properties_async:
184            unsafe extern "system" fn(this: *mut c_void, operation: *mut *mut c_void) -> HResult,
185        pub get_playback_info:
186            unsafe extern "system" fn(this: *mut c_void, result: *mut *mut c_void) -> HResult,
187    }
188
189    #[repr(C)]
190    struct IPlaybackInfoVtbl {
191        pub base: IInspectableVtbl,
192        pub get_controls:
193            unsafe extern "system" fn(this: *mut c_void, value: *mut *mut c_void) -> HResult,
194        pub get_playback_status:
195            unsafe extern "system" fn(this: *mut c_void, value: *mut i32) -> HResult,
196    }
197
198    #[repr(C)]
199    struct IMediaPropertiesVtbl {
200        pub base: IInspectableVtbl,
201        pub get_title: unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
202        pub get_subtitle:
203            unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
204        pub get_artist:
205            unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
206        pub get_album_artist:
207            unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
208        pub get_album_title:
209            unsafe extern "system" fn(this: *mut c_void, value: *mut HString) -> HResult,
210    }
211
212    const IID_IASYNC_INFO: Guid = Guid {
213        data1: 0x0000_0036,
214        data2: 0x0000,
215        data3: 0x0000,
216        data4: [0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46],
217    };
218
219    const IID_ISTATICS: Guid = Guid {
220        data1: 0x2050c4ee,
221        data2: 0x11a0,
222        data3: 0x57de,
223        data4: [0xae, 0xd7, 0xc9, 0x7c, 0x70, 0x33, 0x82, 0x45],
224    };
225
226    type FnRoInitialize = unsafe extern "system" fn(init_type: i32) -> HResult;
227    type FnRoGetActivationFactory = unsafe extern "system" fn(
228        activatable_class_id: HString,
229        iid: *const Guid,
230        factory: *mut *mut c_void,
231    ) -> HResult;
232    type FnWindowsCreateStringReference = unsafe extern "system" fn(
233        source_string: *const u16,
234        length: u32,
235        hstring_header: *mut HstringHeader,
236        string: *mut HString,
237    ) -> HResult;
238    type FnWindowsGetStringRawBuffer =
239        unsafe extern "system" fn(string: HString, length: *mut u32) -> *const u16;
240    type FnWindowsDeleteString = unsafe extern "system" fn(string: HString) -> HResult;
241
242    #[link(name = "kernel32")]
243    extern "system" {
244        fn LoadLibraryA(name: *const i8) -> *mut c_void;
245        fn GetProcAddress(module: *mut c_void, name: *const i8) -> *mut c_void;
246    }
247
248    unsafe fn get_string_buffer(
249        get_buf: FnWindowsGetStringRawBuffer,
250        hs: HString,
251    ) -> Option<String> {
252        if hs.is_null() {
253            return None;
254        }
255        let mut len: u32 = 0;
256        let ptr = get_buf(hs, &mut len);
257        if ptr.is_null() || len == 0 {
258            return None;
259        }
260        let slice = std::slice::from_raw_parts(ptr, len as usize);
261        let s = String::from_utf16_lossy(slice).trim().to_string();
262        if s.is_empty() {
263            None
264        } else {
265            Some(s)
266        }
267    }
268
269    unsafe fn release_com(ptr: *mut c_void) {
270        if !ptr.is_null() {
271            let vtbl = *(ptr as *mut *mut IUnknownVtbl);
272            ((*vtbl).release)(ptr);
273        }
274    }
275
276    unsafe fn wait_async(async_op: *mut c_void, timeout_ms: u64) -> bool {
277        let mut async_info: *mut c_void = ptr::null_mut();
278        let vtbl = *(async_op as *mut *mut IUnknownVtbl);
279        let hr = ((*vtbl).query_interface)(async_op, &IID_IASYNC_INFO, &mut async_info);
280        if hr < 0 || async_info.is_null() {
281            return false;
282        }
283        let info_vtbl = *(async_info as *mut *mut IAsyncInfoVtbl);
284        let start = std::time::Instant::now();
285        let mut completed = false;
286        while start.elapsed().as_millis() < timeout_ms as u128 {
287            let mut status: u32 = 0;
288            let hr = ((*info_vtbl).get_status)(async_info, &mut status);
289            if hr >= 0 && status != 0 {
290                completed = status == 1; // 1 = Completed
291                break;
292            }
293            std::thread::sleep(std::time::Duration::from_millis(1));
294        }
295        release_com(async_info);
296        completed
297    }
298
299    /// Cleans and normalizes Windows AppUserModelIDs / executable names into friendly player names.
300    pub fn clean_app_user_model_id(id: &str) -> String {
301        let lower = id.to_lowercase();
302        if lower.contains("spotify") {
303            "Spotify".to_string()
304        } else if lower.contains("zune")
305            || lower.contains("mediaplayer")
306            || lower.contains("groove")
307        {
308            "Media Player".to_string()
309        } else if lower.contains("msedge") {
310            "Microsoft Edge".to_string()
311        } else if lower.contains("chrome") {
312            "Google Chrome".to_string()
313        } else if lower.contains("firefox") {
314            "Firefox".to_string()
315        } else if lower.contains("vlc") {
316            "VLC media player".to_string()
317        } else if lower.contains("foobar2000") {
318            "foobar2000".to_string()
319        } else if lower.contains("aimp") {
320            "AIMP".to_string()
321        } else if lower.contains("itunes") {
322            "iTunes".to_string()
323        } else if lower.contains("apple.music") || lower.contains("applemusic") {
324            "Apple Music".to_string()
325        } else {
326            // Strip executable extension and trailing package IDs if present
327            let base = id
328                .split('!')
329                .next()
330                .unwrap_or(id)
331                .trim_end_matches(".exe")
332                .trim();
333            base.to_string()
334        }
335    }
336
337    /// Converts Windows WinRT PlaybackStatus integer to a display string.
338    pub fn format_win_playback_status(status: i32) -> Option<String> {
339        match status {
340            4 => Some("Playing".to_string()),
341            5 => Some("Paused".to_string()),
342            3 => Some("Stopped".to_string()),
343            1 | 2 => Some("Buffering".to_string()),
344            _ => None,
345        }
346    }
347
348    /// Detects active media information via native WinRT COM FFI.
349    pub fn detect_winrt_media() -> Option<MediaInfo> {
350        // SAFETY: All pointers are validated, HSTRINGs freed with WindowsDeleteString,
351        // and COM interfaces released with IUnknown::Release.
352        unsafe {
353            let combase = LoadLibraryA(c"combase.dll".as_ptr());
354            if combase.is_null() {
355                return None;
356            }
357
358            let ro_init_ptr = GetProcAddress(combase, c"RoInitialize".as_ptr());
359            let ro_get_factory_ptr = GetProcAddress(combase, c"RoGetActivationFactory".as_ptr());
360            let str_ref_ptr = GetProcAddress(combase, c"WindowsCreateStringReference".as_ptr());
361            let str_buf_ptr = GetProcAddress(combase, c"WindowsGetStringRawBuffer".as_ptr());
362            let str_del_ptr = GetProcAddress(combase, c"WindowsDeleteString".as_ptr());
363
364            if ro_init_ptr.is_null()
365                || ro_get_factory_ptr.is_null()
366                || str_ref_ptr.is_null()
367                || str_buf_ptr.is_null()
368                || str_del_ptr.is_null()
369            {
370                return None;
371            }
372
373            let ro_init: FnRoInitialize = std::mem::transmute(ro_init_ptr);
374            let ro_get_factory: FnRoGetActivationFactory = std::mem::transmute(ro_get_factory_ptr);
375            let str_ref: FnWindowsCreateStringReference = std::mem::transmute(str_ref_ptr);
376            let str_buf: FnWindowsGetStringRawBuffer = std::mem::transmute(str_buf_ptr);
377            let str_del: FnWindowsDeleteString = std::mem::transmute(str_del_ptr);
378
379            // Initialize WinRT for the thread (RO_INIT_MULTITHREADED = 1)
380            ro_init(1);
381
382            let class_name =
383                "Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager\0";
384            let wide_class: Vec<u16> = class_name.encode_utf16().collect();
385            let mut header = std::mem::zeroed::<HstringHeader>();
386            let mut hs_class: HString = ptr::null_mut();
387            str_ref(
388                wide_class.as_ptr(),
389                (wide_class.len() - 1) as u32,
390                &mut header,
391                &mut hs_class,
392            );
393
394            let mut factory: *mut c_void = ptr::null_mut();
395            let hr = ro_get_factory(hs_class, &IID_ISTATICS, &mut factory);
396            if hr < 0 || factory.is_null() {
397                return None;
398            }
399
400            let statics_vtbl = *(factory as *mut *mut IStaticsVtbl);
401            let mut async_op: *mut c_void = ptr::null_mut();
402            let hr = ((*statics_vtbl).request_async)(factory, &mut async_op);
403            release_com(factory);
404
405            if hr < 0 || async_op.is_null() {
406                return None;
407            }
408
409            if !wait_async(async_op, 30) {
410                release_com(async_op);
411                return None;
412            }
413
414            let op_vtbl = *(async_op as *mut *mut IAsyncOperationVtbl);
415            let mut session_mgr: *mut c_void = ptr::null_mut();
416            let hr = ((*op_vtbl).get_results)(async_op, &mut session_mgr);
417            release_com(async_op);
418
419            if hr < 0 || session_mgr.is_null() {
420                return None;
421            }
422
423            let mgr_vtbl = *(session_mgr as *mut *mut ISessionManagerVtbl);
424            let mut session: *mut c_void = ptr::null_mut();
425            let hr = ((*mgr_vtbl).get_current_session)(session_mgr, &mut session);
426            release_com(session_mgr);
427
428            if hr < 0 || session.is_null() {
429                return None;
430            }
431
432            let session_vtbl = *(session as *mut *mut ISessionVtbl);
433            let mut app_id: HString = ptr::null_mut();
434            let _ = ((*session_vtbl).get_source_app_user_model_id)(session, &mut app_id);
435            let player = get_string_buffer(str_buf, app_id).map(|s| clean_app_user_model_id(&s));
436            str_del(app_id);
437
438            let mut playback_info: *mut c_void = ptr::null_mut();
439            let mut status = None;
440            let hr = ((*session_vtbl).get_playback_info)(session, &mut playback_info);
441            if hr >= 0 && !playback_info.is_null() {
442                let pb_vtbl = *(playback_info as *mut *mut IPlaybackInfoVtbl);
443                let mut status_code: i32 = 0;
444                if ((*pb_vtbl).get_playback_status)(playback_info, &mut status_code) >= 0 {
445                    status = format_win_playback_status(status_code);
446                }
447                release_com(playback_info);
448            }
449
450            let mut title = None;
451            let mut artist = None;
452            let mut album = None;
453
454            let mut async_props: *mut c_void = ptr::null_mut();
455            let hr = ((*session_vtbl).try_get_media_properties_async)(session, &mut async_props);
456            if hr >= 0 && !async_props.is_null() {
457                if wait_async(async_props, 30) {
458                    let props_op_vtbl = *(async_props as *mut *mut IAsyncOperationVtbl);
459                    let mut props: *mut c_void = ptr::null_mut();
460                    let hr = ((*props_op_vtbl).get_results)(async_props, &mut props);
461                    if hr >= 0 && !props.is_null() {
462                        let props_vtbl = *(props as *mut *mut IMediaPropertiesVtbl);
463                        let mut title_hs: HString = ptr::null_mut();
464                        let mut artist_hs: HString = ptr::null_mut();
465                        let mut album_hs: HString = ptr::null_mut();
466
467                        if ((*props_vtbl).get_title)(props, &mut title_hs) >= 0 {
468                            title = get_string_buffer(str_buf, title_hs);
469                        }
470                        if ((*props_vtbl).get_artist)(props, &mut artist_hs) >= 0 {
471                            artist = get_string_buffer(str_buf, artist_hs);
472                        }
473                        if ((*props_vtbl).get_album_title)(props, &mut album_hs) >= 0 {
474                            album = get_string_buffer(str_buf, album_hs);
475                        }
476
477                        str_del(title_hs);
478                        str_del(artist_hs);
479                        str_del(album_hs);
480                        release_com(props);
481                    }
482                }
483                release_com(async_props);
484            }
485
486            release_com(session);
487
488            if player.is_none() && title.is_none() && artist.is_none() {
489                None
490            } else {
491                Some(MediaInfo {
492                    player,
493                    status,
494                    artist,
495                    title,
496                    album,
497                })
498            }
499        }
500    }
501}
502
503// ─── Linux Backend (Native D-Bus Wire Protocol) ──────────────────────────────
504
505// ─── Linux Backend (Native D-Bus Wire Protocol) ──────────────────────────────
506
507#[cfg(any(target_os = "linux", test))]
508pub mod linux_dbus {
509    use super::MediaInfo;
510    use std::io::{Read, Write};
511    use std::path::PathBuf;
512    use std::time::Duration;
513
514    #[cfg(target_os = "linux")]
515    use std::os::unix::net::UnixStream;
516
517    /// Locates the user's session D-Bus Unix domain socket path.
518    pub fn get_session_bus_path() -> Option<PathBuf> {
519        if let Ok(addr) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
520            for transport in addr.split(';') {
521                for part in transport.split(',') {
522                    if let Some(path) = part.strip_prefix("unix:path=") {
523                        let p = PathBuf::from(path);
524                        if p.exists() {
525                            return Some(p);
526                        }
527                    }
528                }
529            }
530        }
531        #[cfg(target_os = "linux")]
532        {
533            let uid = unsafe { libc::getuid() };
534            let default_path = PathBuf::from(format!("/run/user/{uid}/bus"));
535            if default_path.exists() {
536                return Some(default_path);
537            }
538        }
539        None
540    }
541
542    /// Performs SASL authentication on a freshly connected D-Bus socket.
543    #[cfg(target_os = "linux")]
544    fn authenticate_dbus(stream: &mut UnixStream) -> bool {
545        let uid = unsafe { libc::getuid() };
546        let uid_str = uid.to_string();
547        let hex_uid: String = uid_str.bytes().map(|b| format!("{b:02x}")).collect();
548
549        // 1. Send single NUL byte followed by AUTH EXTERNAL
550        let auth_cmd = format!("\0AUTH EXTERNAL {hex_uid}\r\n");
551        if stream.write_all(auth_cmd.as_bytes()).is_err() {
552            return false;
553        }
554
555        // 2. Read exactly one response line until \r\n (byte-by-byte to avoid eating binary data)
556        let mut line = Vec::new();
557        let mut b = [0u8; 1];
558        while line.len() < 256 {
559            match stream.read(&mut b) {
560                Ok(1) => {
561                    line.push(b[0]);
562                    if line.ends_with(b"\r\n") {
563                        break;
564                    }
565                }
566                _ => return false,
567            }
568        }
569
570        let resp = String::from_utf8_lossy(&line);
571        if !resp.starts_with("OK") {
572            return false;
573        }
574
575        // 3. Send BEGIN to switch to binary protocol mode
576        if stream.write_all(b"BEGIN\r\n").is_err() {
577            return false;
578        }
579
580        true
581    }
582
583    /// Writes a D-Bus method call message into a byte buffer.
584    pub fn encode_dbus_call(
585        serial: u32,
586        destination: &str,
587        path: &str,
588        interface: &str,
589        member: &str,
590        signature: Option<&str>,
591        body: &[u8],
592    ) -> Vec<u8> {
593        let mut header_fields = Vec::new();
594
595        // 1: PATH (Object Path)
596        encode_header_field(&mut header_fields, 1, b'o', path.as_bytes());
597        // 2: INTERFACE (String)
598        encode_header_field(&mut header_fields, 2, b's', interface.as_bytes());
599        // 3: MEMBER (String)
600        encode_header_field(&mut header_fields, 3, b's', member.as_bytes());
601        // 6: DESTINATION (String)
602        encode_header_field(&mut header_fields, 6, b's', destination.as_bytes());
603        if let Some(sig) = signature {
604            // 8: SIGNATURE (Signature)
605            encode_header_field(&mut header_fields, 8, b'g', sig.as_bytes());
606        }
607
608        let mut msg = vec![
609            b'l', // Little-endian
610            1,    // Method call
611            0,    // Flags
612            1,    // Protocol version
613        ];
614        msg.extend_from_slice(&(body.len() as u32).to_le_bytes());
615        msg.extend_from_slice(&serial.to_le_bytes());
616        msg.extend_from_slice(&(header_fields.len() as u32).to_le_bytes());
617        msg.extend_from_slice(&header_fields);
618
619        // Pad header to 8-byte boundary
620        while msg.len() % 8 != 0 {
621            msg.push(0);
622        }
623        msg.extend_from_slice(body);
624        msg
625    }
626
627    fn encode_header_field(out: &mut Vec<u8>, field_code: u8, sig_byte: u8, val: &[u8]) {
628        // Struct alignment to 8 bytes in header fields array
629        while out.len() % 8 != 0 {
630            out.push(0);
631        }
632        out.push(field_code);
633        out.push(1); // Signature len
634        out.push(sig_byte);
635        out.push(0); // Null terminator for signature
636        if sig_byte == b'g' {
637            out.push(val.len() as u8);
638            out.extend_from_slice(val);
639            out.push(0);
640        } else {
641            // String (s) or Object Path (o)
642            while out.len() % 4 != 0 {
643                out.push(0);
644            }
645            out.extend_from_slice(&(val.len() as u32).to_le_bytes());
646            out.extend_from_slice(val);
647            out.push(0);
648        }
649    }
650
651    /// Reads a single complete D-Bus message from the stream.
652    #[cfg(target_os = "linux")]
653    pub fn read_dbus_message(stream: &mut UnixStream) -> Option<Vec<u8>> {
654        let mut header_buf = [0u8; 16];
655        stream.read_exact(&mut header_buf).ok()?;
656
657        let body_len = u32::from_le_bytes(header_buf[4..8].try_into().ok()?) as usize;
658        let fields_len = u32::from_le_bytes(header_buf[12..16].try_into().ok()?) as usize;
659        let header_padding = (8 - (fields_len % 8)) % 8;
660        let total_remaining = fields_len + header_padding + body_len;
661
662        let mut rest = vec![0u8; total_remaining];
663        stream.read_exact(&mut rest).ok()?;
664
665        let mut full = Vec::with_capacity(16 + total_remaining);
666        full.extend_from_slice(&header_buf);
667        full.extend_from_slice(&rest);
668        Some(full)
669    }
670
671    /// Extracts message type from a D-Bus binary message header.
672    /// 1 = MethodCall, 2 = MethodReturn, 3 = Error, 4 = Signal.
673    pub fn get_message_type(msg: &[u8]) -> Option<u8> {
674        if msg.len() >= 2 {
675            Some(msg[1])
676        } else {
677            None
678        }
679    }
680
681    /// Extracts reply serial number from D-Bus header field 5 (REPLY_SERIAL).
682    pub fn get_reply_serial(msg: &[u8]) -> Option<u32> {
683        if msg.len() < 24 {
684            return None;
685        }
686        let fields_len = u32::from_le_bytes(msg[12..16].try_into().ok()?) as usize;
687        let end = (16 + fields_len).min(msg.len());
688        let mut pos = 16;
689        while pos + 8 <= end {
690            while pos % 8 != 0 && pos < end {
691                pos += 1;
692            }
693            if pos + 8 > end {
694                break;
695            }
696            if msg[pos] == 5 && msg[pos + 1] == 1 && msg[pos + 2] == b'u' && msg[pos + 3] == 0 {
697                return Some(u32::from_le_bytes(msg[pos + 4..pos + 8].try_into().ok()?));
698            }
699            pos += 1;
700        }
701        None
702    }
703
704    /// Reads incoming D-Bus messages, skipping asynchronous signals (e.g. `NameAcquired`),
705    /// until receiving the matching `MethodReturn` (2) or `Error` (3) for `expected_serial`.
706    #[cfg(target_os = "linux")]
707    pub fn read_dbus_reply(stream: &mut UnixStream, expected_serial: u32) -> Option<Vec<u8>> {
708        for _ in 0..10 {
709            let msg = read_dbus_message(stream)?;
710            let msg_type = get_message_type(&msg)?;
711            if msg_type == 2 {
712                // MethodReturn
713                if let Some(serial) = get_reply_serial(&msg) {
714                    if serial == expected_serial {
715                        return Some(msg);
716                    }
717                } else {
718                    return Some(msg);
719                }
720            } else if msg_type == 3 {
721                // Error reply
722                if let Some(serial) = get_reply_serial(&msg) {
723                    if serial == expected_serial {
724                        return None;
725                    }
726                }
727            }
728            // If message was a Signal (4), loop and read the next message
729        }
730        None
731    }
732
733    /// Extracts string list from a `ListNames` reply body (`as`).
734    pub fn parse_name_list(msg: &[u8]) -> Vec<String> {
735        if msg.len() < 16 {
736            return Vec::new();
737        }
738        let fields_len = u32::from_le_bytes(msg[12..16].try_into().unwrap_or([0; 4])) as usize;
739        let header_padding = (8 - (fields_len % 8)) % 8;
740        let body_offset = 16 + fields_len + header_padding;
741        if body_offset + 4 > msg.len() {
742            return Vec::new();
743        }
744
745        let array_len = u32::from_le_bytes(
746            msg[body_offset..body_offset + 4]
747                .try_into()
748                .unwrap_or([0; 4]),
749        ) as usize;
750        let mut pos = body_offset + 4;
751        let end = (pos + array_len).min(msg.len());
752        let mut names = Vec::new();
753
754        while pos + 4 <= end {
755            while pos % 4 != 0 && pos < end {
756                pos += 1;
757            }
758            if pos + 4 > end {
759                break;
760            }
761            let s_len = u32::from_le_bytes(msg[pos..pos + 4].try_into().unwrap_or([0; 4])) as usize;
762            pos += 4;
763            if pos + s_len <= end {
764                if let Ok(s) = std::str::from_utf8(&msg[pos..pos + s_len]) {
765                    names.push(s.to_string());
766                }
767                pos += s_len + 1; // skip string + null terminator
768            } else {
769                break;
770            }
771        }
772        names
773    }
774
775    /// Parses player name from a D-Bus service name (e.g. `"org.mpris.MediaPlayer2.spotify"` -> `"Spotify"`).
776    pub fn clean_mpris_service_name(service: &str) -> String {
777        let raw_name = service
778            .strip_prefix("org.mpris.MediaPlayer2.")
779            .unwrap_or(service);
780
781        // Strip instance suffixes (e.g. "vlc.instance1234" -> "vlc", "firefox.instance_1_42" -> "firefox")
782        let base_name = raw_name
783            .split(".instance")
784            .next()
785            .unwrap_or(raw_name)
786            .trim();
787
788        let lower = base_name.to_lowercase();
789        if lower == "spotify" {
790            "Spotify".to_string()
791        } else if lower == "vlc" {
792            "VLC media player".to_string()
793        } else if lower == "chrome" || lower == "google-chrome" {
794            "Google Chrome".to_string()
795        } else if lower == "chromium" {
796            "Chromium".to_string()
797        } else if lower == "firefox" {
798            "Firefox".to_string()
799        } else if lower == "brave" {
800            "Brave".to_string()
801        } else if lower == "mpv" {
802            "mpv".to_string()
803        } else if lower == "celluloid" {
804            "Celluloid".to_string()
805        } else if lower == "rhythmbox" {
806            "Rhythmbox".to_string()
807        } else if lower == "cider" {
808            "Cider".to_string()
809        } else if lower == "audacious" {
810            "Audacious".to_string()
811        } else if lower == "clementine" {
812            "Clementine".to_string()
813        } else if lower == "strawberry" {
814            "Strawberry".to_string()
815        } else if lower == "amberol" {
816            "Amberol".to_string()
817        } else if lower == "elisa" {
818            "Elisa".to_string()
819        } else if lower == "lollypop" {
820            "Lollypop".to_string()
821        } else {
822            // Capitalize first letter of player name
823            let mut chars = base_name.chars();
824            match chars.next() {
825                None => String::new(),
826                Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
827            }
828        }
829    }
830
831    /// Scans a D-Bus dictionary response for string values by key name.
832    pub fn extract_mpris_string(body: &[u8], key: &str) -> Option<String> {
833        let key_bytes = key.as_bytes();
834        let mut pos = 0;
835        while pos + key_bytes.len() + 6 < body.len() {
836            if &body[pos..pos + key_bytes.len()] == key_bytes {
837                let search_start = pos + key_bytes.len();
838                let search_end = (search_start + 32).min(body.len());
839                for s in search_start..search_end.saturating_sub(6) {
840                    if body[s] == 1 && body[s + 1] == b's' && body[s + 2] == 0 {
841                        // Scan for the 4-byte string length
842                        let mut str_pos = s + 3;
843                        while str_pos < search_end {
844                            if str_pos + 4 <= body.len() {
845                                let slen = u32::from_le_bytes(
846                                    body[str_pos..str_pos + 4].try_into().unwrap_or([0; 4]),
847                                ) as usize;
848                                if slen > 0 && slen < 4096 && str_pos + 4 + slen <= body.len() {
849                                    if let Ok(val) =
850                                        std::str::from_utf8(&body[str_pos + 4..str_pos + 4 + slen])
851                                    {
852                                        let trimmed = val.trim();
853                                        if !trimmed.is_empty()
854                                            && val.chars().all(|c| !c.is_control())
855                                        {
856                                            return Some(trimmed.to_string());
857                                        }
858                                    }
859                                }
860                            }
861                            str_pos += 1;
862                        }
863                    }
864                }
865            }
866            pos += 1;
867        }
868        None
869    }
870
871    /// Scans a D-Bus dictionary response for string array values (e.g. `xesam:artist` -> `as`).
872    pub fn extract_mpris_string_list(body: &[u8], key: &str) -> Option<String> {
873        let key_bytes = key.as_bytes();
874        let mut pos = 0;
875        while pos + key_bytes.len() + 8 < body.len() {
876            if &body[pos..pos + key_bytes.len()] == key_bytes {
877                let search_start = pos + key_bytes.len();
878                let search_end = (search_start + 32).min(body.len());
879                for s in search_start..search_end.saturating_sub(8) {
880                    if body[s] == 2
881                        && body[s + 1] == b'a'
882                        && body[s + 2] == b's'
883                        && body[s + 3] == 0
884                    {
885                        let mut arr_header_pos = s + 4;
886                        while arr_header_pos < search_end {
887                            if arr_header_pos + 4 <= body.len() {
888                                let arr_len = u32::from_le_bytes(
889                                    body[arr_header_pos..arr_header_pos + 4]
890                                        .try_into()
891                                        .unwrap_or([0; 4]),
892                                ) as usize;
893                                if arr_len > 0
894                                    && arr_len < 65536
895                                    && arr_header_pos + 4 + arr_len <= body.len()
896                                {
897                                    let mut elem_pos = arr_header_pos + 4;
898                                    let end = elem_pos + arr_len;
899                                    let mut items = Vec::new();
900                                    while elem_pos + 4 <= end {
901                                        while elem_pos < end && body[elem_pos] == 0 {
902                                            elem_pos += 1;
903                                        }
904                                        if elem_pos + 4 > end {
905                                            break;
906                                        }
907                                        let slen = u32::from_le_bytes(
908                                            body[elem_pos..elem_pos + 4]
909                                                .try_into()
910                                                .unwrap_or([0; 4]),
911                                        )
912                                            as usize;
913                                        elem_pos += 4;
914                                        if slen > 0 && elem_pos + slen <= end {
915                                            if let Ok(item_str) = std::str::from_utf8(
916                                                &body[elem_pos..elem_pos + slen],
917                                            ) {
918                                                let trimmed = item_str.trim();
919                                                if !trimmed.is_empty() {
920                                                    items.push(trimmed.to_string());
921                                                }
922                                            }
923                                            elem_pos += slen + 1; // skip string + null terminator
924                                        } else {
925                                            break;
926                                        }
927                                    }
928                                    if !items.is_empty() {
929                                        return Some(items.join(", "));
930                                    }
931                                }
932                            }
933                            arr_header_pos += 1;
934                        }
935                    }
936                }
937            }
938            pos += 1;
939        }
940        None
941    }
942
943    /// Detects active media information on Linux via direct Unix socket D-Bus communication.
944    #[cfg(target_os = "linux")]
945    pub fn detect_mpris_media() -> Option<MediaInfo> {
946        let bus_path = get_session_bus_path()?;
947        let mut stream = UnixStream::connect(bus_path).ok()?;
948        stream
949            .set_read_timeout(Some(Duration::from_millis(100)))
950            .ok()?;
951        stream
952            .set_write_timeout(Some(Duration::from_millis(100)))
953            .ok()?;
954
955        if !authenticate_dbus(&mut stream) {
956            return None;
957        }
958
959        // 1. Send Hello to obtain unique connection name (serial 1)
960        let hello_msg = encode_dbus_call(
961            1,
962            "org.freedesktop.DBus",
963            "/org/freedesktop/DBus",
964            "org.freedesktop.DBus",
965            "Hello",
966            None,
967            &[],
968        );
969        stream.write_all(&hello_msg).ok()?;
970        let _ = read_dbus_reply(&mut stream, 1)?;
971
972        // 2. Query ListNames to find media players (serial 2)
973        let list_msg = encode_dbus_call(
974            2,
975            "org.freedesktop.DBus",
976            "/org/freedesktop/DBus",
977            "org.freedesktop.DBus",
978            "ListNames",
979            None,
980            &[],
981        );
982        stream.write_all(&list_msg).ok()?;
983        let list_reply = read_dbus_reply(&mut stream, 2)?;
984        let names = parse_name_list(&list_reply);
985
986        let mpris_services: Vec<_> = names
987            .into_iter()
988            .filter(|n| n.starts_with("org.mpris.MediaPlayer2."))
989            .collect();
990
991        if mpris_services.is_empty() {
992            return None;
993        }
994
995        let mut candidate_info: Option<MediaInfo> = None;
996
997        // Query properties for each media player service
998        for (i, service) in mpris_services.iter().enumerate() {
999            let mut arg_body = Vec::new();
1000            let iface_name = "org.mpris.MediaPlayer2.Player";
1001            arg_body.extend_from_slice(&(iface_name.len() as u32).to_le_bytes());
1002            arg_body.extend_from_slice(iface_name.as_bytes());
1003            arg_body.push(0);
1004
1005            let serial = 10 + i as u32;
1006            let get_all_msg = encode_dbus_call(
1007                serial,
1008                service,
1009                "/org/mpris/MediaPlayer2",
1010                "org.freedesktop.DBus.Properties",
1011                "GetAll",
1012                Some("s"),
1013                &arg_body,
1014            );
1015
1016            if stream.write_all(&get_all_msg).is_ok() {
1017                if let Some(reply) = read_dbus_reply(&mut stream, serial) {
1018                    let status = extract_mpris_string(&reply, "PlaybackStatus");
1019                    let title = extract_mpris_string(&reply, "xesam:title");
1020                    let artist = extract_mpris_string_list(&reply, "xesam:artist")
1021                        .or_else(|| extract_mpris_string(&reply, "xesam:artist"));
1022                    let album = extract_mpris_string(&reply, "xesam:album");
1023                    let player = Some(clean_mpris_service_name(service));
1024
1025                    let info = MediaInfo {
1026                        player,
1027                        status: status.clone(),
1028                        artist,
1029                        title,
1030                        album,
1031                    };
1032
1033                    if status.as_deref() == Some("Playing") {
1034                        return Some(info);
1035                    }
1036                    if candidate_info.is_none() {
1037                        candidate_info = Some(info);
1038                    }
1039                }
1040            }
1041        }
1042
1043        candidate_info
1044    }
1045}
1046
1047// ─── macOS Backend (Native Objective-C Runtime FFI) ──────────────────────────
1048
1049#[cfg(target_os = "macos")]
1050pub mod macos_media {
1051    use super::MediaInfo;
1052    use crate::macos_ffi::{cf_string_to_rust, CFStringRef};
1053    use std::ffi::{c_void, CString};
1054
1055    #[link(name = "objc")]
1056    extern "C" {
1057        fn objc_getClass(name: *const i8) -> *mut c_void;
1058        fn sel_registerName(name: *const i8) -> *mut c_void;
1059        fn objc_msgSend(self_: *mut c_void, op: *mut c_void, ...) -> *mut c_void;
1060    }
1061
1062    /// Queries Apple Music or Spotify via native Objective-C ScriptingBridge FFI.
1063    pub fn detect_macos_media() -> Option<MediaInfo> {
1064        unsafe {
1065            for (bundle_id, player_name) in [
1066                ("com.apple.Music", "Apple Music"),
1067                ("com.spotify.client", "Spotify"),
1068            ] {
1069                if let Some(info) = query_sb_player(bundle_id, player_name) {
1070                    return Some(info);
1071                }
1072            }
1073            None
1074        }
1075    }
1076
1077    unsafe fn query_sb_player(bundle_id: &str, player_name: &str) -> Option<MediaInfo> {
1078        let sb_cls_name = CString::new("SBApplication").ok()?;
1079        let sb_cls = objc_getClass(sb_cls_name.as_ptr());
1080        if sb_cls.is_null() {
1081            return None;
1082        }
1083
1084        let app_with_bundle_sel = sel_registerName(
1085            CString::new("applicationWithBundleIdentifier:")
1086                .ok()?
1087                .as_ptr(),
1088        );
1089        let is_running_sel = sel_registerName(CString::new("isRunning").ok()?.as_ptr());
1090        let current_track_sel = sel_registerName(CString::new("currentTrack").ok()?.as_ptr());
1091        let player_state_sel = sel_registerName(CString::new("playerState").ok()?.as_ptr());
1092        let name_sel = sel_registerName(CString::new("name").ok()?.as_ptr());
1093        let artist_sel = sel_registerName(CString::new("artist").ok()?.as_ptr());
1094        let album_sel = sel_registerName(CString::new("album").ok()?.as_ptr());
1095
1096        let ns_str_cls = objc_getClass(CString::new("NSString").ok()?.as_ptr());
1097        let str_with_utf8_sel =
1098            sel_registerName(CString::new("stringWithUTF8String:").ok()?.as_ptr());
1099        let bundle_c = CString::new(bundle_id).ok()?;
1100        let bundle_ns = objc_msgSend(ns_str_cls, str_with_utf8_sel, bundle_c.as_ptr());
1101        if bundle_ns.is_null() {
1102            return None;
1103        }
1104
1105        let app = objc_msgSend(sb_cls, app_with_bundle_sel, bundle_ns);
1106        if app.is_null() {
1107            return None;
1108        }
1109
1110        let is_running = objc_msgSend(app, is_running_sel) as usize != 0;
1111        if !is_running {
1112            return None;
1113        }
1114
1115        let track = objc_msgSend(app, current_track_sel);
1116        if track.is_null() {
1117            return None;
1118        }
1119
1120        let name_ref = objc_msgSend(track, name_sel) as CFStringRef;
1121        let artist_ref = objc_msgSend(track, artist_sel) as CFStringRef;
1122        let album_ref = objc_msgSend(track, album_sel) as CFStringRef;
1123
1124        let title = cf_string_to_rust(name_ref);
1125        let artist = cf_string_to_rust(artist_ref);
1126        let album = cf_string_to_rust(album_ref);
1127
1128        let state_val = objc_msgSend(app, player_state_sel) as usize;
1129        let status = if state_val == 1800426352 || state_val == 1 {
1130            // 'kPSP' (playing) or 1
1131            Some("Playing".to_string())
1132        } else if state_val == 1800426353 || state_val == 2 {
1133            // 'kPSp' (paused) or 2
1134            Some("Paused".to_string())
1135        } else {
1136            None
1137        };
1138
1139        if title.is_none() && artist.is_none() {
1140            None
1141        } else {
1142            Some(MediaInfo {
1143                player: Some(player_name.to_string()),
1144                status,
1145                artist,
1146                title,
1147                album,
1148            })
1149        }
1150    }
1151}
1152
1153// ─── Tests ───────────────────────────────────────────────────────────────────
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    #[test]
1160    fn test_format_player() {
1161        let with_status = MediaInfo {
1162            player: Some("Spotify".to_string()),
1163            status: Some("Playing".to_string()),
1164            artist: Some("Daft Punk".to_string()),
1165            title: Some("Get Lucky".to_string()),
1166            album: None,
1167        };
1168        assert_eq!(
1169            with_status.format_player(),
1170            Some("Spotify (Playing)".to_string())
1171        );
1172
1173        let without_status = MediaInfo {
1174            player: Some("VLC media player".to_string()),
1175            status: None,
1176            artist: None,
1177            title: None,
1178            album: None,
1179        };
1180        assert_eq!(
1181            without_status.format_player(),
1182            Some("VLC media player".to_string())
1183        );
1184
1185        let empty = MediaInfo::default();
1186        assert_eq!(empty.format_player(), None);
1187    }
1188
1189    #[test]
1190    fn test_format_media() {
1191        let full = MediaInfo {
1192            player: Some("Spotify".to_string()),
1193            status: Some("Playing".to_string()),
1194            artist: Some("Queen".to_string()),
1195            title: Some("Bohemian Rhapsody".to_string()),
1196            album: Some("A Night at the Opera".to_string()),
1197        };
1198        assert_eq!(
1199            full.format_media(),
1200            Some("Queen - Bohemian Rhapsody".to_string())
1201        );
1202
1203        let title_only = MediaInfo {
1204            player: Some("Firefox".to_string()),
1205            status: Some("Playing".to_string()),
1206            artist: None,
1207            title: Some("YouTube Video Title".to_string()),
1208            album: None,
1209        };
1210        assert_eq!(
1211            title_only.format_media(),
1212            Some("YouTube Video Title".to_string())
1213        );
1214
1215        let artist_only = MediaInfo {
1216            player: None,
1217            status: None,
1218            artist: Some("Unknown Artist".to_string()),
1219            title: None,
1220            album: None,
1221        };
1222        assert_eq!(
1223            artist_only.format_media(),
1224            Some("Unknown Artist".to_string())
1225        );
1226
1227        let empty = MediaInfo::default();
1228        assert_eq!(empty.format_media(), None);
1229    }
1230
1231    #[test]
1232    fn test_clean_app_user_model_id() {
1233        #[cfg(target_os = "windows")]
1234        {
1235            use win_media::clean_app_user_model_id;
1236            assert_eq!(clean_app_user_model_id("Spotify.exe"), "Spotify");
1237            assert_eq!(
1238                clean_app_user_model_id("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic"),
1239                "Media Player"
1240            );
1241            assert_eq!(clean_app_user_model_id("msedge.exe"), "Microsoft Edge");
1242            assert_eq!(clean_app_user_model_id("chrome.exe"), "Google Chrome");
1243            assert_eq!(clean_app_user_model_id("vlc.exe"), "VLC media player");
1244            assert_eq!(clean_app_user_model_id("foobar2000.exe"), "foobar2000");
1245        }
1246    }
1247
1248    #[test]
1249    fn test_linux_dbus_mpris_parsing() {
1250        use linux_dbus::{
1251            clean_mpris_service_name, encode_dbus_call, extract_mpris_string,
1252            extract_mpris_string_list, get_message_type, get_reply_serial, parse_name_list,
1253        };
1254
1255        assert_eq!(
1256            clean_mpris_service_name("org.mpris.MediaPlayer2.spotify"),
1257            "Spotify"
1258        );
1259        assert_eq!(
1260            clean_mpris_service_name("org.mpris.MediaPlayer2.vlc.instance1234"),
1261            "VLC media player"
1262        );
1263        assert_eq!(
1264            clean_mpris_service_name("org.mpris.MediaPlayer2.firefox.instance_1_42"),
1265            "Firefox"
1266        );
1267        assert_eq!(
1268            clean_mpris_service_name("org.mpris.MediaPlayer2.mpv"),
1269            "mpv"
1270        );
1271
1272        // Test synthetic D-Bus ListNames reply message
1273        let mut body = Vec::new();
1274        let name1 = "org.freedesktop.DBus";
1275        let name2 = "org.mpris.MediaPlayer2.spotify";
1276        let name3 = ":1.42";
1277
1278        let mut arr_bytes = Vec::new();
1279        for n in [name1, name2, name3] {
1280            while arr_bytes.len() % 4 != 0 {
1281                arr_bytes.push(0);
1282            }
1283            arr_bytes.extend_from_slice(&(n.len() as u32).to_le_bytes());
1284            arr_bytes.extend_from_slice(n.as_bytes());
1285            arr_bytes.push(0); // null terminator
1286        }
1287
1288        body.extend_from_slice(&(arr_bytes.len() as u32).to_le_bytes());
1289        body.extend_from_slice(&arr_bytes);
1290
1291        let msg = encode_dbus_call(
1292            1,
1293            ":1.42",
1294            "/org/freedesktop/DBus",
1295            "org.freedesktop.DBus",
1296            "ListNames",
1297            Some("as"),
1298            &body,
1299        );
1300
1301        assert_eq!(get_message_type(&msg), Some(1)); // MethodCall
1302
1303        let names = parse_name_list(&msg);
1304        assert!(names.contains(&"org.mpris.MediaPlayer2.spotify".to_string()));
1305        assert!(names.contains(&"org.freedesktop.DBus".to_string()));
1306
1307        // Test D-Bus reply serial extraction from a synthetic MethodReturn header
1308        let mut reply_header_fields = Vec::new();
1309        // Struct alignment 8
1310        while reply_header_fields.len() % 8 != 0 {
1311            reply_header_fields.push(0);
1312        }
1313        reply_header_fields.push(5); // field code 5 = REPLY_SERIAL
1314        reply_header_fields.push(1); // sig len
1315        reply_header_fields.push(b'u'); // sig type
1316        reply_header_fields.push(0); // null terminator
1317        reply_header_fields.extend_from_slice(&42u32.to_le_bytes()); // reply serial 42
1318
1319        let mut reply_msg = Vec::new();
1320        reply_msg.push(b'l'); // little-endian
1321        reply_msg.push(2); // MethodReturn
1322        reply_msg.push(0);
1323        reply_msg.push(1);
1324        reply_msg.extend_from_slice(&0u32.to_le_bytes()); // body len 0
1325        reply_msg.extend_from_slice(&100u32.to_le_bytes()); // serial 100
1326        reply_msg.extend_from_slice(&(reply_header_fields.len() as u32).to_le_bytes());
1327        reply_msg.extend_from_slice(&reply_header_fields);
1328
1329        assert_eq!(get_message_type(&reply_msg), Some(2));
1330        assert_eq!(get_reply_serial(&reply_msg), Some(42));
1331
1332        // Test property extraction (PlaybackStatus, xesam:title, xesam:artist)
1333        let mut prop_buf = Vec::new();
1334        // 1. PlaybackStatus: String "Playing"
1335        prop_buf.extend_from_slice(b"PlaybackStatus");
1336        prop_buf.push(0); // null terminator
1337        prop_buf.push(1); // variant sig len
1338        prop_buf.push(b's'); // variant sig 's'
1339        prop_buf.push(0); // null terminator
1340        prop_buf.extend_from_slice(&(7u32).to_le_bytes());
1341        prop_buf.extend_from_slice(b"Playing");
1342        prop_buf.push(0);
1343
1344        // 2. xesam:title: String "Get Lucky"
1345        prop_buf.extend_from_slice(b"xesam:title");
1346        prop_buf.push(0);
1347        prop_buf.push(1);
1348        prop_buf.push(b's');
1349        prop_buf.push(0);
1350        prop_buf.extend_from_slice(&(9u32).to_le_bytes());
1351        prop_buf.extend_from_slice(b"Get Lucky");
1352        prop_buf.push(0);
1353
1354        // 3. xesam:artist: Array of String ["Daft Punk", "Pharrell Williams"]
1355        prop_buf.extend_from_slice(b"xesam:artist");
1356        prop_buf.push(0);
1357        prop_buf.push(2); // variant sig len 2
1358        prop_buf.push(b'a');
1359        prop_buf.push(b's');
1360        prop_buf.push(0); // null terminator
1361        prop_buf.push(0); // alignment padding
1362        let mut artist_arr = Vec::new();
1363        for a in ["Daft Punk", "Pharrell Williams"] {
1364            artist_arr.extend_from_slice(&(a.len() as u32).to_le_bytes());
1365            artist_arr.extend_from_slice(a.as_bytes());
1366            artist_arr.push(0);
1367        }
1368        prop_buf.extend_from_slice(&(artist_arr.len() as u32).to_le_bytes());
1369        prop_buf.extend_from_slice(&artist_arr);
1370
1371        assert_eq!(
1372            extract_mpris_string(&prop_buf, "PlaybackStatus"),
1373            Some("Playing".to_string())
1374        );
1375        assert_eq!(
1376            extract_mpris_string(&prop_buf, "xesam:title"),
1377            Some("Get Lucky".to_string())
1378        );
1379        assert_eq!(
1380            extract_mpris_string_list(&prop_buf, "xesam:artist"),
1381            Some("Daft Punk, Pharrell Williams".to_string())
1382        );
1383    }
1384}