Skip to content

Commit 96f8667

Browse files
committed
feat(playback): enable macOS media key support via AppKit run loop
1 parent f0134ea commit 96f8667

5 files changed

Lines changed: 136 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

playback/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ rodio.workspace = true
4343
tokio.workspace = true
4444
# soundtouch= { git = 'https://github.com/Drewol/soundtouch-rs.git' }
4545

46+
[target.'cfg(target_os = "macos")'.dependencies]
47+
objc2 = "0.6"
48+
4649
[target.'cfg(target_os = "windows")'.dependencies]
4750
windows = { workspace = true, features = [
4851
"Win32_Foundation",

playback/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub use backends::{Backend, BackendSelect};
2626

2727
mod discord;
2828
mod mpris;
29+
#[cfg(target_os = "macos")]
30+
pub use mpris::macos;
2931
pub mod playlist;
3032

3133
#[macro_use]

playback/src/mpris.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+

server/src/server.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,13 @@ impl PlayerStats {
8686
}
8787

8888
fn main() -> Result<()> {
89+
#[cfg(target_os = "macos")]
90+
let res = termusicplayback::macos::run_with_run_loop(actual_main);
91+
#[cfg(not(target_os = "macos"))]
8992
let res = actual_main();
9093

9194
trace!("Tokio Exited");
9295

93-
// print error to the log and then throw it
9496
if let Err(err) = res {
9597
error!("Error: {err:?}");
9698
return Err(err);

0 commit comments

Comments
 (0)