@@ -395,3 +395,130 @@ mod windows {
395395 // info!("Windows Event Queue Pump Ending");
396396 // }
397397}
398+
399+ #[ cfg( target_os = "macos" ) ]
400+ #[ allow( unsafe_code) ]
401+ pub mod macos {
402+ //! macOS `AppKit` integration for media key support.
403+ //!
404+ //! `souvlaki`'s macOS backend registers `MPRemoteCommandCenter` handlers for
405+ //! media key events, but macOS delivers those events through `AppKit`'s main run
406+ //! loop. Rust CLI binaries don't initialize an `NSApplication` run loop by
407+ //! default, so the registered handlers never receive events.
408+ //!
409+ //! Both [`init_macos_main_thread`] and [`pump_run_loop`] must be called from
410+ //! the main thread. Apple's [`NSApplicationMain(_:_:)`] documentation
411+ //! explicitly states: *"You must call this function from the main thread of
412+ //! your application."* The Thread Safety Summary also notes that the main
413+ //! thread is *"the one blocked in the `run` method of `NSApplication`"* and
414+ //! that `NSRunLoop` is not thread-safe ([Thread Safety Summary]). In
415+ //! practice, `MPRemoteCommandCenter` dispatches callbacks via GCD's main
416+ //! queue which executes on the main thread, so event delivery breaks without
417+ //! the main thread pumping the run loop.
418+ //!
419+ //! [`NSApplicationMain(_:_:)`]: https://developer.apple.com/documentation/appkit/nsapplicationmain(_:_:)/
420+ //! [Thread Safety Summary]: https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafetySummary/ThreadSafetySummary.html#//apple_ref/doc/uid/10000057i-CH12-SW1
421+ //!
422+ //! This module provides:
423+ //! - [`init_macos_main_thread`]: Initialize `NSApplication` (no Dock icon).
424+ //! - [`pump_run_loop`]: Pump the main run loop so `AppKit` can dispatch events.
425+ //! - [`run_with_run_loop`]: Convenience: spawn a closure on a background thread
426+ //! while pumping the run loop on the main thread.
427+
428+ use std:: time:: Duration ;
429+
430+ use objc2:: msg_send;
431+ use objc2:: runtime:: { AnyClass , AnyObject } ;
432+
433+ /// `NSApplicationActivationPolicy.accessory` — run as a background process
434+ /// with no Dock icon, but still able to receive media key events.
435+ const NS_APPLICATION_ACTIVATION_POLICY_ACCESSORY : i64 = 1 ;
436+
437+ /// Initialize the macOS `AppKit` application on the main thread.
438+ ///
439+ /// Must be called from the main thread (the only thread that can run
440+ /// `AppKit` per Apple's [Thread Safety Summary](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafetySummary/ThreadSafetySummary.html))
441+ /// before any `souvlaki`
442+ /// `MediaControls` are attached. Sets the activation policy to
443+ /// `.accessory`, meaning the app appears as a background process (no Dock
444+ /// icon) but can still receive media key events via
445+ /// `MPRemoteCommandCenter`.
446+ ///
447+ /// # Panics
448+ ///
449+ /// Panics if the `NSApplication` class is not available at runtime (should
450+ /// never happen on a real macOS system).
451+ pub fn init_macos_main_thread ( ) {
452+ unsafe {
453+ let cls = AnyClass :: get ( c"NSApplication" ) . expect ( "NSApplication class not found" ) ;
454+ let app: * mut AnyObject = msg_send ! [ cls, sharedApplication] ;
455+ let _: ( ) =
456+ msg_send ! [ app, setActivationPolicy: NS_APPLICATION_ACTIVATION_POLICY_ACCESSORY ] ;
457+ }
458+ }
459+
460+ /// Pump the main `CFRunLoop` until the sender is dropped or a message is
461+ /// received (i.e. the background thread finished).
462+ ///
463+ /// Each iteration processes any pending events via `runUntilDate:` with
464+ /// `distantPast` (non-blocking), then sleeps for 50 ms. This loop is
465+ /// intended to run on the main thread while the Tokio runtime executes on a
466+ /// background thread. When the background thread completes (or panics), the
467+ /// sender is dropped and `recv_timeout` returns `Err`, causing this function
468+ /// to return.
469+ ///
470+ /// # Panics
471+ ///
472+ /// Panics if the `NSRunLoop` or `NSDate` class is not available at runtime
473+ /// (should never happen on a real macOS system).
474+ pub fn pump_run_loop ( done : & std:: sync:: mpsc:: Receiver < ( ) > ) {
475+ let rl_cls = AnyClass :: get ( c"NSRunLoop" ) . expect ( "NSRunLoop class not found on macOS" ) ;
476+ let date_cls = AnyClass :: get ( c"NSDate" ) . expect ( "NSDate class not found on macOS" ) ;
477+ loop {
478+ match done. recv_timeout ( Duration :: from_millis ( 50 ) ) {
479+ Ok ( ( ) ) | Err ( std:: sync:: mpsc:: RecvTimeoutError :: Disconnected ) => break ,
480+ Err ( std:: sync:: mpsc:: RecvTimeoutError :: Timeout ) => { }
481+ }
482+ unsafe {
483+ let rl: * mut AnyObject = msg_send ! [ rl_cls, mainRunLoop] ;
484+ let distant_past: * mut AnyObject = msg_send ! [ date_cls, distantPast] ;
485+ let _: ( ) = msg_send ! [ rl, runUntilDate: distant_past] ;
486+ }
487+ }
488+ }
489+
490+ /// Run a closure on a background thread while pumping the `AppKit` run loop
491+ /// on the main thread.
492+ ///
493+ /// This is the simplest way to use the macOS media key support: call this
494+ /// from `main()`, passing a closure that sets up and runs the Tokio runtime.
495+ /// The closure runs on a background thread named "termusic-tokio", and the
496+ /// main thread pumps `CFRunLoop` until the closure returns. `AppKit` and
497+ /// `CFRunLoop` must run on the main thread per Apple's
498+ /// [Thread Safety Summary](https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafetySummary/ThreadSafetySummary.html).
499+ ///
500+ /// # Panics
501+ ///
502+ /// Panics if the background thread cannot be spawned or panics.
503+ pub fn run_with_run_loop < T > ( f : impl FnOnce ( ) -> T + Send + ' static ) -> T
504+ where
505+ T : Send + ' static ,
506+ {
507+ init_macos_main_thread ( ) ;
508+
509+ let ( tx, rx) = std:: sync:: mpsc:: channel :: < ( ) > ( ) ;
510+
511+ let handle = std:: thread:: Builder :: new ( )
512+ . name ( "termusic-tokio" . into ( ) )
513+ . spawn ( move || {
514+ let result = f ( ) ;
515+ let _ = tx. send ( ( ) ) ;
516+ result
517+ } )
518+ . expect ( "failed to spawn termusic-tokio thread" ) ;
519+
520+ pump_run_loop ( & rx) ;
521+ handle. join ( ) . expect ( "termusic-tokio thread panicked" )
522+ }
523+ }
524+
0 commit comments