From 8fa2acfcb4688f083ed1d48ea2c2118e63cc9127 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Tue, 11 Aug 2026 21:25:04 +0900 Subject: [PATCH 01/20] feat: arbitrate concurrent now playing sessions --- Cargo.lock | 1088 +---------------------------------------- Cargo.toml | 3 +- README.md | 8 +- build.rs | 47 +- src/media.rs | 328 ++++++++++--- src/media_sessions.m | 288 +++++++++++ src/media_sessions.pl | 14 + 7 files changed, 642 insertions(+), 1134 deletions(-) create mode 100644 src/media_sessions.m create mode 100644 src/media_sessions.pl diff --git a/Cargo.lock b/Cargo.lock index b46fc83..7231bfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,24 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "anstream" version = "1.0.0" @@ -82,114 +64,24 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - [[package]] name = "block2" version = "0.6.2" @@ -199,18 +91,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - [[package]] name = "bytemuck" version = "1.25.2" @@ -230,8 +110,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -278,7 +156,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -292,6 +170,7 @@ name = "codex-micro-chroma" version = "0.1.0" dependencies = [ "anyhow", + "base64", "cc", "clap", "crossbeam-channel", @@ -300,18 +179,12 @@ dependencies = [ "hidapi", "image", "libc", - "media-remote", "serde", "serde_json", + "tempfile", "thiserror", ] -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.5" @@ -336,60 +209,23 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "ctrlc" version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ - "dispatch2 0.3.1", + "dispatch2", "nix", "windows-sys", ] -[[package]] -name = "dispatch2" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a0d569e003ff27784e0e14e4a594048698e0c0f0b66cabcb51511be55a7caa0" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - [[package]] name = "dispatch2" version = "0.3.1" @@ -402,32 +238,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "errno" version = "0.3.14" @@ -438,35 +248,12 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "exr" -version = "1.74.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "num-complex", - "pulp", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - [[package]] name = "fdeflate" version = "0.3.7" @@ -476,16 +263,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.10" @@ -512,18 +289,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -532,28 +297,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", + "r-efi", ] [[package]] @@ -583,18 +327,10 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", - "exr", - "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", "zune-core", "zune-jpeg", ] @@ -609,159 +345,30 @@ dependencies = [ "quick-error", ] -[[package]] -name = "imgref" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "lexical-parse-float" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" -dependencies = [ - "lexical-parse-integer", - "lexical-util", -] - -[[package]] -name = "lexical-parse-integer" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" -dependencies = [ - "lexical-util", -] - -[[package]] -name = "lexical-util" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" - [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "media-remote" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e20a8b224bf855c811971a55790de18f58535249899e42c1bf379d0b04dfb24" -dependencies = [ - "base64", - "block2", - "dispatch2 0.2.0", - "flate2", - "image", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "serde_json", - "speedate", - "tar", - "tempfile", -] - [[package]] name = "memchr" version = "2.8.3" @@ -788,12 +395,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nix" version = "0.31.3" @@ -807,270 +408,44 @@ dependencies = [ ] [[package]] -name = "no_std_io2" -version = "0.9.4" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "memchr", + "autocfg", ] [[package]] -name = "nom" -version = "8.0.0" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "memchr", + "objc2-encode", ] [[package]] -name = "noop_proc_macro" -version = "0.3.0" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "num-bigint" -version = "0.4.8" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "num-complex" -version = "0.4.6" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "bytemuck", - "num-traits", -] +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2 0.3.1", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags", - "dispatch2 0.3.1", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pkg-config" -version = "0.3.33" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" @@ -1087,15 +462,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -1105,63 +471,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pulp" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" -dependencies = [ - "bytemuck", - "cfg-if", - "libm", - "num-complex", - "paste", - "pulp-wasm-simd-flag", - "raw-cpuid", - "reborrow", - "version_check", -] - -[[package]] -name = "pulp-wasm-simd-flag" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" - [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - [[package]] name = "quick-error" version = "2.0.1" @@ -1177,138 +492,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand", - "rand_chacha", - "simd_helpers", - "thiserror", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "reborrow" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - [[package]] name = "rustix" version = "1.1.4" @@ -1322,12 +511,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - [[package]] name = "serde" version = "1.0.229" @@ -1355,7 +538,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn", ] [[package]] @@ -1383,76 +566,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "speedate" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aba069c070b5e213f2a094deb7e5ed50ecb092be36102a4f4042e8d2056d060e" -dependencies = [ - "lexical-parse-float", - "strum", - "strum_macros", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "3.0.3" @@ -1464,17 +583,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -1482,7 +590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -1505,21 +613,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", + "syn", ] [[package]] @@ -1534,83 +628,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.127" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "winapi" version = "0.3.9" @@ -1648,48 +665,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zmij" version = "1.0.23" @@ -1702,15 +677,6 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index 0343ace..b378da7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,11 +16,12 @@ serde_json = "1.0" thiserror = "2.0" [target.'cfg(target_os = "macos")'.dependencies] +base64 = "0.22" crossbeam-channel = "0.5" fs2 = "0.4" hidapi = { version = "2.6", features = ["macos-shared-device"] } libc = "0.2" -media-remote = "0.5.2" +tempfile = "3.23" [build-dependencies] cc = "1.2" diff --git a/README.md b/README.md index d53709e..8ea5dea 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ Lighting effects and brightness respond dynamically to the audio's musical dynam ```text macOS Now Playing (Music / Spotify / browser / other players) -> macOS MediaRemote - -> Apple-signed /usr/bin/perl + mediaremote-adapter - -> media-remote Rust crate + -> Apple-signed /usr/bin/perl + embedded per-player session adapter + -> active-session arbitration from macOS-owned state -> local image decoding and representative-colour extraction -> artwork colour @@ -27,6 +27,8 @@ macOS system output MediaRemote is a private Apple framework. Its behaviour may change after a macOS update, and this architecture is not suitable for App Store distribution. If a player does not publish artwork to Now Playing, Codex Micro Chroma cannot derive a colour from it. +When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. + ## Requirements - macOS 14.2 or later for reactive mode; `--mode static` does not require a Process Tap @@ -40,7 +42,7 @@ MediaRemote is a private Apple framework. Its behaviour may change after a macOS The integration is service-agnostic, but artwork compatibility ultimately depends on each player publishing an image to macOS Now Playing. The browser/Helium path has been verified. Apple Music and Spotify artwork and colour output still require validation on the target Mac, as do long-running reconnect behaviour and final effect calibration on physical Codex Micro hardware. -Reactive analysis listens to the complete mixed macOS system output. If several applications play audio at once, the effects respond to that combined output while the base colour continues to come from the application selected by Now Playing. +Reactive analysis listens to the complete mixed macOS system output. If several applications play audio at once, the effects respond to that combined output while the base colour comes from the active session selected by the arbitration above. Exactly one HID interface matching the Codex Micro is required. No matching device, more than one matching interface, or missing Input Monitoring access leaves `run` waiting for the controller and causes one-shot commands such as `probe` or `set` to report an error. diff --git a/build.rs b/build.rs index d9d4c48..ca5e9ed 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf}; +use std::{env, path::PathBuf, process::Command}; fn main() { const MINIMUM_MACOS_VERSION: &str = "14.2"; @@ -6,6 +6,8 @@ fn main() { println!("cargo:rerun-if-changed=Info.plist"); println!("cargo:rerun-if-changed=src/process_tap.h"); println!("cargo:rerun-if-changed=src/process_tap.m"); + println!("cargo:rerun-if-changed=src/media_sessions.m"); + println!("cargo:rerun-if-changed=src/media_sessions.pl"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { return; @@ -18,6 +20,49 @@ fn main() { .flag(format!("-mmacosx-version-min={MINIMUM_MACOS_VERSION}")) .compile("codex_micro_chroma_process_tap"); + let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("build output directory")); + let helper = out_dir.join("libcodex_micro_chroma_media_sessions.dylib"); + let target_arch = match env::var("CARGO_CFG_TARGET_ARCH").as_deref() { + Ok("aarch64") => "arm64", + Ok("x86_64") => "x86_64", + Ok(other) => panic!("unsupported macOS target architecture: {other}"), + Err(error) => panic!("missing target architecture: {error}"), + }; + let clang_status = Command::new("xcrun") + .args([ + "clang", + "-dynamiclib", + "-fobjc-arc", + "-fblocks", + "-Wall", + "-Wextra", + "-Werror=return-type", + "-arch", + target_arch, + &format!("-mmacosx-version-min={MINIMUM_MACOS_VERSION}"), + "-framework", + "Foundation", + "-o", + ]) + .arg(&helper) + .arg("src/media_sessions.m") + .status() + .expect("failed to launch clang for the MediaRemote session helper"); + assert!( + clang_status.success(), + "failed to compile MediaRemote session helper" + ); + + let codesign_status = Command::new("codesign") + .args(["--force", "--sign", "-"]) + .arg(&helper) + .status() + .expect("failed to launch codesign for the MediaRemote session helper"); + assert!( + codesign_status.success(), + "failed to ad-hoc sign MediaRemote session helper" + ); + println!("cargo:rustc-link-lib=framework=Foundation"); println!("cargo:rustc-link-lib=framework=CoreAudio"); println!("cargo:rustc-link-arg=-mmacosx-version-min={MINIMUM_MACOS_VERSION}"); diff --git a/src/media.rs b/src/media.rs index 4fbada3..98d6627 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,7 +1,50 @@ use image::{DynamicImage, GenericImageView}; +use serde::Deserialize; const POSITION_EDGE_TOLERANCE_SECONDS: f64 = 2.0; +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PlaybackCandidate { + stable_id: String, + #[serde(default)] + bundle_id: Option, + playing: bool, + #[serde(default)] + last_playing_date: Option, + #[serde(default)] + elected: bool, + #[serde(default)] + title: Option, + #[serde(default)] + artist: Option, + #[serde(default)] + album: Option, + #[serde(default)] + elapsed_time: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + playback_rate: Option, + #[serde(default)] + info_update_date: Option, + #[serde(default)] + artwork_data: Option, +} + +fn select_playback_candidate(candidates: &[PlaybackCandidate]) -> Option<&PlaybackCandidate> { + candidates + .iter() + .filter(|candidate| candidate.playing) + .max_by(|left, right| { + left.last_playing_date + .unwrap_or(f64::NEG_INFINITY) + .total_cmp(&right.last_playing_date.unwrap_or(f64::NEG_INFINITY)) + .then_with(|| left.elected.cmp(&right.elected)) + .then_with(|| right.stable_id.cmp(&left.stable_id)) + }) +} + #[derive(Clone, Debug)] pub struct TrackSnapshot { pub is_playing: Option, @@ -83,8 +126,7 @@ fn position_at_reception( .unwrap_or_else(|| f64::from(is_playing == Some(true))); let update_age = update_age_seconds.filter(|value| value.is_finite() && *value >= 0.0); - // media-remote 0.5.2 projects elapsed time from MediaRemote's timestamp in - // get_info(), but that timestamp can remain at the start of a long pause when + // MediaRemote's timestamp can remain at the start of a long pause when // playback resumes. Anchor a resume at the newly delivered elapsed value. let projected = if resumed || is_playing != Some(true) || rate == 0.0 { elapsed @@ -127,47 +169,87 @@ impl ArtworkDelivery { mod platform { use std::{ cell::RefCell, + fs, + io::{BufRead, BufReader}, path::Path, - process::{Command, Stdio}, + process::{Child, Command, Stdio}, sync::{Arc, RwLock}, - time::{Instant, SystemTime}, + thread::{self, JoinHandle}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use anyhow::{bail, Context, Result}; - use media_remote::{ListenerToken, NowPlayingInfo, NowPlayingPerl, Subscription}; + use base64::{engine::general_purpose, Engine as _}; + use serde::Deserialize; + use tempfile::TempDir; - use super::{artwork_signature, position_at_reception, ArtworkDelivery, TrackSnapshot}; + use super::{ + artwork_signature, position_at_reception, select_playback_candidate, ArtworkDelivery, + PlaybackCandidate, TrackSnapshot, + }; + + const MEDIA_SESSIONS_DYLIB: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/libcodex_micro_chroma_media_sessions.dylib" + )); + const MEDIA_SESSIONS_PERL: &str = include_str!("media_sessions.pl"); + + #[derive(Deserialize)] + struct SessionPayload { + candidates: Vec, + } struct ReceivedNowPlaying { - info: NowPlayingInfo, + snapshot: TrackSnapshot, position_at_received: Option, received_at: Instant, } impl ReceivedNowPlaying { - fn from_event(info: NowPlayingInfo, previous: Option<&Self>) -> Self { + fn from_candidate(candidate: Option<&PlaybackCandidate>, previous: Option<&Self>) -> Self { let received_at = Instant::now(); + let artwork = candidate + .and_then(|candidate| candidate.artwork_data.as_deref()) + .and_then(|encoded| general_purpose::STANDARD.decode(encoded).ok()) + .and_then(|bytes| image::load_from_memory(&bytes).ok()); + let mut snapshot = TrackSnapshot { + is_playing: Some(candidate.is_some_and(|candidate| candidate.playing)), + title: candidate.and_then(|candidate| candidate.title.clone()), + artist: candidate.and_then(|candidate| candidate.artist.clone()), + album: candidate.and_then(|candidate| candidate.album.clone()), + bundle_id: candidate.and_then(|candidate| candidate.bundle_id.clone()), + elapsed_time: candidate.and_then(|candidate| candidate.elapsed_time), + duration: candidate.and_then(|candidate| candidate.duration), + playback_rate: candidate.and_then(|candidate| candidate.playback_rate), + artwork_signature: artwork.as_ref().map(artwork_signature), + artwork, + }; let resumed = previous.is_some_and(|previous| { - same_track(&previous.info, &info) - && previous.info.is_playing == Some(false) - && info.is_playing == Some(true) + same_track(&previous.snapshot, &snapshot) + && previous.snapshot.is_playing == Some(false) + && snapshot.is_playing == Some(true) }); - let update_age_seconds = info.info_update_time.and_then(|updated_at| { + let info_update_time = candidate + .and_then(|candidate| candidate.info_update_date) + .filter(|seconds| seconds.is_finite() && *seconds >= 0.0) + .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs_f64(seconds))); + let update_age_seconds = info_update_time.and_then(|updated_at| { SystemTime::now() .duration_since(updated_at) .ok() .map(|age| age.as_secs_f64()) }); let position_at_received = position_at_reception( - info.elapsed_time, - info.duration, - info.is_playing, - info.playback_rate, + snapshot.elapsed_time, + snapshot.duration, + snapshot.is_playing, + snapshot.playback_rate, update_age_seconds, resumed, ); + snapshot.elapsed_time = position_at_received; Self { - info, + snapshot, position_at_received, received_at, } @@ -175,18 +257,18 @@ mod platform { fn elapsed_time(&self) -> Option { let rate = self - .info + .snapshot .playback_rate .filter(|value| value.is_finite() && *value >= 0.0) - .unwrap_or_else(|| f64::from(self.info.is_playing == Some(true))); + .unwrap_or_else(|| f64::from(self.snapshot.is_playing == Some(true))); let projected = self.position_at_received? - + if self.info.is_playing == Some(true) { + + if self.snapshot.is_playing == Some(true) { self.received_at.elapsed().as_secs_f64() * rate } else { 0.0 }; Some( - self.info + self.snapshot .duration .filter(|duration| duration.is_finite() && *duration > 0.0) .map_or(projected, |duration| projected.min(duration)), @@ -194,7 +276,7 @@ mod platform { } } - fn same_track(left: &NowPlayingInfo, right: &NowPlayingInfo) -> bool { + fn same_track(left: &TrackSnapshot, right: &TrackSnapshot) -> bool { left.bundle_id == right.bundle_id && left.title == right.title && left.artist == right.artist @@ -202,8 +284,9 @@ mod platform { } pub struct MediaRemoteSource { - _remote: NowPlayingPerl, - _subscription: ListenerToken, + child: Child, + reader: Option>, + _temp_dir: TempDir, latest: Arc>>, artwork_delivery: RefCell, } @@ -214,23 +297,49 @@ mod platform { bail!("macOS system Perl was not found at /usr/bin/perl"); } - let remote = std::panic::catch_unwind(NowPlayingPerl::new).map_err(|_| { - anyhow::anyhow!("MediaRemote Perl adapter could not be initialized") - })?; + let temp_dir = tempfile::Builder::new() + .prefix("codex-micro-chroma-media-sessions") + .tempdir() + .context("could not create MediaRemote helper directory")?; + let dylib_path = temp_dir.path().join("media_sessions.dylib"); + let perl_path = temp_dir.path().join("media_sessions.pl"); + fs::write(&dylib_path, MEDIA_SESSIONS_DYLIB) + .context("could not extract MediaRemote session helper")?; + fs::write(&perl_path, MEDIA_SESSIONS_PERL) + .context("could not extract MediaRemote Perl shim")?; + + let mut child = Command::new("/usr/bin/perl") + .arg(&perl_path) + .arg(&dylib_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .context("could not start MediaRemote session helper")?; + let stdout = child + .stdout + .take() + .context("MediaRemote session helper stdout is unavailable")?; let latest = Arc::new(RwLock::new(None::)); - let listener_latest = Arc::clone(&latest); - let subscription = remote.subscribe(move |guard| { - let Some(info) = guard.as_ref().cloned() else { - return; - }; - if let Ok(mut latest) = listener_latest.write() { - let received = ReceivedNowPlaying::from_event(info, latest.as_ref()); - *latest = Some(received); + let reader_latest = Arc::clone(&latest); + let reader = thread::spawn(move || { + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let Ok(payload) = serde_json::from_str::(&line) else { + continue; + }; + let selected = select_playback_candidate(&payload.candidates); + if let Ok(mut latest) = reader_latest.write() { + *latest = Some(ReceivedNowPlaying::from_candidate( + selected, + latest.as_ref(), + )); + } } }); Ok(Self { - _remote: remote, - _subscription: subscription, + child, + reader: Some(reader), + _temp_dir: temp_dir, latest, artwork_delivery: RefCell::new(ArtworkDelivery::default()), }) @@ -239,26 +348,15 @@ mod platform { pub fn snapshot(&self) -> Option { let guard = self.latest.read().ok()?; let received = guard.as_ref()?; - let info = &received.info; - let mut snapshot = TrackSnapshot { - is_playing: info.is_playing, - title: info.title.clone(), - artist: info.artist.clone(), - album: info.album.clone(), - bundle_id: info.bundle_id.clone(), - elapsed_time: received.elapsed_time(), - duration: info.duration, - playback_rate: info.playback_rate, - artwork: None, - artwork_signature: info.album_cover.as_ref().map(artwork_signature), - }; + let mut snapshot = received.snapshot.clone(); + snapshot.elapsed_time = received.elapsed_time(); let key = snapshot.track_key(); let should_copy_artwork = self .artwork_delivery .borrow_mut() - .should_deliver(key.as_deref(), info.album_cover.is_some()); - if should_copy_artwork { - snapshot.artwork = info.album_cover.clone(); + .should_deliver(key.as_deref(), snapshot.artwork.is_some()); + if !should_copy_artwork { + snapshot.artwork = None; } Some(snapshot) } @@ -270,21 +368,11 @@ mod platform { impl Drop for MediaRemoteSource { fn drop(&mut self) { - // media-remote 0.5.2 does not expose its child handle and its reader can - // remain blocked during Drop. Limit cleanup to adapter children of this - // exact process so no unrelated Perl process can be affected. - let _ = Command::new("/usr/bin/pkill") - .args([ - "-P", - &std::process::id().to_string(), - "-f", - "mediaremote-adapter.pl", - ]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .context("failed to stop MediaRemote adapter child"); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } } } } @@ -331,6 +419,110 @@ mod tests { } } + fn candidate( + stable_id: &str, + playing: bool, + last_playing_date: Option, + elected: bool, + ) -> PlaybackCandidate { + PlaybackCandidate { + stable_id: stable_id.into(), + bundle_id: None, + playing, + last_playing_date, + elected, + title: None, + artist: None, + album: None, + elapsed_time: None, + duration: None, + playback_rate: None, + info_update_date: None, + artwork_data: None, + } + } + + #[test] + fn most_recently_played_active_session_wins() { + let candidates = [ + candidate("spotify", true, Some(100.0), true), + candidate("music", true, Some(200.0), false), + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("music") + ); + } + + #[test] + fn stopping_the_selected_session_falls_back_to_another_active_session() { + let candidates = [ + candidate("spotify", true, Some(100.0), false), + candidate("music", false, Some(200.0), true), + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("spotify") + ); + } + + #[test] + fn os_elected_session_breaks_equal_date_ties() { + let candidates = [ + candidate("spotify", true, Some(100.0), false), + candidate("music", true, Some(100.0), true), + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("music") + ); + } + + #[test] + fn no_active_session_turns_the_selection_off() { + let candidates = [ + candidate("spotify", false, Some(100.0), false), + candidate("music", false, Some(200.0), true), + ]; + + assert!(select_playback_candidate(&candidates).is_none()); + } + + #[test] + fn stable_identifier_makes_a_missing_date_tie_deterministic() { + let candidates = [ + candidate("spotify", true, None, false), + candidate("music", true, None, false), + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("music") + ); + } + + #[test] + fn os_session_payload_uses_camel_case_fields() { + let candidate: PlaybackCandidate = serde_json::from_str( + r#"{ + "stableId":"com.apple.Music:default", + "bundleId":"com.apple.Music", + "playing":true, + "lastPlayingDate":123.5, + "elected":true, + "title":"Song" + }"#, + ) + .expect("valid adapter payload"); + + assert_eq!(candidate.bundle_id.as_deref(), Some("com.apple.Music")); + assert_eq!(candidate.last_playing_date, Some(123.5)); + assert_eq!(candidate.title.as_deref(), Some("Song")); + } + #[test] fn track_key_distinguishes_now_playing_applications() { assert_ne!( diff --git a/src/media_sessions.m b/src/media_sessions.m new file mode 100644 index 0000000..37c3f7e --- /dev/null +++ b/src/media_sessions.m @@ -0,0 +1,288 @@ +#import +#import +#import +#import +#import + +typedef void (*MRGetNowPlayingClients)(dispatch_queue_t, void (^)(id)); +typedef void (*MRGetPlayerForClient)(id, id, dispatch_queue_t, void (^)(id)); +typedef void (*MRGetInfoForPlayer)(id, BOOL, dispatch_queue_t, + void (^)(NSDictionary *)); + +static MRGetNowPlayingClients getNowPlayingClients; +static MRGetPlayerForClient getPlayerForClient; +static MRGetInfoForPlayer getInfoForPlayer; +static BOOL refreshInFlight = NO; +static NSData *previousPayloadData = nil; + +static id objectProperty(id object, NSString *selectorName) { + SEL selector = NSSelectorFromString(selectorName); + if (!object || ![object respondsToSelector:selector]) { + return nil; + } + return ((id(*)(id, SEL))objc_msgSend)(object, selector); +} + +static NSString *stringProperty(id object, NSString *selectorName) { + id value = objectProperty(object, selectorName); + return [value isKindOfClass:[NSString class]] ? value : nil; +} + +static long integerProperty(id object, NSString *selectorName, BOOL *present) { + SEL selector = NSSelectorFromString(selectorName); + if (!object || ![object respondsToSelector:selector]) { + *present = NO; + return 0; + } + *present = YES; + return ((long(*)(id, SEL))objc_msgSend)(object, selector); +} + +static void copyString(NSMutableDictionary *destination, NSString *outputKey, + NSDictionary *source, NSString *sourceKey) { + id value = source[sourceKey]; + if ([value isKindOfClass:[NSString class]]) { + destination[outputKey] = value; + } +} + +static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, + NSDictionary *source, NSString *sourceKey) { + id value = source[sourceKey]; + if ([value isKindOfClass:[NSNumber class]]) { + destination[outputKey] = value; + } +} + +static void printCandidates(NSArray *candidates) { + NSDictionary *payload = @{ @"candidates" : candidates ?: @[] }; + NSError *error = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:payload + options:0 + error:&error]; + if (!data) { + fprintf(stderr, "could not serialize MediaRemote sessions: %s\n", + error.localizedDescription.UTF8String); + return; + } + if ([data isEqualToData:previousPayloadData]) { + return; + } + previousPayloadData = [data copy]; + NSString *line = [[NSString alloc] initWithData:data + encoding:NSUTF8StringEncoding]; + printf("%s\n", line.UTF8String); + fflush(stdout); +} + +static void scheduleRefresh(void); + +static void refreshSessions(void) { + if (refreshInFlight) { + return; + } + refreshInFlight = YES; + + dispatch_queue_t queue = dispatch_get_main_queue(); + NSMutableArray *candidates = [NSMutableArray array]; + Class playerPathClass = NSClassFromString(@"MRPlayerPath"); + Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); + id electedPath = objectProperty(requestClass, @"localNowPlayingPlayerPath"); + __block BOOL completed = NO; + + void (^complete)(void) = ^{ + if (completed) { + return; + } + completed = YES; + printCandidates(candidates); + refreshInFlight = NO; + scheduleRefresh(); + }; + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.75 * NSEC_PER_SEC)), + queue, complete); + + getNowPlayingClients(queue, ^(id clientsValue) { + if (completed) { + return; + } + NSArray *clients = [clientsValue isKindOfClass:[NSArray class]] + ? (NSArray *)clientsValue + : @[]; + dispatch_group_t group = dispatch_group_create(); + + for (id client in clients) { + dispatch_group_enter(group); + getPlayerForClient(client, nil, queue, ^(id player) { + if (!player || !playerPathClass) { + dispatch_group_leave(group); + return; + } + + id playerPath = ((id(*)(id, SEL, id, id, id))objc_msgSend)( + [playerPathClass alloc], + NSSelectorFromString(@"initWithOrigin:client:player:"), nil, + client, player); + if (!playerPath) { + dispatch_group_leave(group); + return; + } + + NSString *bundleID = + stringProperty(client, @"parentApplicationBundleIdentifier") + ?: stringProperty(client, @"bundleIdentifier") + ?: @"unknown"; + NSString *playerID = stringProperty(player, @"identifier") + ?: stringProperty(player, @"displayName") + ?: @"default"; + BOOL hasProcessIdentifier = NO; + long processIdentifier = integerProperty( + client, @"processIdentifier", &hasProcessIdentifier); + NSString *stableID = hasProcessIdentifier + ? [NSString stringWithFormat:@"%@:%@:%ld", + bundleID, + playerID, + processIdentifier] + : [NSString stringWithFormat:@"%@:%@", + bundleID, + playerID]; + NSMutableDictionary *entry = [@{ + @"stableId" : stableID, + @"bundleId" : bundleID, + @"playing" : @NO, + @"elected" : @([playerPath isEqual:electedPath]), + } mutableCopy]; + [candidates addObject:entry]; + __block BOOL requestPlayingResolved = NO; + + dispatch_group_enter(group); + getInfoForPlayer(playerPath, YES, queue, + ^(NSDictionary *information) { + if ([information isKindOfClass:[NSDictionary class]]) { + copyString(entry, @"title", information, + @"kMRMediaRemoteNowPlayingInfoTitle"); + copyString(entry, @"artist", information, + @"kMRMediaRemoteNowPlayingInfoArtist"); + copyString(entry, @"album", information, + @"kMRMediaRemoteNowPlayingInfoAlbum"); + copyNumber(entry, @"elapsedTime", information, + @"kMRMediaRemoteNowPlayingInfoElapsedTime"); + copyNumber(entry, @"duration", information, + @"kMRMediaRemoteNowPlayingInfoDuration"); + copyNumber(entry, @"playbackRate", information, + @"kMRMediaRemoteNowPlayingInfoPlaybackRate"); + NSNumber *rate = entry[@"playbackRate"]; + if (rate && !requestPlayingResolved) { + entry[@"playing"] = @([rate doubleValue] > 0.0); + } + id timestamp = information[ + @"kMRMediaRemoteNowPlayingInfoTimestamp"]; + if ([timestamp isKindOfClass:[NSDate class]]) { + entry[@"infoUpdateDate"] = + @([(NSDate *)timestamp timeIntervalSince1970]); + } + id artwork = information[ + @"kMRMediaRemoteNowPlayingInfoArtworkData"]; + if ([artwork isKindOfClass:[NSData class]]) { + entry[@"artworkData"] = + [(NSData *)artwork base64EncodedStringWithOptions:0]; + } + } + dispatch_group_leave(group); + }); + + if (requestClass) { + id request = ((id(*)(id, SEL, id))objc_msgSend)( + [requestClass alloc], + NSSelectorFromString(@"initWithPlayerPath:"), playerPath); + SEL isPlayingSelector = NSSelectorFromString( + @"requestIsPlayingOnQueue:completion:"); + if (request && [request respondsToSelector:isPlayingSelector]) { + dispatch_group_enter(group); + ((void (*)(id, SEL, dispatch_queue_t, + void (^)(BOOL, NSError *)))objc_msgSend)( + request, isPlayingSelector, queue, + ^(BOOL playing, NSError *error) { + (void)request; + (void)error; + requestPlayingResolved = YES; + entry[@"playing"] = @(playing); + dispatch_group_leave(group); + }); + } + + SEL lastPlayingSelector = NSSelectorFromString( + @"requestLastPlayingDateOnQueue:completion:"); + if (request && [request respondsToSelector:lastPlayingSelector]) { + dispatch_group_enter(group); + ((void (*)(id, SEL, dispatch_queue_t, + void (^)(NSDate *, NSError *)))objc_msgSend)( + request, lastPlayingSelector, queue, + ^(NSDate *date, NSError *error) { + (void)request; + (void)error; + if ([date isKindOfClass:[NSDate class]]) { + entry[@"lastPlayingDate"] = + @([date timeIntervalSince1970]); + } + dispatch_group_leave(group); + }); + } + } + + dispatch_group_leave(group); + }); + } + + dispatch_group_notify(group, queue, complete); + }); +} + +static void scheduleRefresh(void) { + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + refreshSessions(); + }); +} + +__attribute__((visibility("default"))) void chroma_media_sessions_stream(void) { + @autoreleasepool { + NSBundle *framework = [NSBundle bundleWithPath: + @"/System/Library/PrivateFrameworks/MediaRemote.framework"]; + if (![framework load]) { + fprintf(stderr, "could not load MediaRemote.framework\n"); + return; + } + + getNowPlayingClients = (MRGetNowPlayingClients)dlsym( + RTLD_DEFAULT, "MRMediaRemoteGetNowPlayingClients"); + getPlayerForClient = (MRGetPlayerForClient)dlsym( + RTLD_DEFAULT, "MRMediaRemoteGetNowPlayingPlayerForClient"); + getInfoForPlayer = (MRGetInfoForPlayer)dlsym( + RTLD_DEFAULT, "MRMediaRemoteGetNowPlayingInfoForPlayer"); + if (!getNowPlayingClients || !getPlayerForClient || !getInfoForPlayer) { + fprintf(stderr, "required per-player MediaRemote symbols are unavailable\n"); + return; + } + + Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); + if (!requestClass || + !class_getInstanceMethod( + requestClass, + NSSelectorFromString(@"requestLastPlayingDateOnQueue:completion:"))) { + fprintf(stderr, + "MediaRemote lastPlayingDate requests are unavailable; " + "OS election will be used as the tie-breaker\n"); + } + + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + refreshSessions(); + }); + CFRunLoopRun(); + } +} diff --git a/src/media_sessions.pl b/src/media_sessions.pl new file mode 100644 index 0000000..bd6c471 --- /dev/null +++ b/src/media_sessions.pl @@ -0,0 +1,14 @@ +use strict; +use warnings; +use DynaLoader; + +my $library = shift @ARGV; +die "media session helper path is required\n" unless defined $library; + +my $handle = DynaLoader::dl_load_file($library, 0) + or die DynaLoader::dl_error() . "\n"; +my $symbol = DynaLoader::dl_find_symbol($handle, "chroma_media_sessions_stream") + or die DynaLoader::dl_error() . "\n"; + +DynaLoader::dl_install_xsub("main::chroma_media_sessions_stream", $symbol); +chroma_media_sessions_stream(); From f5388ee4324651bc8fad9019c04f0d528b0e8434 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Tue, 11 Aug 2026 21:43:21 +0900 Subject: [PATCH 02/20] fix: switch after now playing stops --- src/media.rs | 18 +++++++++++++- src/media_sessions.m | 57 +++++++++++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/media.rs b/src/media.rs index 98d6627..63e1912 100644 --- a/src/media.rs +++ b/src/media.rs @@ -11,6 +11,8 @@ struct PlaybackCandidate { bundle_id: Option, playing: bool, #[serde(default)] + playing_resolved: bool, + #[serde(default)] last_playing_date: Option, #[serde(default)] elected: bool, @@ -35,7 +37,7 @@ struct PlaybackCandidate { fn select_playback_candidate(candidates: &[PlaybackCandidate]) -> Option<&PlaybackCandidate> { candidates .iter() - .filter(|candidate| candidate.playing) + .filter(|candidate| candidate.playing && candidate.playing_resolved) .max_by(|left, right| { left.last_playing_date .unwrap_or(f64::NEG_INFINITY) @@ -429,6 +431,7 @@ mod tests { stable_id: stable_id.into(), bundle_id: None, playing, + playing_resolved: true, last_playing_date, elected, title: None, @@ -491,6 +494,18 @@ mod tests { assert!(select_playback_candidate(&candidates).is_none()); } + #[test] + fn unresolved_scoped_playback_state_does_not_revive_a_stale_playback_rate() { + let mut stale = candidate("music", true, Some(200.0), true); + stale.playing_resolved = false; + let candidates = [candidate("spotify", true, Some(100.0), false), stale]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("spotify") + ); + } + #[test] fn stable_identifier_makes_a_missing_date_tie_deterministic() { let candidates = [ @@ -511,6 +526,7 @@ mod tests { "stableId":"com.apple.Music:default", "bundleId":"com.apple.Music", "playing":true, + "playingResolved":true, "lastPlayingDate":123.5, "elected":true, "title":"Song" diff --git a/src/media_sessions.m b/src/media_sessions.m index 37c3f7e..10184de 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -12,6 +12,7 @@ typedef void (*MRGetInfoForPlayer)(id, BOOL, dispatch_queue_t, static MRGetNowPlayingClients getNowPlayingClients; static MRGetPlayerForClient getPlayerForClient; static MRGetInfoForPlayer getInfoForPlayer; +static dispatch_queue_t requestQueue; static BOOL refreshInFlight = NO; static NSData *previousPayloadData = nil; @@ -152,10 +153,21 @@ static void refreshSessions(void) { @"stableId" : stableID, @"bundleId" : bundleID, @"playing" : @NO, + @"playingResolved" : @NO, @"elected" : @([playerPath isEqual:electedPath]), } mutableCopy]; [candidates addObject:entry]; - __block BOOL requestPlayingResolved = NO; + + id request = requestClass + ? ((id(*)(id, SEL, id))objc_msgSend)( + [requestClass alloc], + NSSelectorFromString(@"initWithPlayerPath:"), + playerPath) + : nil; + SEL isPlayingSelector = NSSelectorFromString( + @"requestIsPlayingOnQueue:completion:"); + BOOL supportsScopedPlaying = + request && [request respondsToSelector:isPlayingSelector]; dispatch_group_enter(group); getInfoForPlayer(playerPath, YES, queue, @@ -173,9 +185,10 @@ static void refreshSessions(void) { @"kMRMediaRemoteNowPlayingInfoDuration"); copyNumber(entry, @"playbackRate", information, @"kMRMediaRemoteNowPlayingInfoPlaybackRate"); - NSNumber *rate = entry[@"playbackRate"]; - if (rate && !requestPlayingResolved) { - entry[@"playing"] = @([rate doubleValue] > 0.0); + if (!supportsScopedPlaying) { + NSNumber *rate = entry[@"playbackRate"]; + entry[@"playing"] = @(rate && [rate doubleValue] > 0.0); + entry[@"playingResolved"] = @YES; } id timestamp = information[ @"kMRMediaRemoteNowPlayingInfoTimestamp"]; @@ -193,23 +206,19 @@ static void refreshSessions(void) { dispatch_group_leave(group); }); - if (requestClass) { - id request = ((id(*)(id, SEL, id))objc_msgSend)( - [requestClass alloc], - NSSelectorFromString(@"initWithPlayerPath:"), playerPath); - SEL isPlayingSelector = NSSelectorFromString( - @"requestIsPlayingOnQueue:completion:"); - if (request && [request respondsToSelector:isPlayingSelector]) { + if (request) { + if (supportsScopedPlaying) { dispatch_group_enter(group); ((void (*)(id, SEL, dispatch_queue_t, void (^)(BOOL, NSError *)))objc_msgSend)( - request, isPlayingSelector, queue, + request, isPlayingSelector, requestQueue, ^(BOOL playing, NSError *error) { (void)request; - (void)error; - requestPlayingResolved = YES; - entry[@"playing"] = @(playing); - dispatch_group_leave(group); + dispatch_async(queue, ^{ + entry[@"playing"] = @(error == nil && playing); + entry[@"playingResolved"] = @YES; + dispatch_group_leave(group); + }); }); } @@ -219,15 +228,17 @@ static void refreshSessions(void) { dispatch_group_enter(group); ((void (*)(id, SEL, dispatch_queue_t, void (^)(NSDate *, NSError *)))objc_msgSend)( - request, lastPlayingSelector, queue, + request, lastPlayingSelector, requestQueue, ^(NSDate *date, NSError *error) { (void)request; (void)error; - if ([date isKindOfClass:[NSDate class]]) { - entry[@"lastPlayingDate"] = - @([date timeIntervalSince1970]); - } - dispatch_group_leave(group); + dispatch_async(queue, ^{ + if ([date isKindOfClass:[NSDate class]]) { + entry[@"lastPlayingDate"] = + @([date timeIntervalSince1970]); + } + dispatch_group_leave(group); + }); }); } } @@ -267,6 +278,8 @@ static void scheduleRefresh(void) { fprintf(stderr, "required per-player MediaRemote symbols are unavailable\n"); return; } + requestQueue = dispatch_queue_create( + "com.local.codex-micro-chroma.media-sessions", DISPATCH_QUEUE_SERIAL); Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); if (!requestClass || From 92134aac84ea58b1858603e2e444010442ba3c75 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Tue, 11 Aug 2026 22:40:54 +0900 Subject: [PATCH 03/20] fix: harden now playing session updates --- README.md | 2 +- src/media.rs | 232 ++++++++++++++++++++++++++++++++++++++---- src/media_sessions.m | 111 ++++++++++++++++---- src/media_sessions.pl | 3 + 4 files changed, 307 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 8ea5dea..69666d1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ macOS system output MediaRemote is a private Apple framework. Its behaviour may change after a macOS update, and this architecture is not suitable for App Store distribution. If a player does not publish artwork to Now Playing, Codex Micro Chroma cannot derive a colour from it. -When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. +When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. Sessions with unresolved playback state are ineligible, and incomplete refreshes retain the last complete OS snapshot until a retry succeeds. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. ## Requirements diff --git a/src/media.rs b/src/media.rs index 63e1912..5077bdb 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,8 +1,13 @@ -use image::{DynamicImage, GenericImageView}; +use image::DynamicImage; +#[cfg(any(target_os = "macos", test))] +use image::GenericImageView; +#[cfg(any(target_os = "macos", test))] use serde::Deserialize; +#[cfg(any(target_os = "macos", test))] const POSITION_EDGE_TOLERANCE_SECONDS: f64 = 2.0; +#[cfg(any(target_os = "macos", test))] #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct PlaybackCandidate { @@ -34,6 +39,7 @@ struct PlaybackCandidate { artwork_data: Option, } +#[cfg(any(target_os = "macos", test))] fn select_playback_candidate(candidates: &[PlaybackCandidate]) -> Option<&PlaybackCandidate> { candidates .iter() @@ -62,6 +68,22 @@ pub struct TrackSnapshot { } impl TrackSnapshot { + #[cfg(any(target_os = "macos", test))] + fn clone_without_artwork(&self) -> Self { + Self { + is_playing: self.is_playing, + title: self.title.clone(), + artist: self.artist.clone(), + album: self.album.clone(), + bundle_id: self.bundle_id.clone(), + elapsed_time: self.elapsed_time, + duration: self.duration, + playback_rate: self.playback_rate, + artwork: None, + artwork_signature: self.artwork_signature, + } + } + pub fn track_key(&self) -> Option { let bundle_id = normalized(self.bundle_id.as_deref()); let title = normalized(self.title.as_deref()); @@ -89,6 +111,7 @@ fn normalized(value: Option<&str>) -> &str { value.map(str::trim).unwrap_or_default() } +#[cfg(any(target_os = "macos", test))] fn artwork_signature(image: &DynamicImage) -> u64 { let (width, height) = image.dimensions(); let mut hash = 0xcbf29ce484222325_u64; @@ -110,6 +133,7 @@ fn artwork_signature(image: &DynamicImage) -> u64 { hash } +#[cfg(any(target_os = "macos", test))] fn position_at_reception( elapsed: Option, duration: Option, @@ -146,10 +170,12 @@ fn position_at_reception( } #[derive(Default)] +#[cfg(any(target_os = "macos", test))] struct ArtworkDelivery { last_key: Option, } +#[cfg(any(target_os = "macos", test))] impl ArtworkDelivery { fn should_deliver(&mut self, key: Option<&str>, artwork_available: bool) -> bool { let Some(key) = key.filter(|_| artwork_available) else { @@ -210,6 +236,9 @@ mod platform { impl ReceivedNowPlaying { fn from_candidate(candidate: Option<&PlaybackCandidate>, previous: Option<&Self>) -> Self { let received_at = Instant::now(); + if candidate.is_none() { + return Self::stopped_from_previous(previous, received_at); + } let artwork = candidate .and_then(|candidate| candidate.artwork_data.as_deref()) .and_then(|encoded| general_purpose::STANDARD.decode(encoded).ok()) @@ -257,6 +286,33 @@ mod platform { } } + fn stopped_from_previous(previous: Option<&Self>, received_at: Instant) -> Self { + let mut snapshot = previous.map_or_else( + || TrackSnapshot { + is_playing: Some(false), + title: None, + artist: None, + album: None, + bundle_id: None, + elapsed_time: None, + duration: None, + playback_rate: None, + artwork: None, + artwork_signature: None, + }, + |previous| previous.snapshot.clone_without_artwork(), + ); + snapshot.is_playing = Some(false); + snapshot.artwork = None; + let position_at_received = previous.and_then(Self::elapsed_time); + snapshot.elapsed_time = position_at_received; + Self { + snapshot, + position_at_received, + received_at, + } + } + fn elapsed_time(&self) -> Option { let rate = self .snapshot @@ -285,6 +341,18 @@ mod platform { && left.album == right.album } + fn publish_stopped( + latest: &RwLock>, + reason: &str, + ) -> std::io::Result<()> { + let mut latest = latest + .write() + .map_err(|_| std::io::Error::other("MediaRemote state lock is poisoned"))?; + *latest = Some(ReceivedNowPlaying::from_candidate(None, latest.as_ref())); + eprintln!("MediaRemote session helper stopped: {reason}; clearing Now Playing state"); + Ok(()) + } + pub struct MediaRemoteSource { child: Child, reader: Option>, @@ -325,16 +393,32 @@ mod platform { let latest = Arc::new(RwLock::new(None::)); let reader_latest = Arc::clone(&latest); let reader = thread::spawn(move || { - for line in BufReader::new(stdout).lines().map_while(Result::ok) { - let Ok(payload) = serde_json::from_str::(&line) else { - continue; - }; - let selected = select_playback_candidate(&payload.candidates); - if let Ok(mut latest) = reader_latest.write() { - *latest = Some(ReceivedNowPlaying::from_candidate( - selected, - latest.as_ref(), - )); + let mut lines = BufReader::new(stdout).lines(); + loop { + match lines.next() { + Some(Ok(line)) => { + let Ok(payload) = serde_json::from_str::(&line) else { + continue; + }; + let selected = select_playback_candidate(&payload.candidates); + if let Ok(mut latest) = reader_latest.write() { + *latest = Some(ReceivedNowPlaying::from_candidate( + selected, + latest.as_ref(), + )); + } + } + Some(Err(error)) => { + let _ = publish_stopped( + &reader_latest, + &format!("stdout read error: {error}"), + ); + break; + } + None => { + let _ = publish_stopped(&reader_latest, "stdout reached EOF"); + break; + } } } }); @@ -350,16 +434,20 @@ mod platform { pub fn snapshot(&self) -> Option { let guard = self.latest.read().ok()?; let received = guard.as_ref()?; - let mut snapshot = received.snapshot.clone(); - snapshot.elapsed_time = received.elapsed_time(); - let key = snapshot.track_key(); + let key = received.snapshot.track_key(); let should_copy_artwork = self .artwork_delivery .borrow_mut() - .should_deliver(key.as_deref(), snapshot.artwork.is_some()); - if !should_copy_artwork { - snapshot.artwork = None; - } + .should_deliver(key.as_deref(), received.snapshot.artwork.is_some()); + let mut snapshot = TrackSnapshot { + artwork: if should_copy_artwork { + received.snapshot.artwork.clone() + } else { + None + }, + ..received.snapshot.clone_without_artwork() + }; + snapshot.elapsed_time = received.elapsed_time(); Some(snapshot) } @@ -377,6 +465,85 @@ mod platform { } } } + + #[cfg(test)] + mod tests { + use super::*; + + fn candidate(title: &str, elapsed_time: f64, info_update_date: f64) -> PlaybackCandidate { + PlaybackCandidate { + stable_id: "music".into(), + bundle_id: Some("com.apple.Music".into()), + playing: true, + playing_resolved: true, + last_playing_date: Some(100.0), + elected: true, + title: Some(title.into()), + artist: Some("Artist".into()), + album: Some("Album".into()), + elapsed_time: Some(elapsed_time), + duration: Some(240.0), + playback_rate: Some(1.0), + info_update_date: Some(info_update_date), + artwork_data: None, + } + } + + #[test] + fn stopped_state_preserves_identity_without_artwork_for_resume() { + let playing = + ReceivedNowPlaying::from_candidate(Some(&candidate("Song", 30.0, 0.0)), None); + let stopped = ReceivedNowPlaying::from_candidate(None, Some(&playing)); + + assert_eq!(stopped.snapshot.is_playing, Some(false)); + assert_eq!(stopped.snapshot.title.as_deref(), Some("Song")); + assert_eq!(stopped.snapshot.artist.as_deref(), Some("Artist")); + assert_eq!(stopped.snapshot.album.as_deref(), Some("Album")); + assert_eq!( + stopped.snapshot.bundle_id.as_deref(), + Some("com.apple.Music") + ); + assert!(stopped.snapshot.artwork.is_none()); + assert!(same_track(&playing.snapshot, &stopped.snapshot)); + } + + #[test] + fn resume_after_stopped_state_anchors_at_fresh_elapsed_time() { + let stale_update_time = 0.0; + let playing = ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, stale_update_time)), + None, + ); + let stopped = ReceivedNowPlaying::from_candidate(None, Some(&playing)); + let resumed = ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 42.0, stale_update_time)), + Some(&stopped), + ); + + assert_eq!(resumed.snapshot.is_playing, Some(true)); + assert_eq!(resumed.position_at_received, Some(42.0)); + assert_eq!(resumed.snapshot.elapsed_time, Some(42.0)); + } + + #[test] + fn publish_stopped_overwrites_a_cached_playing_snapshot() { + let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + ))); + + publish_stopped(&latest, "test termination").expect("state update succeeds"); + + let latest = latest.read().expect("state lock is readable"); + let snapshot = &latest + .as_ref() + .expect("stopped state is published") + .snapshot; + assert_eq!(snapshot.is_playing, Some(false)); + assert_eq!(snapshot.title.as_deref(), Some("Song")); + assert!(snapshot.artwork.is_none()); + } + } } #[cfg(not(target_os = "macos"))] @@ -575,6 +742,35 @@ mod tests { assert_ne!(first.track_key(), second.track_key()); } + #[test] + fn clone_without_artwork_preserves_non_artwork_fields() { + use image::{Rgba, RgbaImage}; + + let mut original = snapshot(Some("com.apple.Music"), Some("Song")); + original.elapsed_time = Some(42.0); + original.duration = Some(240.0); + original.playback_rate = Some(0.5); + original.artwork_signature = Some(7); + original.artwork = Some(DynamicImage::ImageRgba8(RgbaImage::from_pixel( + 1, + 1, + Rgba([10, 20, 30, 255]), + ))); + + let cloned = original.clone_without_artwork(); + + assert_eq!(cloned.is_playing, original.is_playing); + assert_eq!(cloned.title, original.title); + assert_eq!(cloned.artist, original.artist); + assert_eq!(cloned.album, original.album); + assert_eq!(cloned.bundle_id, original.bundle_id); + assert_eq!(cloned.elapsed_time, original.elapsed_time); + assert_eq!(cloned.duration, original.duration); + assert_eq!(cloned.playback_rate, original.playback_rate); + assert_eq!(cloned.artwork_signature, original.artwork_signature); + assert!(cloned.artwork.is_none()); + } + #[test] fn artwork_signature_is_deterministic_and_sensitive_to_sampled_pixels() { use image::{Rgba, RgbaImage}; diff --git a/src/media_sessions.m b/src/media_sessions.m index 10184de..d99c5a8 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -9,12 +9,21 @@ typedef void (*MRGetInfoForPlayer)(id, BOOL, dispatch_queue_t, void (^)(NSDictionary *)); +@interface MRPlayerPath : NSObject +- (instancetype)initWithOrigin:(id)origin client:(id)client player:(id)player; +@end + +@interface MRNowPlayingRequest : NSObject +- (instancetype)initWithPlayerPath:(id)playerPath; +@end + static MRGetNowPlayingClients getNowPlayingClients; static MRGetPlayerForClient getPlayerForClient; static MRGetInfoForPlayer getInfoForPlayer; static dispatch_queue_t requestQueue; static BOOL refreshInFlight = NO; static NSData *previousPayloadData = nil; +static NSMutableDictionary *artworkCache = nil; static id objectProperty(id object, NSString *selectorName) { SEL selector = NSSelectorFromString(selectorName); @@ -36,7 +45,33 @@ static long integerProperty(id object, NSString *selectorName, BOOL *present) { return 0; } *present = YES; - return ((long(*)(id, SEL))objc_msgSend)(object, selector); + return (long)((int (*)(id, SEL))objc_msgSend)(object, selector); +} + +static NSString *cachedArtworkData(NSString *stableID, NSData *artwork) { + if (!stableID || !artwork) { + return nil; + } + NSDictionary *cached = artworkCache[stableID]; + NSData *cachedData = cached[@"data"]; + NSString *cachedEncoded = cached[@"encoded"]; + if ([cachedData isKindOfClass:[NSData class]] && + [cachedEncoded isKindOfClass:[NSString class]] && + [cachedData isEqualToData:artwork]) { + return cachedEncoded; + } + + NSString *encoded = [artwork base64EncodedStringWithOptions:0]; + artworkCache[stableID] = @{ @"data" : [artwork copy], @"encoded" : encoded }; + return encoded; +} + +static void pruneArtworkCache(NSSet *activeStableIDs) { + for (NSString *stableID in [artworkCache.allKeys copy]) { + if (![activeStableIDs containsObject:stableID]) { + [artworkCache removeObjectForKey:stableID]; + } + } } static void copyString(NSMutableDictionary *destination, NSString *outputKey, @@ -86,24 +121,31 @@ static void refreshSessions(void) { dispatch_queue_t queue = dispatch_get_main_queue(); NSMutableArray *candidates = [NSMutableArray array]; + NSMutableSet *activeStableIDs = [NSMutableSet set]; Class playerPathClass = NSClassFromString(@"MRPlayerPath"); Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); id electedPath = objectProperty(requestClass, @"localNowPlayingPlayerPath"); __block BOOL completed = NO; + __block BOOL refreshValid = YES; - void (^complete)(void) = ^{ + void (^complete)(BOOL) = ^(BOOL timedOut) { if (completed) { return; } completed = YES; - printCandidates(candidates); + if (!timedOut && refreshValid) { + printCandidates(candidates); + pruneArtworkCache(activeStableIDs); + } refreshInFlight = NO; scheduleRefresh(); }; dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.75 * NSEC_PER_SEC)), - queue, complete); + queue, ^{ + complete(YES); + }); getNowPlayingClients(queue, ^(id clientsValue) { if (completed) { @@ -117,15 +159,19 @@ static void refreshSessions(void) { for (id client in clients) { dispatch_group_enter(group); getPlayerForClient(client, nil, queue, ^(id player) { + if (completed) { + dispatch_group_leave(group); + return; + } if (!player || !playerPathClass) { dispatch_group_leave(group); return; } - id playerPath = ((id(*)(id, SEL, id, id, id))objc_msgSend)( - [playerPathClass alloc], - NSSelectorFromString(@"initWithOrigin:client:player:"), nil, - client, player); + MRPlayerPath *playerPath = + [[(id)playerPathClass alloc] initWithOrigin:nil + client:client + player:player]; if (!playerPath) { dispatch_group_leave(group); return; @@ -146,9 +192,10 @@ static void refreshSessions(void) { bundleID, playerID, processIdentifier] - : [NSString stringWithFormat:@"%@:%@", - bundleID, - playerID]; + : [NSString stringWithFormat:@"%@:%@", + bundleID, + playerID]; + [activeStableIDs addObject:stableID]; NSMutableDictionary *entry = [@{ @"stableId" : stableID, @"bundleId" : bundleID, @@ -158,12 +205,10 @@ static void refreshSessions(void) { } mutableCopy]; [candidates addObject:entry]; - id request = requestClass - ? ((id(*)(id, SEL, id))objc_msgSend)( - [requestClass alloc], - NSSelectorFromString(@"initWithPlayerPath:"), - playerPath) - : nil; + MRNowPlayingRequest *request = + requestClass + ? [[(id)requestClass alloc] initWithPlayerPath:playerPath] + : nil; SEL isPlayingSelector = NSSelectorFromString( @"requestIsPlayingOnQueue:completion:"); BOOL supportsScopedPlaying = @@ -172,6 +217,10 @@ static void refreshSessions(void) { dispatch_group_enter(group); getInfoForPlayer(playerPath, YES, queue, ^(NSDictionary *information) { + if (completed) { + dispatch_group_leave(group); + return; + } if ([information isKindOfClass:[NSDictionary class]]) { copyString(entry, @"title", information, @"kMRMediaRemoteNowPlayingInfoTitle"); @@ -199,8 +248,11 @@ static void refreshSessions(void) { id artwork = information[ @"kMRMediaRemoteNowPlayingInfoArtworkData"]; if ([artwork isKindOfClass:[NSData class]]) { - entry[@"artworkData"] = - [(NSData *)artwork base64EncodedStringWithOptions:0]; + NSString *encoded = + cachedArtworkData(stableID, (NSData *)artwork); + if (encoded) { + entry[@"artworkData"] = encoded; + } } } dispatch_group_leave(group); @@ -215,8 +267,16 @@ static void refreshSessions(void) { ^(BOOL playing, NSError *error) { (void)request; dispatch_async(queue, ^{ - entry[@"playing"] = @(error == nil && playing); - entry[@"playingResolved"] = @YES; + if (completed) { + dispatch_group_leave(group); + return; + } + if (error) { + refreshValid = NO; + } else { + entry[@"playing"] = @(playing); + entry[@"playingResolved"] = @YES; + } dispatch_group_leave(group); }); }); @@ -233,6 +293,10 @@ static void refreshSessions(void) { (void)request; (void)error; dispatch_async(queue, ^{ + if (completed) { + dispatch_group_leave(group); + return; + } if ([date isKindOfClass:[NSDate class]]) { entry[@"lastPlayingDate"] = @([date timeIntervalSince1970]); @@ -247,7 +311,9 @@ static void refreshSessions(void) { }); } - dispatch_group_notify(group, queue, complete); + dispatch_group_notify(group, queue, ^{ + complete(NO); + }); }); } @@ -280,6 +346,7 @@ static void scheduleRefresh(void) { } requestQueue = dispatch_queue_create( "com.local.codex-micro-chroma.media-sessions", DISPATCH_QUEUE_SERIAL); + artworkCache = [NSMutableDictionary dictionary]; Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); if (!requestClass || diff --git a/src/media_sessions.pl b/src/media_sessions.pl index bd6c471..35b9a71 100644 --- a/src/media_sessions.pl +++ b/src/media_sessions.pl @@ -10,5 +10,8 @@ my $symbol = DynaLoader::dl_find_symbol($handle, "chroma_media_sessions_stream") or die DynaLoader::dl_error() . "\n"; +# Perl invokes installed XSUBs with pTHX_ and CV * arguments. The helper must +# remain a void(void)-style entry point that ignores those arguments and returns +# no value while it blocks on its own run loop. DynaLoader::dl_install_xsub("main::chroma_media_sessions_stream", $symbol); chroma_media_sessions_stream(); From 20619a5074db3240d302a874eca568fd369b54c0 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 08:50:32 +0900 Subject: [PATCH 04/20] fix: complete media session refreshes --- src/media.rs | 188 ++++++++++++++++++++++++++++++++++++++----- src/media_sessions.m | 5 +- 2 files changed, 172 insertions(+), 21 deletions(-) diff --git a/src/media.rs b/src/media.rs index 5077bdb..01c2c59 100644 --- a/src/media.rs +++ b/src/media.rs @@ -229,31 +229,58 @@ mod platform { struct ReceivedNowPlaying { snapshot: TrackSnapshot, + artwork: Option, position_at_received: Option, received_at: Instant, } + struct CachedArtwork { + key: Arc, + image: Arc, + signature: u64, + } + + impl Clone for CachedArtwork { + fn clone(&self) -> Self { + Self { + key: self.key.clone(), + image: Arc::clone(&self.image), + signature: self.signature, + } + } + } + + #[derive(Clone, PartialEq, Eq)] + struct ArtworkCacheKey { + stable_id: String, + encoded_artwork: String, + } + + impl ArtworkCacheKey { + fn matches_candidate(&self, candidate: &PlaybackCandidate, encoded_artwork: &str) -> bool { + self.stable_id == candidate.stable_id.as_str() + && self.encoded_artwork == encoded_artwork + } + } + impl ReceivedNowPlaying { fn from_candidate(candidate: Option<&PlaybackCandidate>, previous: Option<&Self>) -> Self { let received_at = Instant::now(); - if candidate.is_none() { + let Some(candidate) = candidate else { return Self::stopped_from_previous(previous, received_at); - } - let artwork = candidate - .and_then(|candidate| candidate.artwork_data.as_deref()) - .and_then(|encoded| general_purpose::STANDARD.decode(encoded).ok()) - .and_then(|bytes| image::load_from_memory(&bytes).ok()); + }; + let artwork = Self::artwork_from_candidate(candidate, previous); let mut snapshot = TrackSnapshot { - is_playing: Some(candidate.is_some_and(|candidate| candidate.playing)), - title: candidate.and_then(|candidate| candidate.title.clone()), - artist: candidate.and_then(|candidate| candidate.artist.clone()), - album: candidate.and_then(|candidate| candidate.album.clone()), - bundle_id: candidate.and_then(|candidate| candidate.bundle_id.clone()), - elapsed_time: candidate.and_then(|candidate| candidate.elapsed_time), - duration: candidate.and_then(|candidate| candidate.duration), - playback_rate: candidate.and_then(|candidate| candidate.playback_rate), - artwork_signature: artwork.as_ref().map(artwork_signature), - artwork, + is_playing: Some(candidate.playing), + title: candidate.title.clone(), + artist: candidate.artist.clone(), + album: candidate.album.clone(), + bundle_id: candidate.bundle_id.clone(), + elapsed_time: candidate.elapsed_time, + duration: candidate.duration, + playback_rate: candidate.playback_rate, + artwork_signature: artwork.as_ref().map(|artwork| artwork.signature), + artwork: None, }; let resumed = previous.is_some_and(|previous| { same_track(&previous.snapshot, &snapshot) @@ -261,7 +288,7 @@ mod platform { && snapshot.is_playing == Some(true) }); let info_update_time = candidate - .and_then(|candidate| candidate.info_update_date) + .info_update_date .filter(|seconds| seconds.is_finite() && *seconds >= 0.0) .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs_f64(seconds))); let update_age_seconds = info_update_time.and_then(|updated_at| { @@ -281,11 +308,36 @@ mod platform { snapshot.elapsed_time = position_at_received; Self { snapshot, + artwork, position_at_received, received_at, } } + fn artwork_from_candidate( + candidate: &PlaybackCandidate, + previous: Option<&Self>, + ) -> Option { + let encoded_artwork = candidate.artwork_data.as_ref()?; + if let Some(artwork) = previous + .and_then(|previous| previous.artwork.as_ref()) + .filter(|artwork| artwork.key.matches_candidate(candidate, encoded_artwork)) + { + return Some(artwork.clone()); + } + let bytes = general_purpose::STANDARD.decode(encoded_artwork).ok()?; + let image = image::load_from_memory(&bytes).ok()?; + let signature = artwork_signature(&image); + Some(CachedArtwork { + key: Arc::new(ArtworkCacheKey { + stable_id: candidate.stable_id.clone(), + encoded_artwork: encoded_artwork.clone(), + }), + image: Arc::new(image), + signature, + }) + } + fn stopped_from_previous(previous: Option<&Self>, received_at: Instant) -> Self { let mut snapshot = previous.map_or_else( || TrackSnapshot { @@ -308,6 +360,7 @@ mod platform { snapshot.elapsed_time = position_at_received; Self { snapshot, + artwork: None, position_at_received, received_at, } @@ -438,10 +491,13 @@ mod platform { let should_copy_artwork = self .artwork_delivery .borrow_mut() - .should_deliver(key.as_deref(), received.snapshot.artwork.is_some()); + .should_deliver(key.as_deref(), received.artwork.is_some()); let mut snapshot = TrackSnapshot { artwork: if should_copy_artwork { - received.snapshot.artwork.clone() + received + .artwork + .as_ref() + .map(|artwork| (*artwork.image).clone()) } else { None }, @@ -469,6 +525,8 @@ mod platform { #[cfg(test)] mod tests { use super::*; + use image::{ImageFormat, Rgba, RgbaImage}; + use std::io::Cursor; fn candidate(title: &str, elapsed_time: f64, info_update_date: f64) -> PlaybackCandidate { PlaybackCandidate { @@ -489,6 +547,27 @@ mod platform { } } + fn encoded_artwork(red: u8, green: u8, blue: u8) -> String { + let image = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel( + 2, + 2, + Rgba([red, green, blue, 255]), + )); + let mut bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .expect("test image encodes"); + general_purpose::STANDARD.encode(bytes) + } + + fn candidate_with_artwork(stable_id: &str, encoded_artwork: String) -> PlaybackCandidate { + PlaybackCandidate { + stable_id: stable_id.into(), + artwork_data: Some(encoded_artwork), + ..candidate("Song", 30.0, 0.0) + } + } + #[test] fn stopped_state_preserves_identity_without_artwork_for_resume() { let playing = @@ -543,6 +622,77 @@ mod platform { assert_eq!(snapshot.title.as_deref(), Some("Song")); assert!(snapshot.artwork.is_none()); } + + #[test] + fn unchanged_session_and_artwork_reuses_decoded_image() { + let encoded_artwork = encoded_artwork(10, 20, 30); + let first_candidate = candidate_with_artwork("music", encoded_artwork.clone()); + let first = ReceivedNowPlaying::from_candidate(Some(&first_candidate), None); + let second_candidate = PlaybackCandidate { + elapsed_time: Some(42.0), + info_update_date: Some(10.0), + ..candidate_with_artwork("music", encoded_artwork) + }; + + let second = ReceivedNowPlaying::from_candidate(Some(&second_candidate), Some(&first)); + + let first_artwork = first.artwork.as_ref().expect("first artwork decodes"); + let second_artwork = second.artwork.as_ref().expect("second artwork is present"); + assert!(Arc::ptr_eq(&first_artwork.key, &second_artwork.key)); + assert!(Arc::ptr_eq(&first_artwork.image, &second_artwork.image)); + assert_eq!(first_artwork.signature, second_artwork.signature); + assert!(second.snapshot.artwork.is_none()); + } + + #[test] + fn changed_session_identity_decodes_artwork_again() { + let encoded_artwork = encoded_artwork(10, 20, 30); + let first_candidate = candidate_with_artwork("music", encoded_artwork.clone()); + let first = ReceivedNowPlaying::from_candidate(Some(&first_candidate), None); + let second_candidate = candidate_with_artwork("spotify", encoded_artwork); + + let second = ReceivedNowPlaying::from_candidate(Some(&second_candidate), Some(&first)); + + let first_artwork = first.artwork.as_ref().expect("first artwork decodes"); + let second_artwork = second.artwork.as_ref().expect("second artwork decodes"); + assert!(!Arc::ptr_eq(&first_artwork.image, &second_artwork.image)); + assert_eq!(first_artwork.signature, second_artwork.signature); + } + + #[test] + fn changed_encoded_artwork_decodes_and_updates_signature() { + let first_candidate = candidate_with_artwork("music", encoded_artwork(10, 20, 30)); + let first = ReceivedNowPlaying::from_candidate(Some(&first_candidate), None); + let second_candidate = candidate_with_artwork("music", encoded_artwork(30, 20, 10)); + + let second = ReceivedNowPlaying::from_candidate(Some(&second_candidate), Some(&first)); + + let first_artwork = first.artwork.as_ref().expect("first artwork decodes"); + let second_artwork = second.artwork.as_ref().expect("second artwork decodes"); + assert!(!Arc::ptr_eq(&first_artwork.image, &second_artwork.image)); + assert_ne!(first_artwork.signature, second_artwork.signature); + assert_ne!( + first.snapshot.artwork_signature, + second.snapshot.artwork_signature + ); + } + + #[test] + fn stopped_state_clears_cached_artwork() { + let playing = ReceivedNowPlaying::from_candidate( + Some(&candidate_with_artwork( + "music", + encoded_artwork(10, 20, 30), + )), + None, + ); + + let stopped = ReceivedNowPlaying::from_candidate(None, Some(&playing)); + + assert!(stopped.artwork.is_none()); + assert!(stopped.snapshot.artwork_signature.is_some()); + assert!(stopped.snapshot.artwork.is_none()); + } } } diff --git a/src/media_sessions.m b/src/media_sessions.m index d99c5a8..a94fd38 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -291,13 +291,14 @@ static void refreshSessions(void) { request, lastPlayingSelector, requestQueue, ^(NSDate *date, NSError *error) { (void)request; - (void)error; dispatch_async(queue, ^{ if (completed) { dispatch_group_leave(group); return; } - if ([date isKindOfClass:[NSDate class]]) { + if (error) { + refreshValid = NO; + } else if ([date isKindOfClass:[NSDate class]]) { entry[@"lastPlayingDate"] = @([date timeIntervalSince1970]); } From 2de5f89dbe7605efc655947e0872aef60c8d4696 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 12:05:24 +0900 Subject: [PATCH 05/20] fix: harden now playing session helper --- .github/workflows/ci.yml | 52 ++++++++++++++++ README.md | 3 +- build.rs | 8 +-- src/main.rs | 8 +-- src/media.rs | 127 +++++++++++++++++++++++++++++++++------ src/media_sessions.m | 73 +++++++++++++++++++--- src/media_sessions.pl | 17 ------ 7 files changed, 229 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 src/media_sessions.pl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9fc85b8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + pull_request: + branches: + - main + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + RUST_TOOLCHAIN: 1.88.0 + +jobs: + rust: + name: Rust checks and macOS builds + runs-on: macos-15 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install pinned Rust toolchain + run: | + set -euo pipefail + rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal --component rustfmt --component clippy + rustup target add --toolchain "$RUST_TOOLCHAIN" \ + aarch64-apple-darwin \ + x86_64-apple-darwin + + - name: Check formatting + run: cargo +"$RUST_TOOLCHAIN" fmt --all --check + + - name: Lint + run: cargo +"$RUST_TOOLCHAIN" clippy --all-targets --all-features --locked -- -D warnings + + - name: Test + run: cargo +"$RUST_TOOLCHAIN" test --all-targets --all-features --locked + + - name: Build arm64 release binary + run: cargo +"$RUST_TOOLCHAIN" build --release --locked --target aarch64-apple-darwin + + - name: Build x86_64 release binary + run: cargo +"$RUST_TOOLCHAIN" build --release --locked --target x86_64-apple-darwin diff --git a/README.md b/README.md index 69666d1..ddab66b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Lighting effects and brightness respond dynamically to the audio's musical dynam ```text macOS Now Playing (Music / Spotify / browser / other players) -> macOS MediaRemote - -> Apple-signed /usr/bin/perl + embedded per-player session adapter + -> ad-hoc signed embedded per-player session helper -> active-session arbitration from macOS-owned state -> local image decoding and representative-colour extraction -> artwork colour @@ -36,7 +36,6 @@ When several applications publish Now Playing sessions simultaneously, Codex Mic - A connected Work Louder Codex Micro - Rust 1.88 or later - Xcode Command Line Tools -- The standard macOS `/usr/bin/perl` ## Compatibility and current status diff --git a/build.rs b/build.rs index ca5e9ed..93018ac 100644 --- a/build.rs +++ b/build.rs @@ -7,7 +7,6 @@ fn main() { println!("cargo:rerun-if-changed=src/process_tap.h"); println!("cargo:rerun-if-changed=src/process_tap.m"); println!("cargo:rerun-if-changed=src/media_sessions.m"); - println!("cargo:rerun-if-changed=src/media_sessions.pl"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { return; @@ -21,17 +20,16 @@ fn main() { .compile("codex_micro_chroma_process_tap"); let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("build output directory")); - let helper = out_dir.join("libcodex_micro_chroma_media_sessions.dylib"); + let helper = out_dir.join("codex_micro_chroma_media_sessions"); let target_arch = match env::var("CARGO_CFG_TARGET_ARCH").as_deref() { Ok("aarch64") => "arm64", Ok("x86_64") => "x86_64", Ok(other) => panic!("unsupported macOS target architecture: {other}"), Err(error) => panic!("missing target architecture: {error}"), }; - let clang_status = Command::new("xcrun") + let clang_status = Command::new("/usr/bin/xcrun") .args([ "clang", - "-dynamiclib", "-fobjc-arc", "-fblocks", "-Wall", @@ -53,7 +51,7 @@ fn main() { "failed to compile MediaRemote session helper" ); - let codesign_status = Command::new("codesign") + let codesign_status = Command::new("/usr/bin/codesign") .args(["--force", "--sign", "-"]) .arg(&helper) .status() diff --git a/src/main.rs b/src/main.rs index a7eb9ce..2c27ce6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use std::{ - path::Path, sync::{ atomic::{AtomicBool, AtomicU16, Ordering}, Arc, @@ -172,11 +171,6 @@ fn main() -> Result<()> { } fn probe() -> Result<()> { - if !Path::new("/usr/bin/perl").is_file() { - bail!("MediaRemote host /usr/bin/perl is missing"); - } - println!("Perl adapter host: /usr/bin/perl"); - let device = hid::probe().context("Codex Micro HID probe failed")?; println!( "Codex Micro HID: {}{}", @@ -196,7 +190,7 @@ fn probe() -> Result<()> { snapshot.title.as_deref().unwrap_or("unknown title"), snapshot.bundle_id.as_deref().unwrap_or("unknown app") ), - None => println!("MediaRemote: adapter started; play a song to verify a payload"), + None => println!("MediaRemote: helper started; play a song to verify a payload"), } println!("SIP changes: not required"); Ok(()) diff --git a/src/media.rs b/src/media.rs index 01c2c59..be18353 100644 --- a/src/media.rs +++ b/src/media.rs @@ -199,9 +199,9 @@ mod platform { cell::RefCell, fs, io::{BufRead, BufReader}, - path::Path, + os::unix::fs::PermissionsExt, process::{Child, Command, Stdio}, - sync::{Arc, RwLock}, + sync::{mpsc, Arc, RwLock}, thread::{self, JoinHandle}, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -216,17 +216,37 @@ mod platform { PlaybackCandidate, TrackSnapshot, }; - const MEDIA_SESSIONS_DYLIB: &[u8] = include_bytes!(concat!( + const MEDIA_SESSIONS_HELPER: &[u8] = include_bytes!(concat!( env!("OUT_DIR"), - "/libcodex_micro_chroma_media_sessions.dylib" + "/codex_micro_chroma_media_sessions" )); - const MEDIA_SESSIONS_PERL: &str = include_str!("media_sessions.pl"); + const HELPER_READY_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Deserialize)] struct SessionPayload { candidates: Vec, } + #[derive(Debug, PartialEq, Eq)] + enum HelperControlMessage { + Ready, + Invalid(String), + } + + fn parse_helper_control_line(line: &str) -> HelperControlMessage { + match serde_json::from_str::(line) { + Ok(serde_json::Value::Object(map)) + if map + .get("ready") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) => + { + HelperControlMessage::Ready + } + _ => HelperControlMessage::Invalid(line.to_owned()), + } + } + struct ReceivedNowPlaying { snapshot: TrackSnapshot, artwork: Option, @@ -416,24 +436,22 @@ mod platform { impl MediaRemoteSource { pub fn new() -> Result { - if !Path::new("/usr/bin/perl").is_file() { - bail!("macOS system Perl was not found at /usr/bin/perl"); - } - let temp_dir = tempfile::Builder::new() .prefix("codex-micro-chroma-media-sessions") .tempdir() .context("could not create MediaRemote helper directory")?; - let dylib_path = temp_dir.path().join("media_sessions.dylib"); - let perl_path = temp_dir.path().join("media_sessions.pl"); - fs::write(&dylib_path, MEDIA_SESSIONS_DYLIB) + let helper_path = temp_dir.path().join("media_sessions"); + fs::write(&helper_path, MEDIA_SESSIONS_HELPER) .context("could not extract MediaRemote session helper")?; - fs::write(&perl_path, MEDIA_SESSIONS_PERL) - .context("could not extract MediaRemote Perl shim")?; - - let mut child = Command::new("/usr/bin/perl") - .arg(&perl_path) - .arg(&dylib_path) + let mut permissions = fs::metadata(&helper_path) + .context("could not stat MediaRemote session helper")? + .permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&helper_path, permissions) + .context("could not make MediaRemote session helper executable")?; + + let mut child = Command::new(&helper_path) + .env_clear() .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -445,8 +463,34 @@ mod platform { .context("MediaRemote session helper stdout is unavailable")?; let latest = Arc::new(RwLock::new(None::)); let reader_latest = Arc::clone(&latest); + let (ready_tx, ready_rx) = mpsc::channel(); let reader = thread::spawn(move || { let mut lines = BufReader::new(stdout).lines(); + match lines.next() { + Some(Ok(line)) => match parse_helper_control_line(&line) { + HelperControlMessage::Ready => { + let _ = ready_tx.send(Ok(())); + } + HelperControlMessage::Invalid(line) => { + let _ = ready_tx.send(Err(format!( + "malformed pre-ready output from MediaRemote session helper: {line}" + ))); + return; + } + }, + Some(Err(error)) => { + let _ = ready_tx.send(Err(format!( + "could not read MediaRemote session helper ready message: {error}" + ))); + return; + } + None => { + let _ = ready_tx.send(Err( + "MediaRemote session helper exited before ready".to_owned(), + )); + return; + } + } loop { match lines.next() { Some(Ok(line)) => { @@ -475,6 +519,27 @@ mod platform { } } }); + match ready_rx.recv_timeout(HELPER_READY_TIMEOUT) { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + bail!("{error}"); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + bail!("MediaRemote session helper did not become ready within {HELPER_READY_TIMEOUT:?}"); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + bail!("MediaRemote session helper reader stopped before ready"); + } + } Ok(Self { child, reader: Some(reader), @@ -693,6 +758,30 @@ mod platform { assert!(stopped.snapshot.artwork_signature.is_some()); assert!(stopped.snapshot.artwork.is_none()); } + + #[test] + fn helper_control_line_accepts_ready_message() { + assert_eq!( + parse_helper_control_line(r#"{"ready":true}"#), + HelperControlMessage::Ready + ); + } + + #[test] + fn helper_control_line_rejects_session_payload_before_ready() { + assert_eq!( + parse_helper_control_line(r#"{"candidates":[]}"#), + HelperControlMessage::Invalid(r#"{"candidates":[]}"#.to_owned()) + ); + } + + #[test] + fn helper_control_line_rejects_malformed_before_ready() { + assert_eq!( + parse_helper_control_line("not json"), + HelperControlMessage::Invalid("not json".to_owned()) + ); + } } } @@ -849,7 +938,7 @@ mod tests { "title":"Song" }"#, ) - .expect("valid adapter payload"); + .expect("valid helper payload"); assert_eq!(candidate.bundle_id.as_deref(), Some("com.apple.Music")); assert_eq!(candidate.last_playing_date, Some(123.5)); diff --git a/src/media_sessions.m b/src/media_sessions.m index a94fd38..fbfba75 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -85,11 +85,41 @@ static void copyString(NSMutableDictionary *destination, NSString *outputKey, static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, NSDictionary *source, NSString *sourceKey) { id value = source[sourceKey]; - if ([value isKindOfClass:[NSNumber class]]) { + if ([value isKindOfClass:[NSNumber class]] && + isfinite([(NSNumber *)value doubleValue])) { destination[outputKey] = value; } } +static NSArray *publicCandidatesIfComplete(NSArray *candidates, BOOL *complete) { + NSMutableArray *publicCandidates = + [NSMutableArray arrayWithCapacity:candidates.count]; + BOOL valid = YES; + + for (NSDictionary *candidate in candidates) { + if (![candidate isKindOfClass:[NSDictionary class]]) { + valid = NO; + continue; + } + + BOOL playing = [candidate[@"playing"] boolValue]; + if (playing) { + if (![candidate[@"metadataResolved"] boolValue] || + [candidate[@"lastPlayingDateError"] boolValue]) { + valid = NO; + } + } + + NSMutableDictionary *publicCandidate = [candidate mutableCopy]; + [publicCandidate removeObjectForKey:@"metadataResolved"]; + [publicCandidate removeObjectForKey:@"lastPlayingDateError"]; + [publicCandidates addObject:publicCandidate]; + } + + *complete = valid; + return publicCandidates; +} + static void printCandidates(NSArray *candidates) { NSDictionary *payload = @{ @"candidates" : candidates ?: @[] }; NSError *error = nil; @@ -134,8 +164,13 @@ static void refreshSessions(void) { } completed = YES; if (!timedOut && refreshValid) { - printCandidates(candidates); - pruneArtworkCache(activeStableIDs); + BOOL candidatesComplete = NO; + NSArray *publicCandidates = + publicCandidatesIfComplete(candidates, &candidatesComplete); + if (candidatesComplete) { + printCandidates(publicCandidates); + pruneArtworkCache(activeStableIDs); + } } refreshInFlight = NO; scheduleRefresh(); @@ -201,6 +236,8 @@ static void refreshSessions(void) { @"bundleId" : bundleID, @"playing" : @NO, @"playingResolved" : @NO, + @"metadataResolved" : @NO, + @"lastPlayingDateError" : @NO, @"elected" : @([playerPath isEqual:electedPath]), } mutableCopy]; [candidates addObject:entry]; @@ -222,6 +259,7 @@ static void refreshSessions(void) { return; } if ([information isKindOfClass:[NSDictionary class]]) { + entry[@"metadataResolved"] = @YES; copyString(entry, @"title", information, @"kMRMediaRemoteNowPlayingInfoTitle"); copyString(entry, @"artist", information, @@ -297,7 +335,7 @@ static void refreshSessions(void) { return; } if (error) { - refreshValid = NO; + entry[@"lastPlayingDateError"] = @YES; } else if ([date isKindOfClass:[NSDate class]]) { entry[@"lastPlayingDate"] = @([date timeIntervalSince1970]); @@ -326,13 +364,18 @@ static void scheduleRefresh(void) { }); } -__attribute__((visibility("default"))) void chroma_media_sessions_stream(void) { +static void printReady(void) { + printf("{\"ready\":true}\n"); + fflush(stdout); +} + +int main(void) { @autoreleasepool { NSBundle *framework = [NSBundle bundleWithPath: @"/System/Library/PrivateFrameworks/MediaRemote.framework"]; if (![framework load]) { fprintf(stderr, "could not load MediaRemote.framework\n"); - return; + return 1; } getNowPlayingClients = (MRGetNowPlayingClients)dlsym( @@ -343,15 +386,25 @@ static void scheduleRefresh(void) { RTLD_DEFAULT, "MRMediaRemoteGetNowPlayingInfoForPlayer"); if (!getNowPlayingClients || !getPlayerForClient || !getInfoForPlayer) { fprintf(stderr, "required per-player MediaRemote symbols are unavailable\n"); - return; + return 1; } requestQueue = dispatch_queue_create( "com.local.codex-micro-chroma.media-sessions", DISPATCH_QUEUE_SERIAL); artworkCache = [NSMutableDictionary dictionary]; Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); - if (!requestClass || - !class_getInstanceMethod( + if (!requestClass) { + fprintf(stderr, "MRNowPlayingRequest is unavailable\n"); + return 1; + } + if (!class_getInstanceMethod( + requestClass, + NSSelectorFromString(@"requestIsPlayingOnQueue:completion:"))) { + fprintf(stderr, + "MediaRemote scoped playing requests are unavailable; " + "playbackRate will be used as the playing fallback\n"); + } + if (!class_getInstanceMethod( requestClass, NSSelectorFromString(@"requestLastPlayingDateOnQueue:completion:"))) { fprintf(stderr, @@ -359,6 +412,7 @@ static void scheduleRefresh(void) { "OS election will be used as the tie-breaker\n"); } + printReady(); dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ @@ -366,4 +420,5 @@ static void scheduleRefresh(void) { }); CFRunLoopRun(); } + return 0; } diff --git a/src/media_sessions.pl b/src/media_sessions.pl deleted file mode 100644 index 35b9a71..0000000 --- a/src/media_sessions.pl +++ /dev/null @@ -1,17 +0,0 @@ -use strict; -use warnings; -use DynaLoader; - -my $library = shift @ARGV; -die "media session helper path is required\n" unless defined $library; - -my $handle = DynaLoader::dl_load_file($library, 0) - or die DynaLoader::dl_error() . "\n"; -my $symbol = DynaLoader::dl_find_symbol($handle, "chroma_media_sessions_stream") - or die DynaLoader::dl_error() . "\n"; - -# Perl invokes installed XSUBs with pTHX_ and CV * arguments. The helper must -# remain a void(void)-style entry point that ignores those arguments and returns -# no value while it blocks on its own run loop. -DynaLoader::dl_install_xsub("main::chroma_media_sessions_stream", $symbol); -chroma_media_sessions_stream(); From e353e6d26d2ec2ee0da30374b1f14cb2212a36c9 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 12:16:26 +0900 Subject: [PATCH 06/20] fix: fall back when last playing date is unavailable --- src/media.rs | 58 +++++++++++++++++++++++++++++--------------- src/media_sessions.m | 6 +++-- 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/media.rs b/src/media.rs index be18353..0f7cc80 100644 --- a/src/media.rs +++ b/src/media.rs @@ -491,11 +491,21 @@ mod platform { return; } } + let mut reported_parse_error = false; loop { match lines.next() { Some(Ok(line)) => { - let Ok(payload) = serde_json::from_str::(&line) else { - continue; + let payload = match serde_json::from_str::(&line) { + Ok(payload) => payload, + Err(error) => { + if !reported_parse_error { + reported_parse_error = true; + eprintln!( + "MediaRemote session helper sent an unparsable payload: {error}" + ); + } + continue; + } }; let selected = select_playback_candidate(&payload.candidates); if let Ok(mut latest) = reader_latest.write() { @@ -519,26 +529,21 @@ mod platform { } } }); - match ready_rx.recv_timeout(HELPER_READY_TIMEOUT) { - Ok(Ok(())) => {} - Ok(Err(error)) => { - let _ = child.kill(); - let _ = child.wait(); - let _ = reader.join(); - bail!("{error}"); - } - Err(mpsc::RecvTimeoutError::Timeout) => { - let _ = child.kill(); - let _ = child.wait(); - let _ = reader.join(); - bail!("MediaRemote session helper did not become ready within {HELPER_READY_TIMEOUT:?}"); - } + let ready_error = match ready_rx.recv_timeout(HELPER_READY_TIMEOUT) { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error), + Err(mpsc::RecvTimeoutError::Timeout) => Some(format!( + "MediaRemote session helper did not become ready within {HELPER_READY_TIMEOUT:?}" + )), Err(mpsc::RecvTimeoutError::Disconnected) => { - let _ = child.kill(); - let _ = child.wait(); - let _ = reader.join(); - bail!("MediaRemote session helper reader stopped before ready"); + Some("MediaRemote session helper reader stopped before ready".to_owned()) } + }; + if let Some(error) = ready_error { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + bail!("{error}"); } Ok(Self { child, @@ -890,6 +895,19 @@ mod tests { ); } + #[test] + fn os_elected_session_breaks_missing_date_ties() { + let candidates = [ + candidate("spotify", true, None, false), + candidate("music", true, None, true), + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("music") + ); + } + #[test] fn no_active_session_turns_the_selection_off() { let candidates = [ diff --git a/src/media_sessions.m b/src/media_sessions.m index fbfba75..c66f953 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -104,13 +104,15 @@ static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, BOOL playing = [candidate[@"playing"] boolValue]; if (playing) { - if (![candidate[@"metadataResolved"] boolValue] || - [candidate[@"lastPlayingDateError"] boolValue]) { + if (![candidate[@"metadataResolved"] boolValue]) { valid = NO; } } NSMutableDictionary *publicCandidate = [candidate mutableCopy]; + if ([candidate[@"lastPlayingDateError"] boolValue]) { + [publicCandidate removeObjectForKey:@"lastPlayingDate"]; + } [publicCandidate removeObjectForKey:@"metadataResolved"]; [publicCandidate removeObjectForKey:@"lastPlayingDateError"]; [publicCandidates addObject:publicCandidate]; From 26647ebb4f7928aab76de2334578afbde84ebac1 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 12:21:46 +0900 Subject: [PATCH 07/20] ci: verify media session helpers --- .github/workflows/ci.yml | 20 ++++++++++++++++++-- src/media.rs | 23 ++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fc85b8..8554948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,23 @@ jobs: run: cargo +"$RUST_TOOLCHAIN" test --all-targets --all-features --locked - name: Build arm64 release binary - run: cargo +"$RUST_TOOLCHAIN" build --release --locked --target aarch64-apple-darwin + run: | + set -euo pipefail + cargo +"$RUST_TOOLCHAIN" build --release --locked --target aarch64-apple-darwin + helper_count="$(find target/aarch64-apple-darwin/release/build -path '*/out/codex_micro_chroma_media_sessions' -print | wc -l | tr -d ' ')" + test "$helper_count" = "1" + helper="$(find target/aarch64-apple-darwin/release/build -path '*/out/codex_micro_chroma_media_sessions' -print)" + /usr/bin/lipo "$helper" -verify_arch arm64 + /usr/bin/codesign --verify --verbose "$helper" + /usr/bin/codesign -dv "$helper" - name: Build x86_64 release binary - run: cargo +"$RUST_TOOLCHAIN" build --release --locked --target x86_64-apple-darwin + run: | + set -euo pipefail + cargo +"$RUST_TOOLCHAIN" build --release --locked --target x86_64-apple-darwin + helper_count="$(find target/x86_64-apple-darwin/release/build -path '*/out/codex_micro_chroma_media_sessions' -print | wc -l | tr -d ' ')" + test "$helper_count" = "1" + helper="$(find target/x86_64-apple-darwin/release/build -path '*/out/codex_micro_chroma_media_sessions' -print)" + /usr/bin/lipo "$helper" -verify_arch x86_64 + /usr/bin/codesign --verify --verbose "$helper" + /usr/bin/codesign -dv "$helper" diff --git a/src/media.rs b/src/media.rs index 0f7cc80..d34bc46 100644 --- a/src/media.rs +++ b/src/media.rs @@ -426,6 +426,12 @@ mod platform { Ok(()) } + fn terminate_helper_before_ready(child: &mut Child, reader: JoinHandle<()>) { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + } + pub struct MediaRemoteSource { child: Child, reader: Option>, @@ -540,9 +546,7 @@ mod platform { } }; if let Some(error) = ready_error { - let _ = child.kill(); - let _ = child.wait(); - let _ = reader.join(); + terminate_helper_before_ready(&mut child, reader); bail!("{error}"); } Ok(Self { @@ -908,6 +912,19 @@ mod tests { ); } + #[test] + fn missing_date_candidate_still_participates_in_stop_fallback() { + let candidates = [ + candidate("spotify", false, Some(300.0), true), + candidate("music", true, None, false), + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("music") + ); + } + #[test] fn no_active_session_turns_the_selection_off() { let candidates = [ From 5d45bd27ecacf3a22cfd0c82c10df5cc9eff2692 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 12:31:48 +0900 Subject: [PATCH 08/20] fix: isolate media session playback errors --- README.md | 2 +- src/media.rs | 27 +++++++++++++++++++++++++++ src/media_sessions.m | 7 ++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ddab66b..1f1b61c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ macOS system output MediaRemote is a private Apple framework. Its behaviour may change after a macOS update, and this architecture is not suitable for App Store distribution. If a player does not publish artwork to Now Playing, Codex Micro Chroma cannot derive a colour from it. -When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. Sessions with unresolved playback state are ineligible, and incomplete refreshes retain the last complete OS snapshot until a retry succeeds. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. +When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection; a globally timed-out refresh remains incomplete and retains the last complete OS snapshot until a retry succeeds. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. ## Requirements diff --git a/src/media.rs b/src/media.rs index d34bc46..7244aed 100644 --- a/src/media.rs +++ b/src/media.rs @@ -939,6 +939,7 @@ mod tests { fn unresolved_scoped_playback_state_does_not_revive_a_stale_playback_rate() { let mut stale = candidate("music", true, Some(200.0), true); stale.playing_resolved = false; + stale.playback_rate = Some(1.0); let candidates = [candidate("spotify", true, Some(100.0), false), stale]; assert_eq!( @@ -947,6 +948,32 @@ mod tests { ); } + #[test] + fn unresolved_scoped_playback_state_does_not_prevent_stop_fallback() { + let mut unresolved_newer = candidate("music", true, Some(300.0), true); + unresolved_newer.playing_resolved = false; + unresolved_newer.playback_rate = Some(1.0); + let candidates = [ + candidate("spotify", true, Some(100.0), false), + unresolved_newer, + ]; + + assert_eq!( + select_playback_candidate(&candidates).map(|value| value.stable_id.as_str()), + Some("spotify") + ); + } + + #[test] + fn unresolved_scoped_playback_state_can_turn_selection_off() { + let mut unresolved = candidate("music", true, Some(300.0), true); + unresolved.playing_resolved = false; + unresolved.playback_rate = Some(1.0); + let candidates = [candidate("spotify", false, Some(100.0), false), unresolved]; + + assert!(select_playback_candidate(&candidates).is_none()); + } + #[test] fn stable_identifier_makes_a_missing_date_tie_deterministic() { let candidates = [ diff --git a/src/media_sessions.m b/src/media_sessions.m index c66f953..20950b4 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -158,14 +158,13 @@ static void refreshSessions(void) { Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); id electedPath = objectProperty(requestClass, @"localNowPlayingPlayerPath"); __block BOOL completed = NO; - __block BOOL refreshValid = YES; void (^complete)(BOOL) = ^(BOOL timedOut) { if (completed) { return; } completed = YES; - if (!timedOut && refreshValid) { + if (!timedOut) { BOOL candidatesComplete = NO; NSArray *publicCandidates = publicCandidatesIfComplete(candidates, &candidatesComplete); @@ -311,9 +310,7 @@ static void refreshSessions(void) { dispatch_group_leave(group); return; } - if (error) { - refreshValid = NO; - } else { + if (!error) { entry[@"playing"] = @(playing); entry[@"playingResolved"] = @YES; } From 4f0e11828835a911f5d1e837cc1a6b5693a44f46 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 12:41:24 +0900 Subject: [PATCH 09/20] fix: degrade missing media metadata --- README.md | 2 +- src/media.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++ src/media_sessions.m | 24 ++-------------- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 1f1b61c..4199357 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ macOS system output MediaRemote is a private Apple framework. Its behaviour may change after a macOS update, and this architecture is not suitable for App Store distribution. If a player does not publish artwork to Now Playing, Codex Micro Chroma cannot derive a colour from it. -When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection; a globally timed-out refresh remains incomplete and retains the last complete OS snapshot until a retry succeeds. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. +When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection. A completed metadata failure still publishes the authoritative playing session identity without stale track fields or artwork, so previous lighting is cleared while macOS continues reporting that session as active. A globally timed-out refresh remains incomplete and retains the last complete OS snapshot until a retry succeeds. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. ## Requirements diff --git a/src/media.rs b/src/media.rs index 7244aed..9bc8ec4 100644 --- a/src/media.rs +++ b/src/media.rs @@ -642,6 +642,25 @@ mod platform { } } + fn identity_only_candidate(stable_id: &str, bundle_id: &str) -> PlaybackCandidate { + PlaybackCandidate { + stable_id: stable_id.into(), + bundle_id: Some(bundle_id.into()), + playing: true, + playing_resolved: true, + last_playing_date: Some(200.0), + elected: true, + title: None, + artist: None, + album: None, + elapsed_time: None, + duration: None, + playback_rate: None, + info_update_date: None, + artwork_data: None, + } + } + #[test] fn stopped_state_preserves_identity_without_artwork_for_resume() { let playing = @@ -768,6 +787,38 @@ mod platform { assert!(stopped.snapshot.artwork.is_none()); } + #[test] + fn identity_only_playing_candidate_clears_stale_metadata_and_artwork() { + let playing = ReceivedNowPlaying::from_candidate( + Some(&candidate_with_artwork( + "music", + encoded_artwork(10, 20, 30), + )), + None, + ); + + let identity_only = ReceivedNowPlaying::from_candidate( + Some(&identity_only_candidate("music", "com.apple.Music")), + Some(&playing), + ); + + assert_eq!(identity_only.snapshot.is_playing, Some(true)); + assert_eq!( + identity_only.snapshot.bundle_id.as_deref(), + Some("com.apple.Music") + ); + assert_eq!(identity_only.snapshot.title, None); + assert_eq!(identity_only.snapshot.artist, None); + assert_eq!(identity_only.snapshot.album, None); + assert!(identity_only.artwork.is_none()); + assert!(identity_only.snapshot.artwork.is_none()); + assert_eq!(identity_only.snapshot.artwork_signature, None); + assert_ne!( + identity_only.snapshot.track_key(), + playing.snapshot.track_key() + ); + } + #[test] fn helper_control_line_accepts_ready_message() { assert_eq!( @@ -987,6 +1038,20 @@ mod tests { ); } + #[test] + fn metadata_less_playing_candidate_remains_selectable() { + let mut metadata_less = candidate("music", true, Some(200.0), false); + metadata_less.bundle_id = Some("com.apple.Music".into()); + let candidates = [candidate("spotify", true, Some(100.0), true), metadata_less]; + + let selected = select_playback_candidate(&candidates).expect("active candidate selected"); + + assert_eq!(selected.stable_id, "music"); + assert_eq!(selected.bundle_id.as_deref(), Some("com.apple.Music")); + assert_eq!(selected.title, None); + assert_eq!(selected.artwork_data, None); + } + #[test] fn os_session_payload_uses_camel_case_fields() { let candidate: PlaybackCandidate = serde_json::from_str( diff --git a/src/media_sessions.m b/src/media_sessions.m index 20950b4..d7c39e0 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -91,34 +91,23 @@ static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, } } -static NSArray *publicCandidatesIfComplete(NSArray *candidates, BOOL *complete) { +static NSArray *publicCandidates(NSArray *candidates) { NSMutableArray *publicCandidates = [NSMutableArray arrayWithCapacity:candidates.count]; - BOOL valid = YES; for (NSDictionary *candidate in candidates) { if (![candidate isKindOfClass:[NSDictionary class]]) { - valid = NO; continue; } - BOOL playing = [candidate[@"playing"] boolValue]; - if (playing) { - if (![candidate[@"metadataResolved"] boolValue]) { - valid = NO; - } - } - NSMutableDictionary *publicCandidate = [candidate mutableCopy]; if ([candidate[@"lastPlayingDateError"] boolValue]) { [publicCandidate removeObjectForKey:@"lastPlayingDate"]; } - [publicCandidate removeObjectForKey:@"metadataResolved"]; [publicCandidate removeObjectForKey:@"lastPlayingDateError"]; [publicCandidates addObject:publicCandidate]; } - *complete = valid; return publicCandidates; } @@ -165,13 +154,8 @@ static void refreshSessions(void) { } completed = YES; if (!timedOut) { - BOOL candidatesComplete = NO; - NSArray *publicCandidates = - publicCandidatesIfComplete(candidates, &candidatesComplete); - if (candidatesComplete) { - printCandidates(publicCandidates); - pruneArtworkCache(activeStableIDs); - } + printCandidates(publicCandidates(candidates)); + pruneArtworkCache(activeStableIDs); } refreshInFlight = NO; scheduleRefresh(); @@ -237,7 +221,6 @@ static void refreshSessions(void) { @"bundleId" : bundleID, @"playing" : @NO, @"playingResolved" : @NO, - @"metadataResolved" : @NO, @"lastPlayingDateError" : @NO, @"elected" : @([playerPath isEqual:electedPath]), } mutableCopy]; @@ -260,7 +243,6 @@ static void refreshSessions(void) { return; } if ([information isKindOfClass:[NSDictionary class]]) { - entry[@"metadataResolved"] = @YES; copyString(entry, @"title", information, @"kMRMediaRemoteNowPlayingInfoTitle"); copyString(entry, @"artist", information, From 400062080d2dfb36d28ed0f2662decf10f5d40a9 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 12:51:39 +0900 Subject: [PATCH 10/20] fix: restart media helper after callback timeout --- README.md | 2 +- src/media.rs | 136 +++++++++++++++++++++++++++++++++++++------ src/media_sessions.m | 26 ++++++++- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 4199357..8716946 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ macOS system output MediaRemote is a private Apple framework. Its behaviour may change after a macOS update, and this architecture is not suitable for App Store distribution. If a player does not publish artwork to Now Playing, Codex Micro Chroma cannot derive a colour from it. -When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection. A completed metadata failure still publishes the authoritative playing session identity without stale track fields or artwork, so previous lighting is cleared while macOS continues reporting that session as active. A globally timed-out refresh remains incomplete and retains the last complete OS snapshot until a retry succeeds. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. +When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection. A completed metadata failure still publishes the authoritative playing session identity without stale track fields or artwork, so previous lighting is cleared while macOS continues reporting that session as active. A globally timed-out refresh is treated as a broken helper generation: the helper emits a reset control message, Rust clears the last Now Playing state, and the helper replaces its own process image before publishing a fresh ready message. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. ## Requirements diff --git a/src/media.rs b/src/media.rs index 9bc8ec4..06028b7 100644 --- a/src/media.rs +++ b/src/media.rs @@ -230,6 +230,7 @@ mod platform { #[derive(Debug, PartialEq, Eq)] enum HelperControlMessage { Ready, + Reset { reason: Option }, Invalid(String), } @@ -243,6 +244,14 @@ mod platform { { HelperControlMessage::Ready } + Ok(serde_json::Value::Object(map)) if map.contains_key("reset") => { + HelperControlMessage::Reset { + reason: map + .get("reset") + .and_then(serde_json::Value::as_str) + .map(str::to_owned), + } + } _ => HelperControlMessage::Invalid(line.to_owned()), } } @@ -426,6 +435,41 @@ mod platform { Ok(()) } + fn process_started_helper_line( + latest: &RwLock>, + reported_parse_error: &mut bool, + line: &str, + ) { + match parse_helper_control_line(line) { + HelperControlMessage::Ready => { + return; + } + HelperControlMessage::Reset { reason } => { + let reason = reason.as_deref().unwrap_or("control reset requested"); + let _ = publish_stopped(latest, reason); + return; + } + HelperControlMessage::Invalid(_) => {} + } + let payload = match serde_json::from_str::(line) { + Ok(payload) => payload, + Err(error) => { + if !*reported_parse_error { + *reported_parse_error = true; + eprintln!("MediaRemote session helper sent an unparsable payload: {error}"); + } + return; + } + }; + let selected = select_playback_candidate(&payload.candidates); + if let Ok(mut latest) = latest.write() { + *latest = Some(ReceivedNowPlaying::from_candidate( + selected, + latest.as_ref(), + )); + } + } + fn terminate_helper_before_ready(child: &mut Child, reader: JoinHandle<()>) { let _ = child.kill(); let _ = child.wait(); @@ -477,6 +521,12 @@ mod platform { HelperControlMessage::Ready => { let _ = ready_tx.send(Ok(())); } + HelperControlMessage::Reset { .. } => { + let _ = ready_tx.send(Err(format!( + "MediaRemote session helper reset before ready: {line}" + ))); + return; + } HelperControlMessage::Invalid(line) => { let _ = ready_tx.send(Err(format!( "malformed pre-ready output from MediaRemote session helper: {line}" @@ -501,25 +551,11 @@ mod platform { loop { match lines.next() { Some(Ok(line)) => { - let payload = match serde_json::from_str::(&line) { - Ok(payload) => payload, - Err(error) => { - if !reported_parse_error { - reported_parse_error = true; - eprintln!( - "MediaRemote session helper sent an unparsable payload: {error}" - ); - } - continue; - } - }; - let selected = select_playback_candidate(&payload.candidates); - if let Ok(mut latest) = reader_latest.write() { - *latest = Some(ReceivedNowPlaying::from_candidate( - selected, - latest.as_ref(), - )); - } + process_started_helper_line( + &reader_latest, + &mut reported_parse_error, + &line, + ); } Some(Err(error)) => { let _ = publish_stopped( @@ -827,6 +863,16 @@ mod platform { ); } + #[test] + fn helper_control_line_accepts_reset_message() { + assert_eq!( + parse_helper_control_line(r#"{"reset":"timeout"}"#), + HelperControlMessage::Reset { + reason: Some("timeout".to_owned()) + } + ); + } + #[test] fn helper_control_line_rejects_session_payload_before_ready() { assert_eq!( @@ -842,6 +888,58 @@ mod platform { HelperControlMessage::Invalid("not json".to_owned()) ); } + + #[test] + fn reset_control_line_after_ready_clears_cached_playing_state() { + let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + ))); + let mut reported_parse_error = false; + + process_started_helper_line( + &latest, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + + assert!(!reported_parse_error); + let latest = latest.read().expect("state lock is readable"); + let snapshot = &latest + .as_ref() + .expect("stopped state is published") + .snapshot; + assert_eq!(snapshot.is_playing, Some(false)); + assert_eq!(snapshot.title.as_deref(), Some("Song")); + assert!(snapshot.artwork.is_none()); + } + + #[test] + fn ready_control_line_after_reset_is_not_reported_as_parse_error() { + let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + ))); + let mut reported_parse_error = false; + + process_started_helper_line( + &latest, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + process_started_helper_line(&latest, &mut reported_parse_error, r#"{"ready":true}"#); + + assert!(!reported_parse_error); + let latest = latest.read().expect("state lock is readable"); + assert_eq!( + latest + .as_ref() + .expect("state remains published") + .snapshot + .is_playing, + Some(false) + ); + } } } diff --git a/src/media_sessions.m b/src/media_sessions.m index d7c39e0..a00cb93 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -1,8 +1,11 @@ #import #import #import +#import #import #import +#import +#import typedef void (*MRGetNowPlayingClients)(dispatch_queue_t, void (^)(id)); typedef void (*MRGetPlayerForClient)(id, id, dispatch_queue_t, void (^)(id)); @@ -24,6 +27,7 @@ - (instancetype)initWithPlayerPath:(id)playerPath; static BOOL refreshInFlight = NO; static NSData *previousPayloadData = nil; static NSMutableDictionary *artworkCache = nil; +static char **helperArgv = NULL; static id objectProperty(id object, NSString *selectorName) { SEL selector = NSSelectorFromString(selectorName); @@ -134,6 +138,20 @@ static void printCandidates(NSArray *candidates) { static void scheduleRefresh(void); +static void restartAfterTimeout(void) { + printf("{\"reset\":\"timeout\"}\n"); + fflush(stdout); + if (!helperArgv || !helperArgv[0]) { + fprintf(stderr, "could not restart MediaRemote session helper after timeout: missing executable path\n"); + _exit(70); + } + execv(helperArgv[0], helperArgv); + fprintf(stderr, + "could not restart MediaRemote session helper after timeout via execv(%s): %s\n", + helperArgv[0], strerror(errno)); + _exit(70); +} + static void refreshSessions(void) { if (refreshInFlight) { return; @@ -153,7 +171,9 @@ static void refreshSessions(void) { return; } completed = YES; - if (!timedOut) { + if (timedOut) { + restartAfterTimeout(); + } else { printCandidates(publicCandidates(candidates)); pruneArtworkCache(activeStableIDs); } @@ -350,7 +370,9 @@ static void printReady(void) { fflush(stdout); } -int main(void) { +int main(int argc, char *argv[]) { + (void)argc; + helperArgv = argv; @autoreleasepool { NSBundle *framework = [NSBundle bundleWithPath: @"/System/Library/PrivateFrameworks/MediaRemote.framework"]; From 18b6a06f9995730fbec6097fc98f847ff926e40b Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 13:08:29 +0900 Subject: [PATCH 11/20] fix: bound media session payloads --- README.md | 2 + src/media.rs | 213 ++++++++++++++++++++++++++++++++++++------- src/media_sessions.m | 62 +++++++++++-- 3 files changed, 237 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 8716946..2962219 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ MediaRemote is a private Apple framework. Its behaviour may change after a macOS When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection. A completed metadata failure still publishes the authoritative playing session identity without stale track fields or artwork, so previous lighting is cleared while macOS continues reporting that session as active. A globally timed-out refresh is treated as a broken helper generation: the helper emits a reset control message, Rust clears the last Now Playing state, and the helper replaces its own process image before publishing a fresh ready message. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. +MediaRemote payloads are treated as untrusted local input. The helper bounds the number of processed player clients, copied text fields, raw artwork, and aggregate serialized artwork. Rust bounds each helper JSON line, rejects oversized encoded or decoded artwork before image decoding, and decodes images with explicit dimension and allocation limits. Oversized or invalid artwork is omitted gracefully while the authoritative playback candidate is still processed. + ## Requirements - macOS 14.2 or later for reactive mode; `--mode static` does not require a Process Tap diff --git a/src/media.rs b/src/media.rs index 06028b7..95bc6c0 100644 --- a/src/media.rs +++ b/src/media.rs @@ -198,7 +198,7 @@ mod platform { use std::{ cell::RefCell, fs, - io::{BufRead, BufReader}, + io::{BufRead, BufReader, Cursor}, os::unix::fs::PermissionsExt, process::{Child, Command, Stdio}, sync::{mpsc, Arc, RwLock}, @@ -208,6 +208,7 @@ mod platform { use anyhow::{bail, Context, Result}; use base64::{engine::general_purpose, Engine as _}; + use image::{ImageReader, Limits}; use serde::Deserialize; use tempfile::TempDir; @@ -221,6 +222,12 @@ mod platform { "/codex_micro_chroma_media_sessions" )); const HELPER_READY_TIMEOUT: Duration = Duration::from_secs(2); + const MAX_HELPER_JSON_LINE_BYTES: usize = 24 * 1024 * 1024; + const MAX_ENCODED_ARTWORK_BYTES: usize = 12 * 1024 * 1024; + const MAX_RAW_ARTWORK_BYTES: usize = 8 * 1024 * 1024; + const MAX_IMAGE_WIDTH: u32 = 4096; + const MAX_IMAGE_HEIGHT: u32 = 4096; + const MAX_IMAGE_DECODER_ALLOC_BYTES: u64 = 96 * 1024 * 1024; #[derive(Deserialize)] struct SessionPayload { @@ -348,6 +355,9 @@ mod platform { previous: Option<&Self>, ) -> Option { let encoded_artwork = candidate.artwork_data.as_ref()?; + if encoded_artwork.len() > MAX_ENCODED_ARTWORK_BYTES { + return None; + } if let Some(artwork) = previous .and_then(|previous| previous.artwork.as_ref()) .filter(|artwork| artwork.key.matches_candidate(candidate, encoded_artwork)) @@ -355,7 +365,18 @@ mod platform { return Some(artwork.clone()); } let bytes = general_purpose::STANDARD.decode(encoded_artwork).ok()?; - let image = image::load_from_memory(&bytes).ok()?; + if bytes.len() > MAX_RAW_ARTWORK_BYTES { + return None; + } + let mut reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + let mut limits = Limits::default(); + limits.max_image_width = Some(MAX_IMAGE_WIDTH); + limits.max_image_height = Some(MAX_IMAGE_HEIGHT); + limits.max_alloc = Some(MAX_IMAGE_DECODER_ALLOC_BYTES); + reader.limits(limits); + let image = reader.decode().ok()?; let signature = artwork_signature(&image); Some(CachedArtwork { key: Arc::new(ArtworkCacheKey { @@ -470,6 +491,72 @@ mod platform { } } + fn read_bounded_line( + reader: &mut R, + max_bytes: usize, + ) -> std::io::Result> { + let mut bytes = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + break; + } + let newline_index = available.iter().position(|byte| *byte == b'\n'); + let segment_len = newline_index.map_or(available.len(), |index| index + 1); + let remaining = max_bytes.saturating_sub(bytes.len()); + if segment_len > remaining { + reader.consume(remaining); + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("helper JSON line exceeded {max_bytes} bytes"), + )); + } + bytes.extend_from_slice(&available[..segment_len]); + reader.consume(segment_len); + if newline_index.is_some() { + break; + } + } + if bytes.ends_with(b"\n") { + bytes.pop(); + if bytes.ends_with(b"\r") { + bytes.pop(); + } + } + String::from_utf8(bytes).map(Some).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("helper JSON line was not UTF-8: {error}"), + ) + }) + } + + fn process_started_helper_stream( + latest: &RwLock>, + reader: &mut R, + publish_stopped_on_eof: bool, + ) -> std::io::Result<()> { + let mut reported_parse_error = false; + loop { + match read_bounded_line(reader, MAX_HELPER_JSON_LINE_BYTES) { + Ok(Some(line)) => { + process_started_helper_line(latest, &mut reported_parse_error, &line); + } + Ok(None) if publish_stopped_on_eof => { + return publish_stopped(latest, "stdout reached EOF"); + } + Ok(None) => return Ok(()), + Err(error) => { + let _ = publish_stopped(latest, &format!("stdout read error: {error}")); + return Err(error); + } + } + } + } + fn terminate_helper_before_ready(child: &mut Child, reader: JoinHandle<()>) { let _ = child.kill(); let _ = child.wait(); @@ -515,9 +602,9 @@ mod platform { let reader_latest = Arc::clone(&latest); let (ready_tx, ready_rx) = mpsc::channel(); let reader = thread::spawn(move || { - let mut lines = BufReader::new(stdout).lines(); - match lines.next() { - Some(Ok(line)) => match parse_helper_control_line(&line) { + let mut reader = BufReader::new(stdout); + match read_bounded_line(&mut reader, MAX_HELPER_JSON_LINE_BYTES) { + Ok(Some(line)) => match parse_helper_control_line(&line) { HelperControlMessage::Ready => { let _ = ready_tx.send(Ok(())); } @@ -534,42 +621,20 @@ mod platform { return; } }, - Some(Err(error)) => { - let _ = ready_tx.send(Err(format!( - "could not read MediaRemote session helper ready message: {error}" - ))); - return; - } - None => { + Ok(None) => { let _ = ready_tx.send(Err( "MediaRemote session helper exited before ready".to_owned(), )); return; } - } - let mut reported_parse_error = false; - loop { - match lines.next() { - Some(Ok(line)) => { - process_started_helper_line( - &reader_latest, - &mut reported_parse_error, - &line, - ); - } - Some(Err(error)) => { - let _ = publish_stopped( - &reader_latest, - &format!("stdout read error: {error}"), - ); - break; - } - None => { - let _ = publish_stopped(&reader_latest, "stdout reached EOF"); - break; - } + Err(error) => { + let _ = ready_tx.send(Err(format!( + "could not read MediaRemote session helper ready message: {error}" + ))); + return; } } + let _ = process_started_helper_stream(&reader_latest, &mut reader, true); }); let ready_error = match ready_rx.recv_timeout(HELPER_READY_TIMEOUT) { Ok(Ok(())) => None, @@ -940,6 +1005,86 @@ mod platform { Some(false) ); } + + #[test] + fn post_ready_stream_accepts_reset_ready_and_payload_without_eof() { + let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Old Song", 30.0, 0.0)), + None, + ))); + let payload = r#"{"candidates":[{"stableId":"music","bundleId":"com.apple.Music","playing":true,"playingResolved":true,"lastPlayingDate":500.0,"elected":true,"title":"New Song"}]}"#; + let mut stream = Cursor::new(format!( + "{{\"reset\":\"timeout\"}}\n{{\"ready\":true}}\n{payload}\n" + )); + + process_started_helper_stream(&latest, &mut stream, false) + .expect("stream lines are processed"); + + let latest = latest.read().expect("state lock is readable"); + let snapshot = &latest.as_ref().expect("payload is published").snapshot; + assert_eq!(snapshot.is_playing, Some(true)); + assert_eq!(snapshot.title.as_deref(), Some("New Song")); + assert_eq!(snapshot.bundle_id.as_deref(), Some("com.apple.Music")); + } + + #[test] + fn bounded_line_reader_rejects_oversized_helper_line_without_growing_past_cap() { + let mut stream = Cursor::new(vec![b'a'; 17]); + + let error = read_bounded_line(&mut stream, 16).expect_err("line exceeds cap"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(error + .to_string() + .contains("helper JSON line exceeded 16 bytes")); + } + + #[test] + fn oversized_encoded_artwork_is_omitted_without_dropping_candidate() { + let candidate = + candidate_with_artwork("music", "A".repeat(MAX_ENCODED_ARTWORK_BYTES + 1)); + + let received = ReceivedNowPlaying::from_candidate(Some(&candidate), None); + + assert_eq!(received.snapshot.is_playing, Some(true)); + assert!(received.artwork.is_none()); + assert_eq!(received.snapshot.artwork_signature, None); + } + + #[test] + fn oversized_raw_artwork_is_omitted_without_dropping_candidate() { + let candidate = candidate_with_artwork( + "music", + general_purpose::STANDARD.encode(vec![0_u8; MAX_RAW_ARTWORK_BYTES + 1]), + ); + + let received = ReceivedNowPlaying::from_candidate(Some(&candidate), None); + + assert_eq!(received.snapshot.is_playing, Some(true)); + assert!(received.artwork.is_none()); + assert_eq!(received.snapshot.artwork_signature, None); + } + + #[test] + fn oversized_image_dimensions_are_omitted_without_dropping_candidate() { + let image = image::DynamicImage::ImageRgba8(RgbaImage::from_pixel( + MAX_IMAGE_WIDTH + 1, + 1, + Rgba([10, 20, 30, 255]), + )); + let mut bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .expect("test image encodes"); + let candidate = + candidate_with_artwork("music", general_purpose::STANDARD.encode(bytes)); + + let received = ReceivedNowPlaying::from_candidate(Some(&candidate), None); + + assert_eq!(received.snapshot.is_playing, Some(true)); + assert!(received.artwork.is_none()); + assert_eq!(received.snapshot.artwork_signature, None); + } } } diff --git a/src/media_sessions.m b/src/media_sessions.m index a00cb93..7004591 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -29,6 +29,13 @@ - (instancetype)initWithPlayerPath:(id)playerPath; static NSMutableDictionary *artworkCache = nil; static char **helperArgv = NULL; +// MediaRemote is local but untrusted input. These caps cover ordinary album art +// while bounding callback fan-out, copied payload text, artwork, and JSON size. +static const NSUInteger MAX_PROCESSED_CLIENTS = 24; +static const NSUInteger MAX_TEXT_FIELD_BYTES = 8 * 1024; +static const NSUInteger MAX_RAW_ARTWORK_BYTES = 8 * 1024 * 1024; +static const NSUInteger MAX_SERIALIZED_ARTWORK_BYTES = 16 * 1024 * 1024; + static id objectProperty(id object, NSString *selectorName) { SEL selector = NSSelectorFromString(selectorName); if (!object || ![object respondsToSelector:selector]) { @@ -52,10 +59,32 @@ static long integerProperty(id object, NSString *selectorName, BOOL *present) { return (long)((int (*)(id, SEL))objc_msgSend)(object, selector); } +static NSString *boundedString(NSString *value) { + if (!value) { + return nil; + } + NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; + if (data.length <= MAX_TEXT_FIELD_BYTES) { + return value; + } + NSData *truncated = [data subdataWithRange:NSMakeRange(0, MAX_TEXT_FIELD_BYTES)]; + NSString *result = [[NSString alloc] initWithData:truncated + encoding:NSUTF8StringEncoding]; + while (!result && truncated.length > 0) { + truncated = [truncated subdataWithRange:NSMakeRange(0, truncated.length - 1)]; + result = [[NSString alloc] initWithData:truncated + encoding:NSUTF8StringEncoding]; + } + return result ?: @""; +} + static NSString *cachedArtworkData(NSString *stableID, NSData *artwork) { if (!stableID || !artwork) { return nil; } + if (artwork.length > MAX_RAW_ARTWORK_BYTES) { + return nil; + } NSDictionary *cached = artworkCache[stableID]; NSData *cachedData = cached[@"data"]; NSString *cachedEncoded = cached[@"encoded"]; @@ -82,7 +111,7 @@ static void copyString(NSMutableDictionary *destination, NSString *outputKey, NSDictionary *source, NSString *sourceKey) { id value = source[sourceKey]; if ([value isKindOfClass:[NSString class]]) { - destination[outputKey] = value; + destination[outputKey] = boundedString((NSString *)value); } } @@ -98,6 +127,7 @@ static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, static NSArray *publicCandidates(NSArray *candidates) { NSMutableArray *publicCandidates = [NSMutableArray arrayWithCapacity:candidates.count]; + NSUInteger serializedArtworkBytes = 0; for (NSDictionary *candidate in candidates) { if (![candidate isKindOfClass:[NSDictionary class]]) { @@ -105,6 +135,20 @@ static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, } NSMutableDictionary *publicCandidate = [candidate mutableCopy]; + BOOL playing = [candidate[@"playing"] boolValue]; + BOOL playingResolved = [candidate[@"playingResolved"] boolValue]; + NSString *artworkData = publicCandidate[@"artworkData"]; + if ([artworkData isKindOfClass:[NSString class]]) { + NSUInteger artworkBytes = + [artworkData lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + if (!playing || !playingResolved || + artworkBytes > MAX_SERIALIZED_ARTWORK_BYTES || + serializedArtworkBytes + artworkBytes > MAX_SERIALIZED_ARTWORK_BYTES) { + [publicCandidate removeObjectForKey:@"artworkData"]; + } else { + serializedArtworkBytes += artworkBytes; + } + } if ([candidate[@"lastPlayingDateError"] boolValue]) { [publicCandidate removeObjectForKey:@"lastPlayingDate"]; } @@ -196,7 +240,12 @@ static void refreshSessions(void) { : @[]; dispatch_group_t group = dispatch_group_create(); + NSUInteger processedClients = 0; for (id client in clients) { + if (processedClients >= MAX_PROCESSED_CLIENTS) { + break; + } + processedClients++; dispatch_group_enter(group); getPlayerForClient(client, nil, queue, ^(id player) { if (completed) { @@ -217,13 +266,14 @@ static void refreshSessions(void) { return; } - NSString *bundleID = + NSString *bundleID = boundedString( stringProperty(client, @"parentApplicationBundleIdentifier") ?: stringProperty(client, @"bundleIdentifier") - ?: @"unknown"; - NSString *playerID = stringProperty(player, @"identifier") - ?: stringProperty(player, @"displayName") - ?: @"default"; + ?: @"unknown"); + NSString *playerID = + boundedString(stringProperty(player, @"identifier") + ?: stringProperty(player, @"displayName") + ?: @"default"); BOOL hasProcessIdentifier = NO; long processIdentifier = integerProperty( client, @"processIdentifier", &hasProcessIdentifier); From 528793028797ec01874f89bcdcb050cbcade14ec Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 13:38:34 +0900 Subject: [PATCH 12/20] fix: bound media arbitration state --- README.md | 4 +- src/media.rs | 300 +++++++++++++++---- src/media_sessions.m | 698 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 810 insertions(+), 192 deletions(-) diff --git a/README.md b/README.md index 2962219..eaaed80 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ macOS system output MediaRemote is a private Apple framework. Its behaviour may change after a macOS update, and this architecture is not suitable for App Store distribution. If a player does not publish artwork to Now Playing, Codex Micro Chroma cannot derive a colour from it. -When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection. A completed metadata failure still publishes the authoritative playing session identity without stale track fields or artwork, so previous lighting is cleared while macOS continues reporting that session as active. A globally timed-out refresh is treated as a broken helper generation: the helper emits a reset control message, Rust clears the last Now Playing state, and the helper replaces its own process image before publishing a fresh ready message. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. +When several applications publish Now Playing sessions simultaneously, Codex Micro Chroma enumerates the current OS sessions on every refresh and considers only sessions that macOS reports as playing with a resolved playback state. A per-session scoped-playing error leaves only that session unresolved and ineligible for selection. A completed metadata failure still publishes the authoritative playing session identity without stale track fields or artwork, so previous lighting is cleared while macOS continues reporting that session as active. A globally timed-out refresh is treated as a broken helper generation: the helper emits a reset control message, Rust clears the last Now Playing state, and the helper replaces its own process image before publishing a fresh ready message. The session with the newest macOS `lastPlayingDate` wins; the OS-elected Now Playing session breaks equal or unavailable-date ties, followed by a stable identifier for deterministic output. No separate playback-order history is persisted by this application. If the selected session stops, its lighting is cleared and the next still-playing session is selected from that same OS snapshot. If none remains, lighting stays off. The helper gathers identity, scoped playing state, election, and `lastPlayingDate` for the complete MediaRemote session list in fixed eight-client batches while retaining only the top eight ranked playing records. Clients without scoped playing support use a no-artwork metadata fallback in that same bounded batch pipeline to resolve `playbackRate`. A second phase fetches metadata and artwork only for the final top eight, and only those selected candidates are published. If more than eight sessions are simultaneously playing, lower-ranked sessions wait for a later snapshot after higher-ranked sessions stop or move down. -MediaRemote payloads are treated as untrusted local input. The helper bounds the number of processed player clients, copied text fields, raw artwork, and aggregate serialized artwork. Rust bounds each helper JSON line, rejects oversized encoded or decoded artwork before image decoding, and decodes images with explicit dimension and allocation limits. Oversized or invalid artwork is omitted gracefully while the authoritative playback candidate is still processed. +MediaRemote payloads are treated as untrusted local input. The helper bounds copied text fields before UTF-8 allocation, per-item raw artwork, aggregate retained artwork cache bytes, and aggregate serialized artwork. Rust bounds each helper JSON line, rejects oversized encoded or decoded artwork before image decoding, and decodes images with explicit dimension and allocation limits. Oversized or invalid artwork is omitted gracefully while the authoritative playback candidate is still processed. ## Requirements diff --git a/src/media.rs b/src/media.rs index 95bc6c0..1fb5a29 100644 --- a/src/media.rs +++ b/src/media.rs @@ -263,6 +263,7 @@ mod platform { } } + #[derive(Clone)] struct ReceivedNowPlaying { snapshot: TrackSnapshot, artwork: Option, @@ -270,6 +271,12 @@ mod platform { received_at: Instant, } + #[derive(Default)] + struct NowPlayingState { + latest: Option, + pending_reset: Option, + } + struct CachedArtwork { key: Arc, image: Arc, @@ -444,20 +451,30 @@ mod platform { && left.album == right.album } + enum StoppedDelivery { + Authoritative, + OneShotReset, + } + fn publish_stopped( - latest: &RwLock>, + state: &RwLock, reason: &str, + delivery: StoppedDelivery, ) -> std::io::Result<()> { - let mut latest = latest + let mut state = state .write() .map_err(|_| std::io::Error::other("MediaRemote state lock is poisoned"))?; - *latest = Some(ReceivedNowPlaying::from_candidate(None, latest.as_ref())); + let stopped = ReceivedNowPlaying::from_candidate(None, state.latest.as_ref()); + state.latest = Some(stopped.clone()); + if matches!(delivery, StoppedDelivery::OneShotReset) { + state.pending_reset = Some(stopped); + } eprintln!("MediaRemote session helper stopped: {reason}; clearing Now Playing state"); Ok(()) } fn process_started_helper_line( - latest: &RwLock>, + state: &RwLock, reported_parse_error: &mut bool, line: &str, ) { @@ -467,7 +484,7 @@ mod platform { } HelperControlMessage::Reset { reason } => { let reason = reason.as_deref().unwrap_or("control reset requested"); - let _ = publish_stopped(latest, reason); + let _ = publish_stopped(state, reason, StoppedDelivery::OneShotReset); return; } HelperControlMessage::Invalid(_) => {} @@ -483,10 +500,10 @@ mod platform { } }; let selected = select_playback_candidate(&payload.candidates); - if let Ok(mut latest) = latest.write() { - *latest = Some(ReceivedNowPlaying::from_candidate( + if let Ok(mut state) = state.write() { + state.latest = Some(ReceivedNowPlaying::from_candidate( selected, - latest.as_ref(), + state.latest.as_ref(), )); } } @@ -535,7 +552,7 @@ mod platform { } fn process_started_helper_stream( - latest: &RwLock>, + state: &RwLock, reader: &mut R, publish_stopped_on_eof: bool, ) -> std::io::Result<()> { @@ -543,20 +560,62 @@ mod platform { loop { match read_bounded_line(reader, MAX_HELPER_JSON_LINE_BYTES) { Ok(Some(line)) => { - process_started_helper_line(latest, &mut reported_parse_error, &line); + process_started_helper_line(state, &mut reported_parse_error, &line); } Ok(None) if publish_stopped_on_eof => { - return publish_stopped(latest, "stdout reached EOF"); + return publish_stopped( + state, + "stdout reached EOF", + StoppedDelivery::Authoritative, + ); } Ok(None) => return Ok(()), Err(error) => { - let _ = publish_stopped(latest, &format!("stdout read error: {error}")); + let _ = publish_stopped( + state, + &format!("stdout read error: {error}"), + StoppedDelivery::Authoritative, + ); return Err(error); } } } } + fn snapshot_from_state( + state: &RwLock, + artwork_delivery: &RefCell, + ) -> Option { + let (received, from_reset) = { + let mut state = state.write().ok()?; + if let Some(received) = state.pending_reset.take() { + (received, true) + } else { + (state.latest.as_ref()?.clone(), false) + } + }; + if from_reset { + artwork_delivery.borrow_mut().invalidate(); + } + let key = received.snapshot.track_key(); + let should_copy_artwork = artwork_delivery + .borrow_mut() + .should_deliver(key.as_deref(), !from_reset && received.artwork.is_some()); + let mut snapshot = TrackSnapshot { + artwork: if should_copy_artwork { + received + .artwork + .as_ref() + .map(|artwork| (*artwork.image).clone()) + } else { + None + }, + ..received.snapshot.clone_without_artwork() + }; + snapshot.elapsed_time = received.elapsed_time(); + Some(snapshot) + } + fn terminate_helper_before_ready(child: &mut Child, reader: JoinHandle<()>) { let _ = child.kill(); let _ = child.wait(); @@ -567,7 +626,7 @@ mod platform { child: Child, reader: Option>, _temp_dir: TempDir, - latest: Arc>>, + state: Arc>, artwork_delivery: RefCell, } @@ -598,8 +657,8 @@ mod platform { .stdout .take() .context("MediaRemote session helper stdout is unavailable")?; - let latest = Arc::new(RwLock::new(None::)); - let reader_latest = Arc::clone(&latest); + let state = Arc::new(RwLock::new(NowPlayingState::default())); + let reader_state = Arc::clone(&state); let (ready_tx, ready_rx) = mpsc::channel(); let reader = thread::spawn(move || { let mut reader = BufReader::new(stdout); @@ -634,7 +693,7 @@ mod platform { return; } } - let _ = process_started_helper_stream(&reader_latest, &mut reader, true); + let _ = process_started_helper_stream(&reader_state, &mut reader, true); }); let ready_error = match ready_rx.recv_timeout(HELPER_READY_TIMEOUT) { Ok(Ok(())) => None, @@ -654,32 +713,13 @@ mod platform { child, reader: Some(reader), _temp_dir: temp_dir, - latest, + state, artwork_delivery: RefCell::new(ArtworkDelivery::default()), }) } pub fn snapshot(&self) -> Option { - let guard = self.latest.read().ok()?; - let received = guard.as_ref()?; - let key = received.snapshot.track_key(); - let should_copy_artwork = self - .artwork_delivery - .borrow_mut() - .should_deliver(key.as_deref(), received.artwork.is_some()); - let mut snapshot = TrackSnapshot { - artwork: if should_copy_artwork { - received - .artwork - .as_ref() - .map(|artwork| (*artwork.image).clone()) - } else { - None - }, - ..received.snapshot.clone_without_artwork() - }; - snapshot.elapsed_time = received.elapsed_time(); - Some(snapshot) + snapshot_from_state(&self.state, &self.artwork_delivery) } pub fn invalidate_artwork_delivery(&self) { @@ -800,21 +840,27 @@ mod platform { #[test] fn publish_stopped_overwrites_a_cached_playing_snapshot() { - let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( - Some(&candidate("Song", 30.0, 0.0)), - None, - ))); + let state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); - publish_stopped(&latest, "test termination").expect("state update succeeds"); + publish_stopped(&state, "test termination", StoppedDelivery::Authoritative) + .expect("state update succeeds"); - let latest = latest.read().expect("state lock is readable"); - let snapshot = &latest + let state = state.read().expect("state lock is readable"); + let snapshot = &state + .latest .as_ref() .expect("stopped state is published") .snapshot; assert_eq!(snapshot.is_playing, Some(false)); assert_eq!(snapshot.title.as_deref(), Some("Song")); assert!(snapshot.artwork.is_none()); + assert!(state.pending_reset.is_none()); } #[test] @@ -956,48 +1002,57 @@ mod platform { #[test] fn reset_control_line_after_ready_clears_cached_playing_state() { - let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( - Some(&candidate("Song", 30.0, 0.0)), - None, - ))); + let state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); let mut reported_parse_error = false; process_started_helper_line( - &latest, + &state, &mut reported_parse_error, r#"{"reset":"timeout"}"#, ); assert!(!reported_parse_error); - let latest = latest.read().expect("state lock is readable"); - let snapshot = &latest + let state = state.read().expect("state lock is readable"); + let snapshot = &state + .latest .as_ref() .expect("stopped state is published") .snapshot; assert_eq!(snapshot.is_playing, Some(false)); assert_eq!(snapshot.title.as_deref(), Some("Song")); assert!(snapshot.artwork.is_none()); + assert!(state.pending_reset.is_some()); } #[test] fn ready_control_line_after_reset_is_not_reported_as_parse_error() { - let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( - Some(&candidate("Song", 30.0, 0.0)), - None, - ))); + let state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); let mut reported_parse_error = false; process_started_helper_line( - &latest, + &state, &mut reported_parse_error, r#"{"reset":"timeout"}"#, ); - process_started_helper_line(&latest, &mut reported_parse_error, r#"{"ready":true}"#); + process_started_helper_line(&state, &mut reported_parse_error, r#"{"ready":true}"#); assert!(!reported_parse_error); - let latest = latest.read().expect("state lock is readable"); + let state = state.read().expect("state lock is readable"); assert_eq!( - latest + state + .latest .as_ref() .expect("state remains published") .snapshot @@ -1008,25 +1063,138 @@ mod platform { #[test] fn post_ready_stream_accepts_reset_ready_and_payload_without_eof() { - let latest = RwLock::new(Some(ReceivedNowPlaying::from_candidate( - Some(&candidate("Old Song", 30.0, 0.0)), - None, - ))); + let state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Old Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); let payload = r#"{"candidates":[{"stableId":"music","bundleId":"com.apple.Music","playing":true,"playingResolved":true,"lastPlayingDate":500.0,"elected":true,"title":"New Song"}]}"#; let mut stream = Cursor::new(format!( "{{\"reset\":\"timeout\"}}\n{{\"ready\":true}}\n{payload}\n" )); - process_started_helper_stream(&latest, &mut stream, false) + process_started_helper_stream(&state, &mut stream, false) .expect("stream lines are processed"); - let latest = latest.read().expect("state lock is readable"); - let snapshot = &latest.as_ref().expect("payload is published").snapshot; + let state = state.read().expect("state lock is readable"); + let snapshot = &state + .latest + .as_ref() + .expect("payload is published") + .snapshot; assert_eq!(snapshot.is_playing, Some(true)); assert_eq!(snapshot.title.as_deref(), Some("New Song")); assert_eq!(snapshot.bundle_id.as_deref(), Some("com.apple.Music")); } + #[test] + fn reset_delivery_is_sticky_until_snapshot_observes_it() { + let state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Old Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); + let artwork_delivery = RefCell::new(ArtworkDelivery::default()); + let mut reported_parse_error = false; + let replacement = r#"{"candidates":[{"stableId":"music","bundleId":"com.apple.Music","playing":true,"playingResolved":true,"lastPlayingDate":500.0,"elected":true,"title":"New Song"}]}"#; + + process_started_helper_line( + &state, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + process_started_helper_line(&state, &mut reported_parse_error, replacement); + + let stopped = + snapshot_from_state(&state, &artwork_delivery).expect("reset is delivered first"); + let playing = snapshot_from_state(&state, &artwork_delivery) + .expect("replacement is delivered after reset"); + + assert!(!reported_parse_error); + assert_eq!(stopped.is_playing, Some(false)); + assert_eq!(stopped.title.as_deref(), Some("Old Song")); + assert!(stopped.artwork.is_none()); + assert_eq!(playing.is_playing, Some(true)); + assert_eq!(playing.title.as_deref(), Some("New Song")); + } + + #[test] + fn multiple_resets_coalesce_into_one_stopped_snapshot() { + let state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); + let artwork_delivery = RefCell::new(ArtworkDelivery::default()); + let mut reported_parse_error = false; + + process_started_helper_line( + &state, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + process_started_helper_line( + &state, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + + let stopped = + snapshot_from_state(&state, &artwork_delivery).expect("coalesced reset is present"); + let next = snapshot_from_state(&state, &artwork_delivery) + .expect("latest state remains present"); + + assert!(!reported_parse_error); + assert_eq!(stopped.is_playing, Some(false)); + assert!(stopped.artwork.is_none()); + assert_eq!(next.is_playing, Some(false)); + } + + #[test] + fn reset_snapshot_omits_artwork_and_allows_replacement_redelivery() { + let encoded_artwork = encoded_artwork(10, 20, 30); + let playing = ReceivedNowPlaying::from_candidate( + Some(&candidate_with_artwork("music", encoded_artwork.clone())), + None, + ); + let state = RwLock::new(NowPlayingState { + latest: Some(playing), + pending_reset: None, + }); + let artwork_delivery = RefCell::new(ArtworkDelivery::default()); + let mut reported_parse_error = false; + let initial = + snapshot_from_state(&state, &artwork_delivery).expect("initial artwork snapshot"); + let replacement = format!( + r#"{{"candidates":[{{"stableId":"music","bundleId":"com.apple.Music","playing":true,"playingResolved":true,"lastPlayingDate":500.0,"elected":true,"title":"Song","artist":"Artist","album":"Album","artworkData":"{encoded_artwork}"}}]}}"# + ); + + process_started_helper_line( + &state, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + process_started_helper_line(&state, &mut reported_parse_error, &replacement); + + let stopped = + snapshot_from_state(&state, &artwork_delivery).expect("reset is delivered first"); + let redelivered = snapshot_from_state(&state, &artwork_delivery) + .expect("replacement is delivered after reset"); + + assert!(!reported_parse_error); + assert!(initial.artwork.is_some()); + assert_eq!(stopped.is_playing, Some(false)); + assert!(stopped.artwork.is_none()); + assert_eq!(redelivered.is_playing, Some(true)); + assert!(redelivered.artwork.is_some()); + } + #[test] fn bounded_line_reader_rejects_oversized_helper_line_without_growing_past_cap() { let mut stream = Cursor::new(vec![b'a'; 17]); diff --git a/src/media_sessions.m b/src/media_sessions.m index 7004591..02dad23 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -27,14 +27,17 @@ - (instancetype)initWithPlayerPath:(id)playerPath; static BOOL refreshInFlight = NO; static NSData *previousPayloadData = nil; static NSMutableDictionary *artworkCache = nil; +static NSUInteger artworkCacheBytes = 0; static char **helperArgv = NULL; // MediaRemote is local but untrusted input. These caps cover ordinary album art -// while bounding callback fan-out, copied payload text, artwork, and JSON size. -static const NSUInteger MAX_PROCESSED_CLIENTS = 24; +// while bounding copied payload text, retained artwork, and JSON size. static const NSUInteger MAX_TEXT_FIELD_BYTES = 8 * 1024; static const NSUInteger MAX_RAW_ARTWORK_BYTES = 8 * 1024 * 1024; static const NSUInteger MAX_SERIALIZED_ARTWORK_BYTES = 16 * 1024 * 1024; +static const NSUInteger MAX_ARTWORK_CACHE_BYTES = 32 * 1024 * 1024; +static const NSUInteger MAX_ENRICHED_PLAYING_CANDIDATES = 8; +static const NSUInteger MAX_PHASE1_BATCH_CLIENTS = 8; static id objectProperty(id object, NSString *selectorName) { SEL selector = NSSelectorFromString(selectorName); @@ -63,19 +66,112 @@ static long integerProperty(id object, NSString *selectorName, BOOL *present) { if (!value) { return nil; } - NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding]; - if (data.length <= MAX_TEXT_FIELD_BYTES) { + + NSUInteger valueLength = value.length; + if (valueLength == 0) { return value; } - NSData *truncated = [data subdataWithRange:NSMakeRange(0, MAX_TEXT_FIELD_BYTES)]; - NSString *result = [[NSString alloc] initWithData:truncated - encoding:NSUTF8StringEncoding]; - while (!result && truncated.length > 0) { - truncated = [truncated subdataWithRange:NSMakeRange(0, truncated.length - 1)]; - result = [[NSString alloc] initWithData:truncated - encoding:NSUTF8StringEncoding]; + + NSMutableData *scratch = [NSMutableData dataWithLength:MAX_TEXT_FIELD_BYTES]; + NSUInteger fullUsedLength = 0; + NSRange remainingRange = NSMakeRange(0, 0); + BOOL fullConverted = [value getBytes:scratch.mutableBytes + maxLength:MAX_TEXT_FIELD_BYTES + usedLength:&fullUsedLength + encoding:NSUTF8StringEncoding + options:0 + range:NSMakeRange(0, valueLength) + remainingRange:&remainingRange]; + if (fullConverted && fullUsedLength <= MAX_TEXT_FIELD_BYTES && + remainingRange.location == NSMaxRange(NSMakeRange(0, valueLength))) { + return value; + } + + NSUInteger low = 0; + NSUInteger high = valueLength; + NSUInteger bestLength = 0; + NSUInteger bestBytes = 0; + while (low <= high) { + NSUInteger mid = low + (high - low) / 2; + NSUInteger usedLength = 0; + remainingRange = NSMakeRange(0, 0); + BOOL converted = [value getBytes:scratch.mutableBytes + maxLength:MAX_TEXT_FIELD_BYTES + usedLength:&usedLength + encoding:NSUTF8StringEncoding + options:0 + range:NSMakeRange(0, mid) + remainingRange:&remainingRange]; + if (converted && usedLength <= MAX_TEXT_FIELD_BYTES && + remainingRange.location == mid) { + bestLength = mid; + bestBytes = usedLength; + if (mid == valueLength) { + break; + } + low = mid + 1; + } else if (mid == 0) { + break; + } else { + high = mid - 1; + } + } + if (bestLength == 0 && bestBytes == 0) { + return @""; + } + + NSMutableData *data = [NSMutableData dataWithLength:bestBytes]; + NSUInteger usedLength = 0; + BOOL converted = [value getBytes:data.mutableBytes + maxLength:bestBytes + usedLength:&usedLength + encoding:NSUTF8StringEncoding + options:0 + range:NSMakeRange(0, bestLength) + remainingRange:&remainingRange]; + if (!converted || usedLength != bestBytes) { + return @""; + } + return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; +} + +static BOOL addWouldExceedNSUInteger(NSUInteger left, NSUInteger right, + NSUInteger limit) { + return left > limit || right > limit - left; +} + +static NSUInteger artworkCacheCost(NSData *data, NSString *encoded) { + if (!data || !encoded) { + return 0; + } + + NSUInteger encodedBytes = + [encoded lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + if (addWouldExceedNSUInteger(data.length, encodedBytes, NSUIntegerMax)) { + return NSUIntegerMax; } - return result ?: @""; + return data.length + encodedBytes; +} + +static NSUInteger cachedArtworkEntryCost(NSDictionary *entry) { + NSData *data = entry[@"data"]; + NSString *encoded = entry[@"encoded"]; + if (![data isKindOfClass:[NSData class]] || + ![encoded isKindOfClass:[NSString class]]) { + return 0; + } + return artworkCacheCost(data, encoded); +} + +static void removeCachedArtwork(NSString *stableID) { + NSDictionary *entry = artworkCache[stableID]; + if (!entry) { + return; + } + + NSUInteger cost = cachedArtworkEntryCost(entry); + artworkCacheBytes = cost > artworkCacheBytes ? 0 : artworkCacheBytes - cost; + [artworkCache removeObjectForKey:stableID]; } static NSString *cachedArtworkData(NSString *stableID, NSData *artwork) { @@ -95,14 +191,22 @@ static long integerProperty(id object, NSString *selectorName, BOOL *present) { } NSString *encoded = [artwork base64EncodedStringWithOptions:0]; + removeCachedArtwork(stableID); + NSUInteger cost = artworkCacheCost(artwork, encoded); + if (addWouldExceedNSUInteger(artworkCacheBytes, cost, + MAX_ARTWORK_CACHE_BYTES)) { + return encoded; + } + artworkCache[stableID] = @{ @"data" : [artwork copy], @"encoded" : encoded }; + artworkCacheBytes += cost; return encoded; } static void pruneArtworkCache(NSSet *activeStableIDs) { for (NSString *stableID in [artworkCache.allKeys copy]) { if (![activeStableIDs containsObject:stableID]) { - [artworkCache removeObjectForKey:stableID]; + removeCachedArtwork(stableID); } } } @@ -124,6 +228,51 @@ static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, } } +static void copyDateSeconds(NSMutableDictionary *destination, NSString *outputKey, + NSDate *date) { + if (![date isKindOfClass:[NSDate class]]) { + return; + } + NSTimeInterval seconds = [date timeIntervalSince1970]; + if (isfinite(seconds)) { + destination[outputKey] = @(seconds); + } +} + +static void copyMetadata(NSMutableDictionary *entry, NSDictionary *information, + NSString *stableID, BOOL includeArtwork, + BOOL resolvePlayingFromRate) { + if (![information isKindOfClass:[NSDictionary class]]) { + return; + } + copyString(entry, @"title", information, @"kMRMediaRemoteNowPlayingInfoTitle"); + copyString(entry, @"artist", information, @"kMRMediaRemoteNowPlayingInfoArtist"); + copyString(entry, @"album", information, @"kMRMediaRemoteNowPlayingInfoAlbum"); + copyNumber(entry, @"elapsedTime", information, + @"kMRMediaRemoteNowPlayingInfoElapsedTime"); + copyNumber(entry, @"duration", information, + @"kMRMediaRemoteNowPlayingInfoDuration"); + copyNumber(entry, @"playbackRate", information, + @"kMRMediaRemoteNowPlayingInfoPlaybackRate"); + if (resolvePlayingFromRate) { + NSNumber *rate = entry[@"playbackRate"]; + entry[@"playing"] = @(rate && [rate doubleValue] > 0.0); + entry[@"playingResolved"] = @YES; + } + copyDateSeconds(entry, @"infoUpdateDate", + information[@"kMRMediaRemoteNowPlayingInfoTimestamp"]); + if (!includeArtwork) { + return; + } + id artwork = information[@"kMRMediaRemoteNowPlayingInfoArtworkData"]; + if ([artwork isKindOfClass:[NSData class]]) { + NSString *encoded = cachedArtworkData(stableID, (NSData *)artwork); + if (encoded) { + entry[@"artworkData"] = encoded; + } + } +} + static NSArray *publicCandidates(NSArray *candidates) { NSMutableArray *publicCandidates = [NSMutableArray arrayWithCapacity:candidates.count]; @@ -159,6 +308,101 @@ static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, return publicCandidates; } +static NSComparisonResult compareCandidateRank(NSDictionary *left, + NSDictionary *right) { + NSNumber *leftDate = left[@"lastPlayingDate"]; + NSNumber *rightDate = right[@"lastPlayingDate"]; + BOOL leftHasDate = [leftDate isKindOfClass:[NSNumber class]]; + BOOL rightHasDate = [rightDate isKindOfClass:[NSNumber class]]; + if (leftHasDate && rightHasDate) { + double leftValue = [leftDate doubleValue]; + double rightValue = [rightDate doubleValue]; + if (leftValue > rightValue) { + return NSOrderedAscending; + } + if (leftValue < rightValue) { + return NSOrderedDescending; + } + } else if (leftHasDate) { + return NSOrderedAscending; + } else if (rightHasDate) { + return NSOrderedDescending; + } + + BOOL leftElected = [left[@"elected"] boolValue]; + BOOL rightElected = [right[@"elected"] boolValue]; + if (leftElected != rightElected) { + return leftElected ? NSOrderedAscending : NSOrderedDescending; + } + + NSString *leftStableID = left[@"stableId"]; + NSString *rightStableID = right[@"stableId"]; + if (![leftStableID isKindOfClass:[NSString class]]) { + leftStableID = @""; + } + if (![rightStableID isKindOfClass:[NSString class]]) { + rightStableID = @""; + } + return [leftStableID compare:rightStableID]; +} + +static NSArray *rankedPlayingCandidates(NSArray *candidates, NSUInteger limit) { + NSMutableArray *playingCandidates = [NSMutableArray array]; + for (NSDictionary *candidate in candidates) { + if (![candidate isKindOfClass:[NSDictionary class]]) { + continue; + } + if ([candidate[@"playing"] boolValue] && + [candidate[@"playingResolved"] boolValue]) { + [playingCandidates addObject:candidate]; + } + } + [playingCandidates sortUsingComparator:^NSComparisonResult(NSDictionary *left, + NSDictionary *right) { + return compareCandidateRank(left, right); + }]; + if (playingCandidates.count > limit) { + return [playingCandidates subarrayWithRange:NSMakeRange(0, limit)]; + } + return playingCandidates; +} + +static NSComparisonResult compareRecordRank(NSDictionary *left, + NSDictionary *right) { + NSDictionary *leftEntry = left[@"entry"]; + NSDictionary *rightEntry = right[@"entry"]; + return compareCandidateRank(leftEntry, rightEntry); +} + +static void insertTopRecord(NSMutableArray *topRecords, NSDictionary *record, + NSUInteger limit) { + NSDictionary *entry = record[@"entry"]; + if (![entry isKindOfClass:[NSDictionary class]] || + ![entry[@"playing"] boolValue] || + ![entry[@"playingResolved"] boolValue]) { + return; + } + [topRecords addObject:record]; + [topRecords sortUsingComparator:^NSComparisonResult(NSDictionary *left, + NSDictionary *right) { + return compareRecordRank(left, right); + }]; + while (topRecords.count > limit) { + [topRecords removeLastObject]; + } +} + +static NSArray *candidateEntriesFromRecords(NSArray *records) { + NSMutableArray *entries = [NSMutableArray arrayWithCapacity:records.count]; + for (NSDictionary *record in records) { + NSDictionary *entry = record[@"entry"]; + if ([entry isKindOfClass:[NSDictionary class]]) { + [entries addObject:entry]; + } + } + return entries; +} + static void printCandidates(NSArray *candidates) { NSDictionary *payload = @{ @"candidates" : candidates ?: @[] }; NSError *error = nil; @@ -203,8 +447,7 @@ static void refreshSessions(void) { refreshInFlight = YES; dispatch_queue_t queue = dispatch_get_main_queue(); - NSMutableArray *candidates = [NSMutableArray array]; - NSMutableSet *activeStableIDs = [NSMutableSet set]; + NSMutableArray *topRecords = [NSMutableArray array]; Class playerPathClass = NSClassFromString(@"MRPlayerPath"); Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); id electedPath = objectProperty(requestClass, @"localNowPlayingPlayerPath"); @@ -218,7 +461,16 @@ static void refreshSessions(void) { if (timedOut) { restartAfterTimeout(); } else { - printCandidates(publicCandidates(candidates)); + NSArray *candidates = publicCandidates(candidateEntriesFromRecords(topRecords)); + printCandidates(candidates); + NSMutableSet *activeStableIDs = + [NSMutableSet setWithCapacity:candidates.count]; + for (NSDictionary *candidate in candidates) { + NSString *stableID = candidate[@"stableId"]; + if ([stableID isKindOfClass:[NSString class]]) { + [activeStableIDs addObject:stableID]; + } + } pruneArtworkCache(activeStableIDs); } refreshInFlight = NO; @@ -238,22 +490,65 @@ static void refreshSessions(void) { NSArray *clients = [clientsValue isKindOfClass:[NSArray class]] ? (NSArray *)clientsValue : @[]; - dispatch_group_t group = dispatch_group_create(); - - NSUInteger processedClients = 0; - for (id client in clients) { - if (processedClients >= MAX_PROCESSED_CLIENTS) { - break; - } - processedClients++; - dispatch_group_enter(group); - getPlayerForClient(client, nil, queue, ^(id player) { + __block void (^enrichTopRecords)(void); + __block void (^processBatch)(NSUInteger) = nil; + + enrichTopRecords = ^{ + if (completed || topRecords.count == 0) { + complete(NO); + return; + } + dispatch_group_t enrichmentGroup = dispatch_group_create(); + for (NSDictionary *record in [topRecords copy]) { + NSMutableDictionary *entry = record[@"entry"]; + id playerPath = record[@"playerPath"]; + NSString *stableID = entry[@"stableId"]; + if (!playerPath || ![stableID isKindOfClass:[NSString class]]) { + continue; + } + dispatch_group_enter(enrichmentGroup); + getInfoForPlayer(playerPath, YES, queue, + ^(NSDictionary *information) { + if (!completed) { + copyMetadata(entry, information, stableID, YES, NO); + } + dispatch_group_leave(enrichmentGroup); + }); + } + dispatch_group_notify(enrichmentGroup, queue, ^{ + complete(NO); + }); + }; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-retain-cycles" + processBatch = ^(NSUInteger startIndex) { + if (completed) { + processBatch = nil; + return; + } + if (startIndex >= clients.count) { + enrichTopRecords(); + processBatch = nil; + return; + } + + NSUInteger endIndex = + MIN(clients.count, startIndex + MAX_PHASE1_BATCH_CLIENTS); + dispatch_group_t batchGroup = dispatch_group_create(); + NSMutableArray *batchRecords = + [NSMutableArray arrayWithCapacity:endIndex - startIndex]; + + for (NSUInteger index = startIndex; index < endIndex; index++) { + id client = clients[index]; + dispatch_group_enter(batchGroup); + getPlayerForClient(client, nil, queue, ^(id player) { if (completed) { - dispatch_group_leave(group); + dispatch_group_leave(batchGroup); return; } if (!player || !playerPathClass) { - dispatch_group_leave(group); + dispatch_group_leave(batchGroup); return; } @@ -262,7 +557,7 @@ static void refreshSessions(void) { client:client player:player]; if (!playerPath) { - dispatch_group_leave(group); + dispatch_group_leave(batchGroup); return; } @@ -275,17 +570,14 @@ static void refreshSessions(void) { ?: stringProperty(player, @"displayName") ?: @"default"); BOOL hasProcessIdentifier = NO; - long processIdentifier = integerProperty( - client, @"processIdentifier", &hasProcessIdentifier); - NSString *stableID = hasProcessIdentifier - ? [NSString stringWithFormat:@"%@:%@:%ld", - bundleID, - playerID, - processIdentifier] - : [NSString stringWithFormat:@"%@:%@", - bundleID, - playerID]; - [activeStableIDs addObject:stableID]; + long processIdentifier = + integerProperty(client, @"processIdentifier", + &hasProcessIdentifier); + NSString *stableID = + hasProcessIdentifier + ? [NSString stringWithFormat:@"%@:%@:%ld", bundleID, + playerID, processIdentifier] + : [NSString stringWithFormat:@"%@:%@", bundleID, playerID]; NSMutableDictionary *entry = [@{ @"stableId" : stableID, @"bundleId" : bundleID, @@ -294,8 +586,6 @@ static void refreshSessions(void) { @"lastPlayingDateError" : @NO, @"elected" : @([playerPath isEqual:electedPath]), } mutableCopy]; - [candidates addObject:entry]; - MRNowPlayingRequest *request = requestClass ? [[(id)requestClass alloc] initWithPlayerPath:playerPath] @@ -304,106 +594,81 @@ static void refreshSessions(void) { @"requestIsPlayingOnQueue:completion:"); BOOL supportsScopedPlaying = request && [request respondsToSelector:isPlayingSelector]; - - dispatch_group_enter(group); - getInfoForPlayer(playerPath, YES, queue, - ^(NSDictionary *information) { - if (completed) { - dispatch_group_leave(group); - return; - } - if ([information isKindOfClass:[NSDictionary class]]) { - copyString(entry, @"title", information, - @"kMRMediaRemoteNowPlayingInfoTitle"); - copyString(entry, @"artist", information, - @"kMRMediaRemoteNowPlayingInfoArtist"); - copyString(entry, @"album", information, - @"kMRMediaRemoteNowPlayingInfoAlbum"); - copyNumber(entry, @"elapsedTime", information, - @"kMRMediaRemoteNowPlayingInfoElapsedTime"); - copyNumber(entry, @"duration", information, - @"kMRMediaRemoteNowPlayingInfoDuration"); - copyNumber(entry, @"playbackRate", information, - @"kMRMediaRemoteNowPlayingInfoPlaybackRate"); - if (!supportsScopedPlaying) { - NSNumber *rate = entry[@"playbackRate"]; - entry[@"playing"] = @(rate && [rate doubleValue] > 0.0); - entry[@"playingResolved"] = @YES; - } - id timestamp = information[ - @"kMRMediaRemoteNowPlayingInfoTimestamp"]; - if ([timestamp isKindOfClass:[NSDate class]]) { - entry[@"infoUpdateDate"] = - @([(NSDate *)timestamp timeIntervalSince1970]); - } - id artwork = information[ - @"kMRMediaRemoteNowPlayingInfoArtworkData"]; - if ([artwork isKindOfClass:[NSData class]]) { - NSString *encoded = - cachedArtworkData(stableID, (NSData *)artwork); - if (encoded) { - entry[@"artworkData"] = encoded; - } + NSDictionary *record = @{ + @"entry" : entry, + @"playerPath" : playerPath, + @"supportsScopedPlaying" : @(supportsScopedPlaying), + }; + [batchRecords addObject:record]; + + if (request && supportsScopedPlaying) { + dispatch_group_enter(batchGroup); + ((void (*)(id, SEL, dispatch_queue_t, + void (^)(BOOL, NSError *)))objc_msgSend)( + request, isPlayingSelector, requestQueue, + ^(BOOL playing, NSError *error) { + (void)request; + dispatch_async(queue, ^{ + if (!completed && !error) { + entry[@"playing"] = @(playing); + entry[@"playingResolved"] = @YES; + } + dispatch_group_leave(batchGroup); + }); + }); + } else { + dispatch_group_enter(batchGroup); + getInfoForPlayer(playerPath, NO, queue, + ^(NSDictionary *information) { + if (!completed) { + copyMetadata(entry, information, stableID, NO, YES); } - } - dispatch_group_leave(group); - }); + dispatch_group_leave(batchGroup); + }); + } if (request) { - if (supportsScopedPlaying) { - dispatch_group_enter(group); - ((void (*)(id, SEL, dispatch_queue_t, - void (^)(BOOL, NSError *)))objc_msgSend)( - request, isPlayingSelector, requestQueue, - ^(BOOL playing, NSError *error) { - (void)request; - dispatch_async(queue, ^{ - if (completed) { - dispatch_group_leave(group); - return; - } - if (!error) { - entry[@"playing"] = @(playing); - entry[@"playingResolved"] = @YES; - } - dispatch_group_leave(group); - }); - }); - } - SEL lastPlayingSelector = NSSelectorFromString( @"requestLastPlayingDateOnQueue:completion:"); - if (request && [request respondsToSelector:lastPlayingSelector]) { - dispatch_group_enter(group); + if ([request respondsToSelector:lastPlayingSelector]) { + dispatch_group_enter(batchGroup); ((void (*)(id, SEL, dispatch_queue_t, void (^)(NSDate *, NSError *)))objc_msgSend)( request, lastPlayingSelector, requestQueue, ^(NSDate *date, NSError *error) { (void)request; dispatch_async(queue, ^{ - if (completed) { - dispatch_group_leave(group); - return; - } - if (error) { - entry[@"lastPlayingDateError"] = @YES; - } else if ([date isKindOfClass:[NSDate class]]) { - entry[@"lastPlayingDate"] = - @([date timeIntervalSince1970]); + if (!completed) { + if (error) { + entry[@"lastPlayingDateError"] = @YES; + } else { + copyDateSeconds(entry, @"lastPlayingDate", + date); + } } - dispatch_group_leave(group); + dispatch_group_leave(batchGroup); }); }); } } - dispatch_group_leave(group); - }); - } + dispatch_group_leave(batchGroup); + }); + } - dispatch_group_notify(group, queue, ^{ - complete(NO); - }); + dispatch_group_notify(batchGroup, queue, ^{ + for (NSDictionary *record in batchRecords) { + insertTopRecord(topRecords, record, + MAX_ENRICHED_PLAYING_CANDIDATES); + } + if (processBatch) { + processBatch(endIndex); + } + }); + }; +#pragma clang diagnostic pop + + processBatch(0); }); } @@ -415,11 +680,195 @@ static void scheduleRefresh(void) { }); } +#if !defined(MEDIA_SESSIONS_SMOKE) static void printReady(void) { printf("{\"ready\":true}\n"); fflush(stdout); } +#endif + +#if defined(MEDIA_SESSIONS_SMOKE) +@interface SmokeInfiniteDate : NSDate +@end + +@implementation SmokeInfiniteDate +- (NSTimeInterval)timeIntervalSinceReferenceDate { + return INFINITY; +} +- (NSTimeInterval)timeIntervalSince1970 { + return INFINITY; +} +@end + +static NSMutableDictionary *smokeCandidate(NSString *stableID, BOOL playing, + BOOL resolved, NSTimeInterval date, + BOOL hasDate, BOOL elected) { + NSMutableDictionary *candidate = [@{ + @"stableId" : stableID, + @"playing" : @(playing), + @"playingResolved" : @(resolved), + @"elected" : @(elected), + } mutableCopy]; + if (hasDate) { + candidate[@"lastPlayingDate"] = @(date); + } + return candidate; +} + +static NSDictionary *smokeRecord(NSMutableDictionary *entry) { + return @{ @"entry" : entry, @"playerPath" : entry }; +} + +static int runSmokeTests(void) { + artworkCache = [NSMutableDictionary dictionary]; + artworkCacheBytes = 0; + NSMutableString *longText = [NSMutableString string]; + for (NSUInteger i = 0; i < MAX_TEXT_FIELD_BYTES + 16; i++) { + [longText appendString:@"a"]; + } + NSString *bounded = boundedString(longText); + if ([bounded lengthOfBytesUsingEncoding:NSUTF8StringEncoding] != + MAX_TEXT_FIELD_BYTES) { + fprintf(stderr, "boundedString did not enforce byte cap\n"); + return 1; + } + + NSString *multibyte = [@"🙂" stringByPaddingToLength:MAX_TEXT_FIELD_BYTES + withString:@"🙂" + startingAtIndex:0]; + bounded = boundedString(multibyte); + if ([bounded lengthOfBytesUsingEncoding:NSUTF8StringEncoding] > + MAX_TEXT_FIELD_BYTES) { + fprintf(stderr, "boundedString returned oversized UTF-8\n"); + return 1; + } + + NSData *smallArtwork = [@"abcd" dataUsingEncoding:NSUTF8StringEncoding]; + NSString *encoded = cachedArtworkData(@"one", smallArtwork); + NSUInteger expectedCost = artworkCacheCost(smallArtwork, encoded); + if (!encoded || artworkCacheBytes != expectedCost || artworkCache.count != 1) { + fprintf(stderr, "artwork cache insertion accounting failed\n"); + return 1; + } + if (cachedArtworkData(@"one", smallArtwork) != encoded || + artworkCacheBytes != expectedCost) { + fprintf(stderr, "artwork cache hit accounting failed\n"); + return 1; + } + + NSMutableData *largeArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES - 1]; + NSString *largeEncoded = cachedArtworkData(@"one", largeArtwork); + NSUInteger largeCost = artworkCacheCost(largeArtwork, largeEncoded); + if (!largeEncoded || !artworkCache[@"one"] || artworkCacheBytes != largeCost) { + fprintf(stderr, "artwork cache large insertion accounting failed\n"); + return 1; + } + NSString *uncachedEncoded = cachedArtworkData(@"two", largeArtwork); + if (!uncachedEncoded || artworkCache[@"two"] || artworkCacheBytes != largeCost) { + fprintf(stderr, "artwork cache oversize replacement accounting failed\n"); + return 1; + } + + pruneArtworkCache([NSSet set]); + if (artworkCache.count != 0 || artworkCacheBytes != 0) { + fprintf(stderr, "artwork cache prune accounting failed\n"); + return 1; + } + + NSMutableArray *manyCandidates = [NSMutableArray array]; + for (NSUInteger i = 0; i < MAX_ENRICHED_PLAYING_CANDIDATES + 4; i++) { + [manyCandidates addObject:smokeCandidate( + [NSString stringWithFormat:@"early-%02lu", + (unsigned long)i], + YES, YES, (NSTimeInterval)i, YES, NO)]; + } + NSMutableDictionary *lateNewest = + smokeCandidate(@"late-newest", YES, YES, 5000.0, YES, NO); + [manyCandidates addObject:lateNewest]; + NSArray *ranked = + rankedPlayingCandidates(manyCandidates, MAX_ENRICHED_PLAYING_CANDIDATES); + if (ranked.count != MAX_ENRICHED_PLAYING_CANDIDATES || + ranked.firstObject != lateNewest || ![ranked containsObject:lateNewest]) { + fprintf(stderr, "ranked enrichment dropped a late newest candidate\n"); + return 1; + } + + NSMutableArray *missingDateTie = [NSMutableArray array]; + for (NSUInteger i = 0; i < MAX_ENRICHED_PLAYING_CANDIDATES + 3; i++) { + [missingDateTie addObject:smokeCandidate( + [NSString stringWithFormat:@"prefix-%02lu", + (unsigned long)i], + YES, YES, 0.0, NO, NO)]; + } + NSMutableDictionary *lateElected = + smokeCandidate(@"late-elected", YES, YES, 0.0, NO, YES); + [missingDateTie addObject:lateElected]; + ranked = + rankedPlayingCandidates(missingDateTie, MAX_ENRICHED_PLAYING_CANDIDATES); + if (ranked.firstObject != lateElected || ![ranked containsObject:lateElected]) { + fprintf(stderr, "ranked enrichment dropped a late elected candidate\n"); + return 1; + } + + NSMutableArray *topRecords = [NSMutableArray array]; + for (NSUInteger i = 0; i < MAX_PHASE1_BATCH_CLIENTS * 3; i++) { + NSMutableDictionary *entry = smokeCandidate( + [NSString stringWithFormat:@"batch-%02lu", (unsigned long)i], YES, + YES, (NSTimeInterval)i, YES, NO); + insertTopRecord(topRecords, smokeRecord(entry), + MAX_ENRICHED_PLAYING_CANDIDATES); + if (topRecords.count > MAX_ENRICHED_PLAYING_CANDIDATES) { + fprintf(stderr, "top record set exceeded enrichment cap\n"); + return 1; + } + } + NSMutableDictionary *lateBatchWinner = + smokeCandidate(@"late-batch-winner", YES, YES, 9000.0, YES, NO); + insertTopRecord(topRecords, smokeRecord(lateBatchWinner), + MAX_ENRICHED_PLAYING_CANDIDATES); + if ([topRecords.firstObject objectForKey:@"entry"] != lateBatchWinner) { + fprintf(stderr, "streaming top records dropped late batch winner\n"); + return 1; + } + + NSArray *publicTop = + publicCandidates(candidateEntriesFromRecords(topRecords)); + if (publicTop.count != MAX_ENRICHED_PLAYING_CANDIDATES) { + fprintf(stderr, "public candidate count exceeded top-K cap\n"); + return 1; + } + + NSMutableDictionary *fallbackEntry = + smokeCandidate(@"unsupported", NO, NO, 0.0, NO, NO); + NSData *fallbackArtwork = [@"artwork" dataUsingEncoding:NSUTF8StringEncoding]; + copyMetadata(fallbackEntry, + @{ + @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @1.0, + @"kMRMediaRemoteNowPlayingInfoArtworkData" : fallbackArtwork, + @"kMRMediaRemoteNowPlayingInfoTimestamp" : + [SmokeInfiniteDate new], + }, + @"unsupported", NO, YES); + if (![fallbackEntry[@"playing"] boolValue] || + ![fallbackEntry[@"playingResolved"] boolValue] || + fallbackEntry[@"artworkData"] || fallbackEntry[@"infoUpdateDate"]) { + fprintf(stderr, "metadata fallback did not stay lightweight and finite\n"); + return 1; + } + + return 0; +} + +int main(int argc, char *argv[]) { + (void)argc; + (void)argv; + @autoreleasepool { + return runSmokeTests(); + } +} +#else int main(int argc, char *argv[]) { (void)argc; helperArgv = argv; @@ -475,3 +924,4 @@ int main(int argc, char *argv[]) { } return 0; } +#endif From e55f249487e1a68189a3fa82982479b257e3a52e Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 13:46:41 +0900 Subject: [PATCH 13/20] ci: run media helper smoke tests --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8554948..eff3525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,22 @@ jobs: aarch64-apple-darwin \ x86_64-apple-darwin + - name: Smoke test Objective-C media helper + run: | + set -euo pipefail + smoke_executable="${RUNNER_TEMP}/codex_micro_chroma_media_sessions_smoke" + /usr/bin/xcrun clang \ + -fobjc-arc \ + -fblocks \ + -Wall \ + -Wextra \ + -Werror \ + -DMEDIA_SESSIONS_SMOKE \ + -framework Foundation \ + src/media_sessions.m \ + -o "$smoke_executable" + "$smoke_executable" + - name: Check formatting run: cargo +"$RUST_TOOLCHAIN" fmt --all --check From 0cc988a546560a9e83931d93fbc411eb6f26a11a Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 14:01:55 +0900 Subject: [PATCH 14/20] fix: harden media refresh pipeline --- src/media.rs | 142 ++++++++++++++++++++++++++++++++++++------- src/media_sessions.m | 48 ++++++++++++--- 2 files changed, 161 insertions(+), 29 deletions(-) diff --git a/src/media.rs b/src/media.rs index 1fb5a29..6a018c5 100644 --- a/src/media.rs +++ b/src/media.rs @@ -238,29 +238,72 @@ mod platform { enum HelperControlMessage { Ready, Reset { reason: Option }, - Invalid(String), + Invalid, + } + + #[derive(Debug, PartialEq, Eq)] + enum HelperControlKind { + Ready, + Reset, + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ReadyControlMessage { + ready: bool, + } + + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct ResetControlMessage { + reset: Option, + } + + fn control_kind_prefix(line: &str) -> Option { + let trimmed = line.trim_start(); + if trimmed.starts_with(r#"{"ready""#) { + Some(HelperControlKind::Ready) + } else if trimmed.starts_with(r#"{"reset""#) { + Some(HelperControlKind::Reset) + } else { + None + } + } + + fn is_control_shaped(line: &str) -> bool { + control_kind_prefix(line).is_some() } fn parse_helper_control_line(line: &str) -> HelperControlMessage { - match serde_json::from_str::(line) { - Ok(serde_json::Value::Object(map)) - if map - .get("ready") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false) => - { - HelperControlMessage::Ready + if !is_control_shaped(line) { + return HelperControlMessage::Invalid; + } + match control_kind_prefix(line) { + Some(HelperControlKind::Ready) => { + match serde_json::from_str::(line) { + Ok(message) if message.ready => HelperControlMessage::Ready, + _ => HelperControlMessage::Invalid, + } } - Ok(serde_json::Value::Object(map)) if map.contains_key("reset") => { - HelperControlMessage::Reset { - reason: map - .get("reset") - .and_then(serde_json::Value::as_str) - .map(str::to_owned), + Some(HelperControlKind::Reset) => { + match serde_json::from_str::(line) { + Ok(message) => HelperControlMessage::Reset { + reason: message.reset, + }, + _ => HelperControlMessage::Invalid, } } - _ => HelperControlMessage::Invalid(line.to_owned()), + None => HelperControlMessage::Invalid, + } + } + + fn system_time_from_unix_seconds(seconds: f64) -> Option { + if !seconds.is_finite() || seconds < 0.0 { + return None; } + Duration::try_from_secs_f64(seconds) + .ok() + .and_then(|duration| UNIX_EPOCH.checked_add(duration)) } #[derive(Clone)] @@ -332,8 +375,7 @@ mod platform { }); let info_update_time = candidate .info_update_date - .filter(|seconds| seconds.is_finite() && *seconds >= 0.0) - .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs_f64(seconds))); + .and_then(system_time_from_unix_seconds); let update_age_seconds = info_update_time.and_then(|updated_at| { SystemTime::now() .duration_since(updated_at) @@ -487,7 +529,7 @@ mod platform { let _ = publish_stopped(state, reason, StoppedDelivery::OneShotReset); return; } - HelperControlMessage::Invalid(_) => {} + HelperControlMessage::Invalid => {} } let payload = match serde_json::from_str::(line) { Ok(payload) => payload, @@ -673,7 +715,7 @@ mod platform { ))); return; } - HelperControlMessage::Invalid(line) => { + HelperControlMessage::Invalid => { let _ = ready_tx.send(Err(format!( "malformed pre-ready output from MediaRemote session helper: {line}" ))); @@ -966,6 +1008,15 @@ mod platform { ); } + #[test] + fn huge_finite_media_remote_timestamp_degrades_to_payload_elapsed_time() { + let received = + ReceivedNowPlaying::from_candidate(Some(&candidate("Song", 42.0, f64::MAX)), None); + + assert_eq!(received.snapshot.elapsed_time, Some(42.0)); + assert_eq!(received.position_at_received, Some(42.0)); + } + #[test] fn helper_control_line_accepts_ready_message() { assert_eq!( @@ -988,7 +1039,7 @@ mod platform { fn helper_control_line_rejects_session_payload_before_ready() { assert_eq!( parse_helper_control_line(r#"{"candidates":[]}"#), - HelperControlMessage::Invalid(r#"{"candidates":[]}"#.to_owned()) + HelperControlMessage::Invalid ); } @@ -996,7 +1047,54 @@ mod platform { fn helper_control_line_rejects_malformed_before_ready() { assert_eq!( parse_helper_control_line("not json"), - HelperControlMessage::Invalid("not json".to_owned()) + HelperControlMessage::Invalid + ); + } + + #[test] + fn session_payload_is_not_control_shaped_before_payload_parse() { + let state = RwLock::new(NowPlayingState::default()); + let mut reported_parse_error = false; + let payload = r#"{"candidates":[{"stableId":"music","bundleId":"com.apple.Music","playing":true,"playingResolved":true,"lastPlayingDate":500.0,"elected":true,"title":"New Song"}]}"#; + + assert_eq!(control_kind_prefix(payload), None); + assert!(!is_control_shaped(payload)); + process_started_helper_line(&state, &mut reported_parse_error, payload); + + assert!(!reported_parse_error); + let state = state.read().expect("state lock is readable"); + let snapshot = &state + .latest + .as_ref() + .expect("payload is published") + .snapshot; + assert_eq!(snapshot.title.as_deref(), Some("New Song")); + } + + #[test] + fn malformed_post_ready_payloads_report_only_first_error() { + let state = RwLock::new(NowPlayingState::default()); + let mut reported_parse_error = false; + + process_started_helper_line(&state, &mut reported_parse_error, "not json"); + assert!(reported_parse_error); + process_started_helper_line(&state, &mut reported_parse_error, "{still bad"); + assert!(reported_parse_error); + process_started_helper_line( + &state, + &mut reported_parse_error, + r#"{"candidates":[{"stableId":"music","playing":true,"playingResolved":true}]}"#, + ); + + let state = state.read().expect("state lock is readable"); + assert_eq!( + state + .latest + .as_ref() + .expect("valid payload still publishes") + .snapshot + .is_playing, + Some(true) ); } diff --git a/src/media_sessions.m b/src/media_sessions.m index 02dad23..985b8bc 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -346,6 +346,7 @@ static NSComparisonResult compareCandidateRank(NSDictionary *left, return [leftStableID compare:rightStableID]; } +#if defined(MEDIA_SESSIONS_SMOKE) static NSArray *rankedPlayingCandidates(NSArray *candidates, NSUInteger limit) { NSMutableArray *playingCandidates = [NSMutableArray array]; for (NSDictionary *candidate in candidates) { @@ -366,6 +367,7 @@ static NSComparisonResult compareCandidateRank(NSDictionary *left, } return playingCandidates; } +#endif static NSComparisonResult compareRecordRank(NSDictionary *left, NSDictionary *right) { @@ -403,6 +405,15 @@ static void insertTopRecord(NSMutableArray *topRecords, NSDictionary *record, return entries; } +static NSUInteger nextWatchdogToken(NSUInteger token) { + return token == NSUIntegerMax ? 1 : token + 1; +} + +static BOOL watchdogTokenIsCurrent(NSUInteger armedToken, + NSUInteger currentToken) { + return armedToken != 0 && armedToken == currentToken; +} + static void printCandidates(NSArray *candidates) { NSDictionary *payload = @{ @"candidates" : candidates ?: @[] }; NSError *error = nil; @@ -452,12 +463,28 @@ static void refreshSessions(void) { Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); id electedPath = objectProperty(requestClass, @"localNowPlayingPlayerPath"); __block BOOL completed = NO; + __block NSUInteger watchdogToken = 0; + __block void (^complete)(BOOL) = nil; + + void (^armWatchdog)(void) = ^{ + watchdogToken = nextWatchdogToken(watchdogToken); + NSUInteger armedToken = watchdogToken; + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.75 * NSEC_PER_SEC)), + queue, ^{ + if (!completed && + watchdogTokenIsCurrent(armedToken, watchdogToken)) { + complete(YES); + } + }); + }; - void (^complete)(BOOL) = ^(BOOL timedOut) { + complete = ^(BOOL timedOut) { if (completed) { return; } completed = YES; + watchdogToken = nextWatchdogToken(watchdogToken); if (timedOut) { restartAfterTimeout(); } else { @@ -477,12 +504,7 @@ static void refreshSessions(void) { scheduleRefresh(); }; - dispatch_after( - dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.75 * NSEC_PER_SEC)), - queue, ^{ - complete(YES); - }); - + armWatchdog(); getNowPlayingClients(queue, ^(id clientsValue) { if (completed) { return; @@ -498,6 +520,7 @@ static void refreshSessions(void) { complete(NO); return; } + armWatchdog(); dispatch_group_t enrichmentGroup = dispatch_group_create(); for (NSDictionary *record in [topRecords copy]) { NSMutableDictionary *entry = record[@"entry"]; @@ -533,6 +556,7 @@ static void refreshSessions(void) { return; } + armWatchdog(); NSUInteger endIndex = MIN(clients.count, startIndex + MAX_PHASE1_BATCH_CLIENTS); dispatch_group_t batchGroup = dispatch_group_create(); @@ -723,6 +747,16 @@ static int runSmokeTests(void) { artworkCache = [NSMutableDictionary dictionary]; artworkCacheBytes = 0; + if (nextWatchdogToken(0) != 1 || + nextWatchdogToken(41) != 42 || + nextWatchdogToken(NSUIntegerMax) != 1 || + !watchdogTokenIsCurrent(7, 7) || + watchdogTokenIsCurrent(0, 0) || + watchdogTokenIsCurrent(7, 8)) { + fprintf(stderr, "watchdog token guard failed\n"); + return 1; + } + NSMutableString *longText = [NSMutableString string]; for (NSUInteger i = 0; i < MAX_TEXT_FIELD_BYTES + 16; i++) { [longText appendString:@"a"]; From 7ed73366a5fd80874ab621dfecfd4f71791fbe7b Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 14:18:55 +0900 Subject: [PATCH 15/20] fix: redeliver recovered artwork --- src/media.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/media.rs b/src/media.rs index 6a018c5..9f4004e 100644 --- a/src/media.rs +++ b/src/media.rs @@ -178,7 +178,12 @@ struct ArtworkDelivery { #[cfg(any(target_os = "macos", test))] impl ArtworkDelivery { fn should_deliver(&mut self, key: Option<&str>, artwork_available: bool) -> bool { - let Some(key) = key.filter(|_| artwork_available) else { + if !artwork_available { + self.last_key = None; + return false; + } + + let Some(key) = key else { return false; }; if self.last_key.as_deref() == Some(key) { @@ -1669,6 +1674,17 @@ mod tests { assert!(delivery.should_deliver(Some("track"), true)); } + #[test] + fn artwork_can_be_delivered_again_after_no_artwork_observation() { + let mut delivery = ArtworkDelivery::default(); + + assert!(delivery.should_deliver(Some("track"), true)); + assert!(!delivery.should_deliver(Some("track"), true)); + assert!(!delivery.should_deliver(Some("track"), false)); + assert!(delivery.should_deliver(Some("track"), true)); + assert!(!delivery.should_deliver(Some("track"), true)); + } + #[test] fn resumed_playback_ignores_a_stale_media_remote_timestamp() { assert_eq!( From 5129acd99aacb67e0a28848ffe67e89108855374 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 14:38:21 +0900 Subject: [PATCH 16/20] fix: budget artwork encoding before allocation --- src/media_sessions.m | 308 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 299 insertions(+), 9 deletions(-) diff --git a/src/media_sessions.m b/src/media_sessions.m index 985b8bc..5c033a9 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -140,6 +140,33 @@ static BOOL addWouldExceedNSUInteger(NSUInteger left, NSUInteger right, return left > limit || right > limit - left; } +static BOOL base64EncodedLength(NSUInteger rawLength, NSUInteger *encodedLength) { + NSUInteger chunks = rawLength / 3; + NSUInteger remainder = rawLength % 3; + if (chunks > NSUIntegerMax / 4) { + return NO; + } + NSUInteger length = chunks * 4; + if (remainder != 0) { + if (addWouldExceedNSUInteger(length, 4, NSUIntegerMax)) { + return NO; + } + length += 4; + } + *encodedLength = length; + return YES; +} + +static BOOL artworkCacheCostForLengths(NSUInteger rawLength, + NSUInteger encodedLength, + NSUInteger *cost) { + if (addWouldExceedNSUInteger(rawLength, encodedLength, NSUIntegerMax)) { + return NO; + } + *cost = rawLength + encodedLength; + return YES; +} + static NSUInteger artworkCacheCost(NSData *data, NSString *encoded) { if (!data || !encoded) { return 0; @@ -190,12 +217,25 @@ static void removeCachedArtwork(NSString *stableID) { return cachedEncoded; } - NSString *encoded = [artwork base64EncodedStringWithOptions:0]; removeCachedArtwork(stableID); - NSUInteger cost = artworkCacheCost(artwork, encoded); + NSUInteger encodedLength = 0; + NSUInteger cost = 0; + if (!base64EncodedLength(artwork.length, &encodedLength) || + !artworkCacheCostForLengths(artwork.length, encodedLength, &cost) || + addWouldExceedNSUInteger(artworkCacheBytes, cost, + MAX_ARTWORK_CACHE_BYTES)) { + return nil; + } + + NSString *encoded = [artwork base64EncodedStringWithOptions:0]; + if ([encoded lengthOfBytesUsingEncoding:NSUTF8StringEncoding] != + encodedLength) { + return nil; + } + cost = artworkCacheCost(artwork, encoded); if (addWouldExceedNSUInteger(artworkCacheBytes, cost, MAX_ARTWORK_CACHE_BYTES)) { - return encoded; + return nil; } artworkCache[stableID] = @{ @"data" : [artwork copy], @"encoded" : encoded }; @@ -211,6 +251,90 @@ static void pruneArtworkCache(NSSet *activeStableIDs) { } } +static NSData *recordArtworkData(NSDictionary *record) { + NSDictionary *information = record[@"information"]; + if (![information isKindOfClass:[NSDictionary class]]) { + return nil; + } + id artwork = information[@"kMRMediaRemoteNowPlayingInfoArtworkData"]; + return [artwork isKindOfClass:[NSData class]] ? artwork : nil; +} + +static void pruneArtworkCacheForSelectedRecords(NSArray *records, + NSSet *stableIDs) { + pruneArtworkCache(stableIDs); + for (NSDictionary *record in records) { + if (![record[@"includeArtwork"] boolValue]) { + continue; + } + NSDictionary *entry = record[@"entry"]; + NSString *stableID = entry[@"stableId"]; + NSData *artworkData = recordArtworkData(record); + if (![stableID isKindOfClass:[NSString class]] || !artworkData) { + continue; + } + + NSDictionary *cached = artworkCache[stableID]; + NSData *cachedData = cached[@"data"]; + if ([cachedData isKindOfClass:[NSData class]] && + ![cachedData isEqualToData:artworkData]) { + removeCachedArtwork(stableID); + } + } +} + +static NSSet *markSelectedArtworkForRecords(NSArray *records) { + NSMutableSet *selected = [NSMutableSet set]; + NSMutableSet *seenStableIDs = [NSMutableSet set]; + NSUInteger serializedArtworkBytes = 0; + NSUInteger selectedCacheBytes = 0; + + for (NSMutableDictionary *record in records) { + if (![record isKindOfClass:[NSMutableDictionary class]]) { + continue; + } + [record removeObjectForKey:@"includeArtwork"]; + NSDictionary *entry = record[@"entry"]; + NSString *stableID = entry[@"stableId"]; + if (![entry isKindOfClass:[NSDictionary class]] || + ![stableID isKindOfClass:[NSString class]] || + [seenStableIDs containsObject:stableID] || + ![entry[@"playing"] boolValue] || + ![entry[@"playingResolved"] boolValue]) { + continue; + } + [seenStableIDs addObject:stableID]; + + NSData *artworkData = recordArtworkData(record); + if (!artworkData) { + continue; + } + if (artworkData.length > MAX_RAW_ARTWORK_BYTES) { + continue; + } + + NSUInteger encodedLength = 0; + NSUInteger cost = 0; + if (!base64EncodedLength(artworkData.length, &encodedLength) || + encodedLength > MAX_SERIALIZED_ARTWORK_BYTES || + addWouldExceedNSUInteger(serializedArtworkBytes, encodedLength, + MAX_SERIALIZED_ARTWORK_BYTES) || + !artworkCacheCostForLengths(artworkData.length, encodedLength, + &cost) || + addWouldExceedNSUInteger(selectedCacheBytes, cost, + MAX_ARTWORK_CACHE_BYTES)) { + continue; + } + + [selected addObject:stableID]; + record[@"includeArtwork"] = @YES; + serializedArtworkBytes += encodedLength; + selectedCacheBytes += cost; + } + + return selected; +} + static void copyString(NSMutableDictionary *destination, NSString *outputKey, NSDictionary *source, NSString *sourceKey) { id value = source[sourceKey]; @@ -533,12 +657,37 @@ static void refreshSessions(void) { getInfoForPlayer(playerPath, YES, queue, ^(NSDictionary *information) { if (!completed) { - copyMetadata(entry, information, stableID, YES, NO); + if ([record isKindOfClass:[NSMutableDictionary class]] && + [information isKindOfClass:[NSDictionary class]]) { + ((NSMutableDictionary *)record)[@"information"] = + information; + } } dispatch_group_leave(enrichmentGroup); }); } dispatch_group_notify(enrichmentGroup, queue, ^{ + NSSet *selectedArtworkStableIDs = + markSelectedArtworkForRecords(topRecords); + pruneArtworkCacheForSelectedRecords(topRecords, + selectedArtworkStableIDs); + for (NSDictionary *record in topRecords) { + NSMutableDictionary *entry = record[@"entry"]; + NSDictionary *information = record[@"information"]; + NSString *stableID = entry[@"stableId"]; + if (![entry isKindOfClass:[NSMutableDictionary class]] || + ![information isKindOfClass:[NSDictionary class]] || + ![stableID isKindOfClass:[NSString class]]) { + continue; + } + copyMetadata(entry, information, stableID, + [record[@"includeArtwork"] boolValue], + NO); + if ([record isKindOfClass:[NSMutableDictionary class]]) { + [(NSMutableDictionary *)record removeObjectForKey:@"includeArtwork"]; + [(NSMutableDictionary *)record removeObjectForKey:@"information"]; + } + } complete(NO); }); }; @@ -618,11 +767,11 @@ static void refreshSessions(void) { @"requestIsPlayingOnQueue:completion:"); BOOL supportsScopedPlaying = request && [request respondsToSelector:isPlayingSelector]; - NSDictionary *record = @{ + NSMutableDictionary *record = [@{ @"entry" : entry, @"playerPath" : playerPath, @"supportsScopedPlaying" : @(supportsScopedPlaying), - }; + } mutableCopy]; [batchRecords addObject:record]; if (request && supportsScopedPlaying) { @@ -739,8 +888,8 @@ - (NSTimeInterval)timeIntervalSince1970 { return candidate; } -static NSDictionary *smokeRecord(NSMutableDictionary *entry) { - return @{ @"entry" : entry, @"playerPath" : entry }; +static NSMutableDictionary *smokeRecord(NSMutableDictionary *entry) { + return [@{ @"entry" : entry, @"playerPath" : entry } mutableCopy]; } static int runSmokeTests(void) { @@ -800,7 +949,7 @@ static int runSmokeTests(void) { return 1; } NSString *uncachedEncoded = cachedArtworkData(@"two", largeArtwork); - if (!uncachedEncoded || artworkCache[@"two"] || artworkCacheBytes != largeCost) { + if (uncachedEncoded || artworkCache[@"two"] || artworkCacheBytes != largeCost) { fprintf(stderr, "artwork cache oversize replacement accounting failed\n"); return 1; } @@ -811,6 +960,147 @@ static int runSmokeTests(void) { return 1; } + NSUInteger encodedLength = 0; + if (!base64EncodedLength(0, &encodedLength) || encodedLength != 0 || + !base64EncodedLength(1, &encodedLength) || encodedLength != 4 || + !base64EncodedLength(2, &encodedLength) || encodedLength != 4 || + !base64EncodedLength(3, &encodedLength) || encodedLength != 4 || + !base64EncodedLength(4, &encodedLength) || encodedLength != 8) { + fprintf(stderr, "base64 encoded length accounting failed\n"); + return 1; + } + + NSMutableData *winnerArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES - 1]; + NSMutableData *rejectedArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES - 1]; + NSMutableData *thirdArtwork = [NSMutableData dataWithLength:3]; + NSMutableDictionary *winner = + smokeCandidate(@"winner", YES, YES, 3000.0, YES, NO); + NSMutableDictionary *runnerUp = + smokeCandidate(@"runner-up", YES, YES, 2000.0, YES, NO); + NSMutableDictionary *third = + smokeCandidate(@"third", YES, YES, 1000.0, YES, NO); + NSMutableDictionary *winnerRecord = smokeRecord(winner); + NSMutableDictionary *runnerUpRecord = smokeRecord(runnerUp); + NSMutableDictionary *thirdRecord = smokeRecord(third); + winnerRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : winnerArtwork }; + runnerUpRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : rejectedArtwork }; + thirdRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : thirdArtwork }; + NSArray *selectionRecords = @[ winnerRecord, runnerUpRecord, thirdRecord ]; + NSSet *selectedArtworkStableIDs = + markSelectedArtworkForRecords(selectionRecords); + if (![selectedArtworkStableIDs containsObject:@"winner"] || + [selectedArtworkStableIDs containsObject:@"runner-up"] || + ![selectedArtworkStableIDs containsObject:@"third"] || + ![winnerRecord[@"includeArtwork"] boolValue] || + runnerUpRecord[@"includeArtwork"] || + ![thirdRecord[@"includeArtwork"] boolValue]) { + fprintf(stderr, "artwork selection did not preserve winner-first budget\n"); + return 1; + } + pruneArtworkCacheForSelectedRecords(selectionRecords, + selectedArtworkStableIDs); + for (NSMutableDictionary *record in selectionRecords) { + NSMutableDictionary *entry = record[@"entry"]; + NSString *stableID = entry[@"stableId"]; + copyMetadata(entry, record[@"information"], stableID, + [record[@"includeArtwork"] boolValue], NO); + [record removeObjectForKey:@"includeArtwork"]; + [record removeObjectForKey:@"information"]; + } + if (!winner[@"artworkData"] || runnerUp[@"artworkData"] || + !third[@"artworkData"] || artworkCache.count != 2 || + !artworkCache[@"winner"] || + !artworkCache[@"third"] || + artworkCacheBytes != + artworkCacheCost(winnerArtwork, winner[@"artworkData"]) + + artworkCacheCost(thirdArtwork, third[@"artworkData"])) { + fprintf(stderr, "artwork selection/cache admission failed\n"); + return 1; + } + + artworkCache = [NSMutableDictionary dictionary]; + artworkCacheBytes = 0; + NSMutableData *oldLowerArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES - 1]; + NSMutableData *newWinnerArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES - 1]; + NSMutableData *newLowerArtwork = [NSMutableData dataWithLength:3]; + if (!cachedArtworkData(@"lower", oldLowerArtwork)) { + fprintf(stderr, "stale artwork cache setup failed\n"); + return 1; + } + NSMutableDictionary *changedWinner = + smokeCandidate(@"changed-winner", YES, YES, 4000.0, YES, NO); + NSMutableDictionary *changedLower = + smokeCandidate(@"lower", YES, YES, 3000.0, YES, NO); + NSMutableDictionary *changedWinnerRecord = smokeRecord(changedWinner); + NSMutableDictionary *changedLowerRecord = smokeRecord(changedLower); + changedWinnerRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : newWinnerArtwork }; + changedLowerRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : newLowerArtwork }; + NSArray *changedRecords = @[ changedWinnerRecord, changedLowerRecord ]; + selectedArtworkStableIDs = markSelectedArtworkForRecords(changedRecords); + pruneArtworkCacheForSelectedRecords(changedRecords, + selectedArtworkStableIDs); + if (artworkCache[@"lower"]) { + fprintf(stderr, "stale selected cache entry was not evicted\n"); + return 1; + } + for (NSMutableDictionary *record in changedRecords) { + NSMutableDictionary *entry = record[@"entry"]; + NSString *stableID = entry[@"stableId"]; + copyMetadata(entry, record[@"information"], stableID, + [record[@"includeArtwork"] boolValue], NO); + } + if (!changedWinner[@"artworkData"] || !changedLower[@"artworkData"] || + !artworkCache[@"changed-winner"] || !artworkCache[@"lower"]) { + fprintf(stderr, "stale selected cache entry blocked changed artwork\n"); + return 1; + } + + artworkCache = [NSMutableDictionary dictionary]; + artworkCacheBytes = 0; + NSMutableDictionary *duplicateFirst = + smokeCandidate(@"duplicate", YES, YES, 2000.0, YES, NO); + NSMutableDictionary *duplicateSecond = + smokeCandidate(@"duplicate", YES, YES, 1000.0, YES, NO); + NSMutableDictionary *duplicateFirstRecord = smokeRecord(duplicateFirst); + NSMutableDictionary *duplicateSecondRecord = smokeRecord(duplicateSecond); + duplicateFirstRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : + [@"first" dataUsingEncoding:NSUTF8StringEncoding] }; + duplicateSecondRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : + [@"second" dataUsingEncoding:NSUTF8StringEncoding] }; + NSArray *duplicateRecords = + @[ duplicateFirstRecord, duplicateSecondRecord ]; + selectedArtworkStableIDs = markSelectedArtworkForRecords(duplicateRecords); + if (selectedArtworkStableIDs.count != 1 || + ![duplicateFirstRecord[@"includeArtwork"] boolValue] || + duplicateSecondRecord[@"includeArtwork"]) { + fprintf(stderr, "duplicate stable ID artwork selection was not record-specific\n"); + return 1; + } + pruneArtworkCacheForSelectedRecords(duplicateRecords, + selectedArtworkStableIDs); + for (NSMutableDictionary *record in duplicateRecords) { + NSMutableDictionary *entry = record[@"entry"]; + NSString *stableID = entry[@"stableId"]; + copyMetadata(entry, record[@"information"], stableID, + [record[@"includeArtwork"] boolValue], NO); + } + if (!duplicateFirst[@"artworkData"] || duplicateSecond[@"artworkData"] || + artworkCache.count != 1) { + fprintf(stderr, "duplicate stable ID encoded more artwork than budgeted\n"); + return 1; + } + NSMutableArray *manyCandidates = [NSMutableArray array]; for (NSUInteger i = 0; i < MAX_ENRICHED_PLAYING_CANDIDATES + 4; i++) { [manyCandidates addObject:smokeCandidate( From 4e18c8964908f2e67c486b17eae4e07f0442358a Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 14:55:15 +0900 Subject: [PATCH 17/20] fix: bound retained media metadata --- src/media_sessions.m | 458 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 394 insertions(+), 64 deletions(-) diff --git a/src/media_sessions.m b/src/media_sessions.m index 5c033a9..67885c1 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -38,6 +38,7 @@ - (instancetype)initWithPlayerPath:(id)playerPath; static const NSUInteger MAX_ARTWORK_CACHE_BYTES = 32 * 1024 * 1024; static const NSUInteger MAX_ENRICHED_PLAYING_CANDIDATES = 8; static const NSUInteger MAX_PHASE1_BATCH_CLIENTS = 8; +static const NSUInteger MAX_PHASE2_BATCH_RECORDS = 4; static id objectProperty(id object, NSString *selectorName) { SEL selector = NSSelectorFromString(selectorName); @@ -260,9 +261,98 @@ static void pruneArtworkCache(NSSet *activeStableIDs) { return [artwork isKindOfClass:[NSData class]] ? artwork : nil; } -static void pruneArtworkCacheForSelectedRecords(NSArray *records, - NSSet *stableIDs) { - pruneArtworkCache(stableIDs); +static void copyString(NSMutableDictionary *destination, NSString *outputKey, + NSDictionary *source, NSString *sourceKey); +static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, + NSDictionary *source, NSString *sourceKey); + +static NSDictionary *sanitizedInformationForRetention(NSDictionary *information) { + if (![information isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSMutableDictionary *sanitized = [NSMutableDictionary dictionary]; + copyString(sanitized, @"kMRMediaRemoteNowPlayingInfoTitle", information, + @"kMRMediaRemoteNowPlayingInfoTitle"); + copyString(sanitized, @"kMRMediaRemoteNowPlayingInfoArtist", information, + @"kMRMediaRemoteNowPlayingInfoArtist"); + copyString(sanitized, @"kMRMediaRemoteNowPlayingInfoAlbum", information, + @"kMRMediaRemoteNowPlayingInfoAlbum"); + copyNumber(sanitized, @"kMRMediaRemoteNowPlayingInfoElapsedTime", + information, @"kMRMediaRemoteNowPlayingInfoElapsedTime"); + copyNumber(sanitized, @"kMRMediaRemoteNowPlayingInfoDuration", + information, @"kMRMediaRemoteNowPlayingInfoDuration"); + copyNumber(sanitized, @"kMRMediaRemoteNowPlayingInfoPlaybackRate", + information, @"kMRMediaRemoteNowPlayingInfoPlaybackRate"); + NSDate *timestamp = information[@"kMRMediaRemoteNowPlayingInfoTimestamp"]; + if ([timestamp isKindOfClass:[NSDate class]] && + isfinite([timestamp timeIntervalSince1970])) { + sanitized[@"kMRMediaRemoteNowPlayingInfoTimestamp"] = timestamp; + } + id artwork = information[@"kMRMediaRemoteNowPlayingInfoArtworkData"]; + if ([artwork isKindOfClass:[NSData class]] && + [(NSData *)artwork length] <= MAX_RAW_ARTWORK_BYTES) { + sanitized[@"kMRMediaRemoteNowPlayingInfoArtworkData"] = artwork; + } + + return sanitized; +} + +static NSUInteger retainedRawArtworkBytesForRecords(NSArray *records) { + NSUInteger total = 0; + for (NSDictionary *record in records) { + NSData *artworkData = recordArtworkData(record); + if (!artworkData) { + continue; + } + if (addWouldExceedNSUInteger(total, artworkData.length, NSUIntegerMax)) { + return NSUIntegerMax; + } + total += artworkData.length; + } + return total; +} + +typedef struct { + NSMutableSet *selectedStableIDs; + NSMutableSet *seenStableIDs; + NSUInteger serializedArtworkBytes; + NSUInteger selectedCacheBytes; +} ArtworkSelectionState; + +static ArtworkSelectionState makeArtworkSelectionState(void) { + ArtworkSelectionState state; + state.selectedStableIDs = [NSMutableSet set]; + state.seenStableIDs = [NSMutableSet set]; + state.serializedArtworkBytes = 0; + state.selectedCacheBytes = 0; + return state; +} + +static void evictUnselectedArtworkUntilAvailable(NSSet *stableIDs, + NSUInteger requiredBytes) { + if (!addWouldExceedNSUInteger(artworkCacheBytes, requiredBytes, + MAX_ARTWORK_CACHE_BYTES)) { + return; + } + + NSArray *keys = [[artworkCache allKeys] + sortedArrayUsingSelector:@selector(compare:)]; + for (NSString *stableID in keys) { + if ([stableIDs containsObject:stableID]) { + continue; + } + removeCachedArtwork(stableID); + if (!addWouldExceedNSUInteger(artworkCacheBytes, requiredBytes, + MAX_ARTWORK_CACHE_BYTES)) { + return; + } + } +} + +static void prepareArtworkCacheForSelectedRecords(NSArray *records, + NSSet *stableIDs) { + NSUInteger requiredBytes = 0; for (NSDictionary *record in records) { if (![record[@"includeArtwork"] boolValue]) { continue; @@ -276,19 +366,39 @@ static void pruneArtworkCacheForSelectedRecords(NSArray *records, NSDictionary *cached = artworkCache[stableID]; NSData *cachedData = cached[@"data"]; + NSString *cachedEncoded = cached[@"encoded"]; if ([cachedData isKindOfClass:[NSData class]] && ![cachedData isEqualToData:artworkData]) { removeCachedArtwork(stableID); + cached = nil; + cachedData = nil; + cachedEncoded = nil; + } + if ([cachedData isKindOfClass:[NSData class]] && + [cachedEncoded isKindOfClass:[NSString class]] && + [cachedData isEqualToData:artworkData]) { + continue; + } + + NSUInteger encodedLength = 0; + NSUInteger cost = 0; + if (base64EncodedLength(artworkData.length, &encodedLength) && + artworkCacheCostForLengths(artworkData.length, encodedLength, + &cost)) { + if (addWouldExceedNSUInteger(requiredBytes, cost, + NSUIntegerMax)) { + requiredBytes = NSUIntegerMax; + } else { + requiredBytes += cost; + } } } -} -static NSSet *markSelectedArtworkForRecords(NSArray *records) { - NSMutableSet *selected = [NSMutableSet set]; - NSMutableSet *seenStableIDs = [NSMutableSet set]; - NSUInteger serializedArtworkBytes = 0; - NSUInteger selectedCacheBytes = 0; + evictUnselectedArtworkUntilAvailable(stableIDs, requiredBytes); +} +static void markSelectedArtworkForRecordsWithState(NSArray *records, + ArtworkSelectionState *state) { for (NSMutableDictionary *record in records) { if (![record isKindOfClass:[NSMutableDictionary class]]) { continue; @@ -298,12 +408,12 @@ static void pruneArtworkCacheForSelectedRecords(NSArray *records, NSString *stableID = entry[@"stableId"]; if (![entry isKindOfClass:[NSDictionary class]] || ![stableID isKindOfClass:[NSString class]] || - [seenStableIDs containsObject:stableID] || + [state->seenStableIDs containsObject:stableID] || ![entry[@"playing"] boolValue] || ![entry[@"playingResolved"] boolValue]) { continue; } - [seenStableIDs addObject:stableID]; + [state->seenStableIDs addObject:stableID]; NSData *artworkData = recordArtworkData(record); if (!artworkData) { @@ -317,22 +427,20 @@ static void pruneArtworkCacheForSelectedRecords(NSArray *records, NSUInteger cost = 0; if (!base64EncodedLength(artworkData.length, &encodedLength) || encodedLength > MAX_SERIALIZED_ARTWORK_BYTES || - addWouldExceedNSUInteger(serializedArtworkBytes, encodedLength, + addWouldExceedNSUInteger(state->serializedArtworkBytes, encodedLength, MAX_SERIALIZED_ARTWORK_BYTES) || !artworkCacheCostForLengths(artworkData.length, encodedLength, &cost) || - addWouldExceedNSUInteger(selectedCacheBytes, cost, + addWouldExceedNSUInteger(state->selectedCacheBytes, cost, MAX_ARTWORK_CACHE_BYTES)) { continue; } - [selected addObject:stableID]; + [state->selectedStableIDs addObject:stableID]; record[@"includeArtwork"] = @YES; - serializedArtworkBytes += encodedLength; - selectedCacheBytes += cost; + state->serializedArtworkBytes += encodedLength; + state->selectedCacheBytes += cost; } - - return selected; } static void copyString(NSMutableDictionary *destination, NSString *outputKey, @@ -644,52 +752,94 @@ static void refreshSessions(void) { complete(NO); return; } - armWatchdog(); - dispatch_group_t enrichmentGroup = dispatch_group_create(); - for (NSDictionary *record in [topRecords copy]) { - NSMutableDictionary *entry = record[@"entry"]; - id playerPath = record[@"playerPath"]; - NSString *stableID = entry[@"stableId"]; - if (!playerPath || ![stableID isKindOfClass:[NSString class]]) { - continue; - } - dispatch_group_enter(enrichmentGroup); - getInfoForPlayer(playerPath, YES, queue, - ^(NSDictionary *information) { - if (!completed) { - if ([record isKindOfClass:[NSMutableDictionary class]] && - [information isKindOfClass:[NSDictionary class]]) { - ((NSMutableDictionary *)record)[@"information"] = - information; - } - } - dispatch_group_leave(enrichmentGroup); - }); - } - dispatch_group_notify(enrichmentGroup, queue, ^{ - NSSet *selectedArtworkStableIDs = - markSelectedArtworkForRecords(topRecords); - pruneArtworkCacheForSelectedRecords(topRecords, - selectedArtworkStableIDs); - for (NSDictionary *record in topRecords) { + NSArray *rankedRecords = [topRecords copy]; + __block ArtworkSelectionState selectionState = + makeArtworkSelectionState(); + __block void (^processEnrichmentBatch)(NSUInteger) = nil; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-retain-cycles" + processEnrichmentBatch = ^(NSUInteger startIndex) { + if (completed) { + processEnrichmentBatch = nil; + return; + } + if (startIndex >= rankedRecords.count) { + pruneArtworkCache(selectionState.selectedStableIDs); + complete(NO); + processEnrichmentBatch = nil; + return; + } + + armWatchdog(); + NSUInteger endIndex = MIN(rankedRecords.count, + startIndex + MAX_PHASE2_BATCH_RECORDS); + NSArray *batchRecords = + [rankedRecords subarrayWithRange:NSMakeRange( + startIndex, endIndex - startIndex)]; + dispatch_group_t enrichmentGroup = dispatch_group_create(); + + for (NSDictionary *record in batchRecords) { NSMutableDictionary *entry = record[@"entry"]; - NSDictionary *information = record[@"information"]; NSString *stableID = entry[@"stableId"]; - if (![entry isKindOfClass:[NSMutableDictionary class]] || - ![information isKindOfClass:[NSDictionary class]] || + id playerPath = record[@"playerPath"]; + if (!playerPath || ![stableID isKindOfClass:[NSString class]]) { continue; } - copyMetadata(entry, information, stableID, - [record[@"includeArtwork"] boolValue], - NO); - if ([record isKindOfClass:[NSMutableDictionary class]]) { - [(NSMutableDictionary *)record removeObjectForKey:@"includeArtwork"]; - [(NSMutableDictionary *)record removeObjectForKey:@"information"]; - } + dispatch_group_enter(enrichmentGroup); + getInfoForPlayer(playerPath, YES, queue, + ^(NSDictionary *information) { + if (!completed && [record isKindOfClass:[NSMutableDictionary class]]) { + NSDictionary *sanitized = + sanitizedInformationForRetention(information); + if (sanitized) { + ((NSMutableDictionary *)record)[@"information"] = + sanitized; + } + } + dispatch_group_leave(enrichmentGroup); + }); } - complete(NO); - }); + + dispatch_group_notify(enrichmentGroup, queue, ^{ + if (completed) { + processEnrichmentBatch = nil; + return; + } + if (retainedRawArtworkBytesForRecords(batchRecords) > + MAX_ARTWORK_CACHE_BYTES) { + fprintf(stderr, + "phase-2 retained artwork exceeded batch cap\n"); + restartAfterTimeout(); + } + markSelectedArtworkForRecordsWithState(batchRecords, + &selectionState); + prepareArtworkCacheForSelectedRecords(batchRecords, + selectionState.selectedStableIDs); + for (NSDictionary *record in batchRecords) { + NSMutableDictionary *entry = record[@"entry"]; + NSDictionary *information = record[@"information"]; + NSString *stableID = entry[@"stableId"]; + if (![entry isKindOfClass:[NSMutableDictionary class]] || + ![information isKindOfClass:[NSDictionary class]] || + ![stableID isKindOfClass:[NSString class]]) { + continue; + } + copyMetadata(entry, information, stableID, + [record[@"includeArtwork"] boolValue], + NO); + if ([record isKindOfClass:[NSMutableDictionary class]]) { + [(NSMutableDictionary *)record removeObjectForKey:@"includeArtwork"]; + [(NSMutableDictionary *)record removeObjectForKey:@"information"]; + } + } + processEnrichmentBatch(endIndex); + }); + }; +#pragma clang diagnostic pop + + processEnrichmentBatch(0); }; #pragma clang diagnostic push @@ -892,6 +1042,12 @@ - (NSTimeInterval)timeIntervalSince1970 { return [@{ @"entry" : entry, @"playerPath" : entry } mutableCopy]; } +static NSSet *markSelectedArtworkForRecords(NSArray *records) { + ArtworkSelectionState state = makeArtworkSelectionState(); + markSelectedArtworkForRecordsWithState(records, &state); + return state.selectedStableIDs; +} + static int runSmokeTests(void) { artworkCache = [NSMutableDictionary dictionary]; artworkCacheBytes = 0; @@ -970,6 +1126,103 @@ static int runSmokeTests(void) { return 1; } + NSMutableData *maxRetainedArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES]; + NSMutableData *oversizedRetainedArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES + 1]; + NSDictionary *keptInformation = sanitizedInformationForRetention(@{ + @"kMRMediaRemoteNowPlayingInfoArtworkData" : maxRetainedArtwork, + @"kMRMediaRemoteNowPlayingInfoTitle" : @"kept", + }); + NSDictionary *strippedInformation = sanitizedInformationForRetention(@{ + @"kMRMediaRemoteNowPlayingInfoArtworkData" : oversizedRetainedArtwork, + @"kMRMediaRemoteNowPlayingInfoTitle" : @"stripped", + }); + NSDictionary *nonDataInformation = sanitizedInformationForRetention(@{ + @"kMRMediaRemoteNowPlayingInfoArtworkData" : @"not-data", + @"kMRMediaRemoteNowPlayingInfoTitle" : @"non-data", + }); + NSMutableString *oversizedTitle = [NSMutableString string]; + for (NSUInteger i = 0; i < MAX_TEXT_FIELD_BYTES + 64; i++) { + [oversizedTitle appendString:@"t"]; + } + NSMutableData *irrelevantLargeObject = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES + 4096]; + NSDate *finiteTimestamp = [NSDate dateWithTimeIntervalSince1970:1234.0]; + NSDictionary *boundedKnownInformation = sanitizedInformationForRetention(@{ + @"kMRMediaRemoteNowPlayingInfoTitle" : oversizedTitle, + @"kMRMediaRemoteNowPlayingInfoArtist" : @"artist", + @"kMRMediaRemoteNowPlayingInfoAlbum" : @42, + @"kMRMediaRemoteNowPlayingInfoElapsedTime" : @12.5, + @"kMRMediaRemoteNowPlayingInfoDuration" : @(INFINITY), + @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @1.0, + @"kMRMediaRemoteNowPlayingInfoTimestamp" : finiteTimestamp, + @"kMRMediaRemoteNowPlayingInfoArtworkData" : maxRetainedArtwork, + @"irrelevantLargeObject" : irrelevantLargeObject, + }); + NSDictionary *invalidKnownInformation = sanitizedInformationForRetention(@{ + @"kMRMediaRemoteNowPlayingInfoElapsedTime" : @(NAN), + @"kMRMediaRemoteNowPlayingInfoDuration" : @(INFINITY), + @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @(-INFINITY), + @"kMRMediaRemoteNowPlayingInfoTimestamp" : [SmokeInfiniteDate new], + @"irrelevantLargeObject" : irrelevantLargeObject, + }); + NSDictionary *emptyKnownInformation = sanitizedInformationForRetention(@{ + @"irrelevantLargeObject" : irrelevantLargeObject, + }); + if (keptInformation == nil || + keptInformation[@"kMRMediaRemoteNowPlayingInfoArtworkData"] != + maxRetainedArtwork || + strippedInformation[@"kMRMediaRemoteNowPlayingInfoArtworkData"] || + nonDataInformation[@"kMRMediaRemoteNowPlayingInfoArtworkData"] || + ![strippedInformation[@"kMRMediaRemoteNowPlayingInfoTitle"] + isEqualToString:@"stripped"]) { + fprintf(stderr, "retained information artwork sanitization failed\n"); + return 1; + } + if ([boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoTitle"] + lengthOfBytesUsingEncoding:NSUTF8StringEncoding] != + MAX_TEXT_FIELD_BYTES || + ![boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoArtist"] + isEqualToString:@"artist"] || + boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoAlbum"] || + ![boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoElapsedTime"] + isEqual:@12.5] || + boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoDuration"] || + ![boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoPlaybackRate"] + isEqual:@1.0] || + boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoTimestamp"] != + finiteTimestamp || + boundedKnownInformation[@"kMRMediaRemoteNowPlayingInfoArtworkData"] != + maxRetainedArtwork || + boundedKnownInformation[@"irrelevantLargeObject"]) { + fprintf(stderr, "retained information whitelist/bounds failed\n"); + return 1; + } + if (invalidKnownInformation.count != 0 || + emptyKnownInformation.count != 0) { + fprintf(stderr, "retained information invalid value stripping failed\n"); + return 1; + } + + NSMutableArray *phase2BatchRecords = [NSMutableArray array]; + for (NSUInteger i = 0; i < MAX_PHASE2_BATCH_RECORDS; i++) { + NSMutableDictionary *entry = smokeCandidate( + [NSString stringWithFormat:@"phase2-%lu", (unsigned long)i], + YES, YES, (NSTimeInterval)i, YES, NO); + NSMutableDictionary *record = smokeRecord(entry); + record[@"information"] = @{ + @"kMRMediaRemoteNowPlayingInfoArtworkData" : maxRetainedArtwork, + }; + [phase2BatchRecords addObject:record]; + } + if (MAX_PHASE2_BATCH_RECORDS != 4 || + retainedRawArtworkBytesForRecords(phase2BatchRecords) != + MAX_ARTWORK_CACHE_BYTES) { + fprintf(stderr, "phase-2 retained artwork batch bound failed\n"); + return 1; + } + NSMutableData *winnerArtwork = [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES - 1]; NSMutableData *rejectedArtwork = @@ -1002,8 +1255,8 @@ static int runSmokeTests(void) { fprintf(stderr, "artwork selection did not preserve winner-first budget\n"); return 1; } - pruneArtworkCacheForSelectedRecords(selectionRecords, - selectedArtworkStableIDs); + prepareArtworkCacheForSelectedRecords(selectionRecords, + selectedArtworkStableIDs); for (NSMutableDictionary *record in selectionRecords) { NSMutableDictionary *entry = record[@"entry"]; NSString *stableID = entry[@"stableId"]; @@ -1023,6 +1276,83 @@ static int runSmokeTests(void) { return 1; } + NSMutableDictionary *batchedWinner = + smokeCandidate(@"batched-winner", YES, YES, 3000.0, YES, NO); + NSMutableDictionary *batchedRunnerUp = + smokeCandidate(@"batched-runner-up", YES, YES, 2000.0, YES, NO); + NSMutableDictionary *batchedThird = + smokeCandidate(@"batched-third", YES, YES, 1000.0, YES, NO); + NSMutableDictionary *batchedWinnerRecord = smokeRecord(batchedWinner); + NSMutableDictionary *batchedRunnerUpRecord = smokeRecord(batchedRunnerUp); + NSMutableDictionary *batchedThirdRecord = smokeRecord(batchedThird); + batchedWinnerRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : winnerArtwork }; + batchedRunnerUpRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : rejectedArtwork }; + batchedThirdRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : thirdArtwork }; + ArtworkSelectionState batchedState = makeArtworkSelectionState(); + markSelectedArtworkForRecordsWithState(@[ batchedWinnerRecord, + batchedRunnerUpRecord ], + &batchedState); + markSelectedArtworkForRecordsWithState(@[ batchedThirdRecord ], + &batchedState); + if (![batchedState.selectedStableIDs containsObject:@"batched-winner"] || + [batchedState.selectedStableIDs containsObject:@"batched-runner-up"] || + ![batchedState.selectedStableIDs containsObject:@"batched-third"] || + ![batchedWinnerRecord[@"includeArtwork"] boolValue] || + batchedRunnerUpRecord[@"includeArtwork"] || + ![batchedThirdRecord[@"includeArtwork"] boolValue]) { + fprintf(stderr, "batched artwork selection lost cumulative budget\n"); + return 1; + } + + artworkCache = [NSMutableDictionary dictionary]; + artworkCacheBytes = 0; + NSData *futureArtwork = [@"future-artwork" dataUsingEncoding:NSUTF8StringEncoding]; + NSString *futureEncoded = cachedArtworkData(@"future", futureArtwork); + if (!futureEncoded) { + fprintf(stderr, "future cache setup failed\n"); + return 1; + } + NSMutableDictionary *firstBatchEntry = + smokeCandidate(@"first-batch", YES, YES, 5000.0, YES, NO); + NSMutableDictionary *futureEntry = + smokeCandidate(@"future", YES, YES, 4000.0, YES, NO); + NSMutableDictionary *firstBatchRecord = smokeRecord(firstBatchEntry); + NSMutableDictionary *futureRecord = smokeRecord(futureEntry); + firstBatchRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : + [@"first-batch-artwork" dataUsingEncoding:NSUTF8StringEncoding] }; + futureRecord[@"information"] = + @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : futureArtwork }; + ArtworkSelectionState futureState = makeArtworkSelectionState(); + markSelectedArtworkForRecordsWithState(@[ firstBatchRecord ], &futureState); + prepareArtworkCacheForSelectedRecords(@[ firstBatchRecord ], + futureState.selectedStableIDs); + copyMetadata(firstBatchEntry, firstBatchRecord[@"information"], @"first-batch", + [firstBatchRecord[@"includeArtwork"] boolValue], NO); + if (artworkCache[@"future"] == nil || + cachedArtworkData(@"future", futureArtwork) != futureEncoded) { + fprintf(stderr, "future batch exact cache hit was pruned early\n"); + return 1; + } + markSelectedArtworkForRecordsWithState(@[ futureRecord ], &futureState); + prepareArtworkCacheForSelectedRecords(@[ futureRecord ], + futureState.selectedStableIDs); + copyMetadata(futureEntry, futureRecord[@"information"], @"future", + [futureRecord[@"includeArtwork"] boolValue], NO); + if (futureEntry[@"artworkData"] != futureEncoded || + artworkCache[@"future"][@"encoded"] != futureEncoded) { + fprintf(stderr, "future batch exact cache hit was re-encoded\n"); + return 1; + } + pruneArtworkCache(futureState.selectedStableIDs); + if (!artworkCache[@"first-batch"] || !artworkCache[@"future"]) { + fprintf(stderr, "final selected cache prune removed selected entries\n"); + return 1; + } + artworkCache = [NSMutableDictionary dictionary]; artworkCacheBytes = 0; NSMutableData *oldLowerArtwork = @@ -1046,8 +1376,8 @@ static int runSmokeTests(void) { @{ @"kMRMediaRemoteNowPlayingInfoArtworkData" : newLowerArtwork }; NSArray *changedRecords = @[ changedWinnerRecord, changedLowerRecord ]; selectedArtworkStableIDs = markSelectedArtworkForRecords(changedRecords); - pruneArtworkCacheForSelectedRecords(changedRecords, - selectedArtworkStableIDs); + prepareArtworkCacheForSelectedRecords(changedRecords, + selectedArtworkStableIDs); if (artworkCache[@"lower"]) { fprintf(stderr, "stale selected cache entry was not evicted\n"); return 1; @@ -1087,8 +1417,8 @@ static int runSmokeTests(void) { fprintf(stderr, "duplicate stable ID artwork selection was not record-specific\n"); return 1; } - pruneArtworkCacheForSelectedRecords(duplicateRecords, - selectedArtworkStableIDs); + prepareArtworkCacheForSelectedRecords(duplicateRecords, + selectedArtworkStableIDs); for (NSMutableDictionary *record in duplicateRecords) { NSMutableDictionary *entry = record[@"entry"]; NSString *stableID = entry[@"stableId"]; From d4e69c27590c6130705998a63129968f9c661f99 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 15:06:58 +0900 Subject: [PATCH 18/20] fix: stabilize bounded media enrichment --- src/media_sessions.m | 150 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 143 insertions(+), 7 deletions(-) diff --git a/src/media_sessions.m b/src/media_sessions.m index 67885c1..81769b2 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -35,10 +35,10 @@ - (instancetype)initWithPlayerPath:(id)playerPath; static const NSUInteger MAX_TEXT_FIELD_BYTES = 8 * 1024; static const NSUInteger MAX_RAW_ARTWORK_BYTES = 8 * 1024 * 1024; static const NSUInteger MAX_SERIALIZED_ARTWORK_BYTES = 16 * 1024 * 1024; -static const NSUInteger MAX_ARTWORK_CACHE_BYTES = 32 * 1024 * 1024; +static const NSUInteger MAX_ARTWORK_CACHE_BYTES = 24 * 1024 * 1024; static const NSUInteger MAX_ENRICHED_PLAYING_CANDIDATES = 8; static const NSUInteger MAX_PHASE1_BATCH_CLIENTS = 8; -static const NSUInteger MAX_PHASE2_BATCH_RECORDS = 4; +static const NSUInteger MAX_PHASE2_BATCH_RECORDS = 1; static id objectProperty(id object, NSString *selectorName) { SEL selector = NSSelectorFromString(selectorName); @@ -471,6 +471,17 @@ static void copyDateSeconds(NSMutableDictionary *destination, NSString *outputKe } } +static void clearMutableMediaMetadata(NSMutableDictionary *entry) { + [entry removeObjectForKey:@"title"]; + [entry removeObjectForKey:@"artist"]; + [entry removeObjectForKey:@"album"]; + [entry removeObjectForKey:@"elapsedTime"]; + [entry removeObjectForKey:@"duration"]; + [entry removeObjectForKey:@"playbackRate"]; + [entry removeObjectForKey:@"infoUpdateDate"]; + [entry removeObjectForKey:@"artworkData"]; +} + static void copyMetadata(NSMutableDictionary *entry, NSDictionary *information, NSString *stableID, BOOL includeArtwork, BOOL resolvePlayingFromRate) { @@ -616,6 +627,28 @@ static void insertTopRecord(NSMutableArray *topRecords, NSDictionary *record, ![entry[@"playingResolved"] boolValue]) { return; } + NSString *stableID = entry[@"stableId"]; + if (![stableID isKindOfClass:[NSString class]]) { + return; + } + for (NSUInteger index = 0; index < topRecords.count; index++) { + NSDictionary *existingRecord = topRecords[index]; + NSDictionary *existingEntry = existingRecord[@"entry"]; + NSString *existingStableID = existingEntry[@"stableId"]; + if (![existingEntry isKindOfClass:[NSDictionary class]] || + ![existingStableID isKindOfClass:[NSString class]] || + ![existingStableID isEqualToString:stableID]) { + continue; + } + if (compareRecordRank(record, existingRecord) == NSOrderedAscending) { + topRecords[index] = record; + [topRecords sortUsingComparator:^NSComparisonResult(NSDictionary *left, + NSDictionary *right) { + return compareRecordRank(left, right); + }]; + } + return; + } [topRecords addObject:record]; [topRecords sortUsingComparator:^NSComparisonResult(NSDictionary *left, NSDictionary *right) { @@ -808,7 +841,7 @@ static void refreshSessions(void) { return; } if (retainedRawArtworkBytesForRecords(batchRecords) > - MAX_ARTWORK_CACHE_BYTES) { + MAX_PHASE2_BATCH_RECORDS * MAX_RAW_ARTWORK_BYTES) { fprintf(stderr, "phase-2 retained artwork exceeded batch cap\n"); restartAfterTimeout(); @@ -821,8 +854,11 @@ static void refreshSessions(void) { NSMutableDictionary *entry = record[@"entry"]; NSDictionary *information = record[@"information"]; NSString *stableID = entry[@"stableId"]; - if (![entry isKindOfClass:[NSMutableDictionary class]] || - ![information isKindOfClass:[NSDictionary class]] || + if (![entry isKindOfClass:[NSMutableDictionary class]]) { + continue; + } + clearMutableMediaMetadata(entry); + if (![information isKindOfClass:[NSDictionary class]] || ![stableID isKindOfClass:[NSString class]]) { continue; } @@ -1110,6 +1146,17 @@ static int runSmokeTests(void) { return 1; } + pruneArtworkCache([NSSet set]); + NSMutableData *maxArtwork = + [NSMutableData dataWithLength:MAX_RAW_ARTWORK_BYTES]; + NSString *maxEncoded = cachedArtworkData(@"max", maxArtwork); + NSUInteger maxCost = artworkCacheCost(maxArtwork, maxEncoded); + if (!maxEncoded || !artworkCache[@"max"] || artworkCacheBytes != maxCost || + maxCost > MAX_ARTWORK_CACHE_BYTES) { + fprintf(stderr, "artwork cache max raw admission failed\n"); + return 1; + } + pruneArtworkCache([NSSet set]); if (artworkCache.count != 0 || artworkCacheBytes != 0) { fprintf(stderr, "artwork cache prune accounting failed\n"); @@ -1216,9 +1263,16 @@ static int runSmokeTests(void) { }; [phase2BatchRecords addObject:record]; } - if (MAX_PHASE2_BATCH_RECORDS != 4 || + if (MAX_ARTWORK_CACHE_BYTES + + MAX_PHASE2_BATCH_RECORDS * MAX_RAW_ARTWORK_BYTES != + 32 * 1024 * 1024) { + fprintf(stderr, "global retained artwork ceiling proof failed\n"); + return 1; + } + if (MAX_ARTWORK_CACHE_BYTES != 24 * 1024 * 1024 || + MAX_PHASE2_BATCH_RECORDS != 1 || retainedRawArtworkBytesForRecords(phase2BatchRecords) != - MAX_ARTWORK_CACHE_BYTES) { + MAX_PHASE2_BATCH_RECORDS * MAX_RAW_ARTWORK_BYTES) { fprintf(stderr, "phase-2 retained artwork batch bound failed\n"); return 1; } @@ -1431,6 +1485,35 @@ static int runSmokeTests(void) { return 1; } + NSMutableDictionary *phase1FallbackEntry = + smokeCandidate(@"phase1-fallback", YES, YES, 6000.0, YES, YES); + phase1FallbackEntry[@"bundleId"] = @"bundle"; + phase1FallbackEntry[@"title"] = @"phase-one-title"; + phase1FallbackEntry[@"artist"] = @"phase-one-artist"; + phase1FallbackEntry[@"album"] = @"phase-one-album"; + phase1FallbackEntry[@"elapsedTime"] = @1.0; + phase1FallbackEntry[@"duration"] = @2.0; + phase1FallbackEntry[@"playbackRate"] = @1.0; + phase1FallbackEntry[@"infoUpdateDate"] = @123.0; + phase1FallbackEntry[@"artworkData"] = @"phase-one-artwork"; + clearMutableMediaMetadata(phase1FallbackEntry); + copyMetadata(phase1FallbackEntry, @{}, @"phase1-fallback", YES, NO); + if (phase1FallbackEntry[@"title"] || phase1FallbackEntry[@"artist"] || + phase1FallbackEntry[@"album"] || phase1FallbackEntry[@"elapsedTime"] || + phase1FallbackEntry[@"duration"] || + phase1FallbackEntry[@"playbackRate"] || + phase1FallbackEntry[@"infoUpdateDate"] || + phase1FallbackEntry[@"artworkData"] || + ![phase1FallbackEntry[@"stableId"] isEqualToString:@"phase1-fallback"] || + ![phase1FallbackEntry[@"bundleId"] isEqualToString:@"bundle"] || + ![phase1FallbackEntry[@"playing"] boolValue] || + ![phase1FallbackEntry[@"playingResolved"] boolValue] || + ![phase1FallbackEntry[@"lastPlayingDate"] isEqual:@6000.0] || + ![phase1FallbackEntry[@"elected"] boolValue]) { + fprintf(stderr, "empty phase-2 metadata did not clear phase-1 fields\n"); + return 1; + } + NSMutableArray *manyCandidates = [NSMutableArray array]; for (NSUInteger i = 0; i < MAX_ENRICHED_PLAYING_CANDIDATES + 4; i++) { [manyCandidates addObject:smokeCandidate( @@ -1487,6 +1570,59 @@ static int runSmokeTests(void) { return 1; } + NSMutableDictionary *duplicateOlder = + smokeCandidate(@"duplicate-top", YES, YES, 1000.0, YES, NO); + NSMutableDictionary *duplicateNewer = + smokeCandidate(@"duplicate-top", YES, YES, 2000.0, YES, NO); + NSMutableDictionary *duplicateElected = + smokeCandidate(@"duplicate-top-election", YES, YES, 0.0, NO, YES); + NSMutableDictionary *duplicateUnelected = + smokeCandidate(@"duplicate-top-election", YES, YES, 0.0, NO, NO); + NSMutableDictionary *duplicateEqualFirst = + smokeCandidate(@"duplicate-top-equal", YES, YES, 3000.0, YES, NO); + NSMutableDictionary *duplicateEqualSecond = + smokeCandidate(@"duplicate-top-equal", YES, YES, 3000.0, YES, NO); + NSMutableArray *duplicateTopRecords = [NSMutableArray array]; + insertTopRecord(duplicateTopRecords, smokeRecord(duplicateOlder), + MAX_ENRICHED_PLAYING_CANDIDATES); + insertTopRecord(duplicateTopRecords, smokeRecord(duplicateNewer), + MAX_ENRICHED_PLAYING_CANDIDATES); + if (duplicateTopRecords.count != 1 || + [duplicateTopRecords.firstObject objectForKey:@"entry"] != duplicateNewer) { + fprintf(stderr, "duplicate top record did not keep newest date\n"); + return 1; + } + insertTopRecord(duplicateTopRecords, smokeRecord(duplicateUnelected), + MAX_ENRICHED_PLAYING_CANDIDATES); + insertTopRecord(duplicateTopRecords, smokeRecord(duplicateElected), + MAX_ENRICHED_PLAYING_CANDIDATES); + BOOL keptDuplicateElected = NO; + for (NSDictionary *record in duplicateTopRecords) { + if ([record objectForKey:@"entry"] == duplicateElected) { + keptDuplicateElected = YES; + } + } + if (duplicateTopRecords.count != 2 || !keptDuplicateElected) { + fprintf(stderr, "duplicate top record did not keep elected tie winner\n"); + return 1; + } + insertTopRecord(duplicateTopRecords, smokeRecord(duplicateEqualFirst), + MAX_ENRICHED_PLAYING_CANDIDATES); + insertTopRecord(duplicateTopRecords, smokeRecord(duplicateEqualSecond), + MAX_ENRICHED_PLAYING_CANDIDATES); + if (duplicateTopRecords.count != 3) { + fprintf(stderr, "duplicate top record count was not coalesced\n"); + return 1; + } + for (NSDictionary *record in duplicateTopRecords) { + NSDictionary *candidate = record[@"entry"]; + if ([candidate[@"stableId"] isEqualToString:@"duplicate-top-equal"] && + candidate != duplicateEqualFirst) { + fprintf(stderr, "equal duplicate top record did not keep first\n"); + return 1; + } + } + NSArray *publicTop = publicCandidates(candidateEntriesFromRecords(topRecords)); if (publicTop.count != MAX_ENRICHED_PLAYING_CANDIDATES) { From 7784252c8b23245b17d742cdd9e9531a9d71ec62 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 15:21:46 +0900 Subject: [PATCH 19/20] fix: preserve unresolved playback fallbacks --- src/media_sessions.m | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/media_sessions.m b/src/media_sessions.m index 81769b2..1600feb 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -499,8 +499,11 @@ static void copyMetadata(NSMutableDictionary *entry, NSDictionary *information, @"kMRMediaRemoteNowPlayingInfoPlaybackRate"); if (resolvePlayingFromRate) { NSNumber *rate = entry[@"playbackRate"]; - entry[@"playing"] = @(rate && [rate doubleValue] > 0.0); - entry[@"playingResolved"] = @YES; + if ([rate isKindOfClass:[NSNumber class]] && + isfinite([rate doubleValue])) { + entry[@"playing"] = @([rate doubleValue] > 0.0); + entry[@"playingResolved"] = @YES; + } } copyDateSeconds(entry, @"infoUpdateDate", information[@"kMRMediaRemoteNowPlayingInfoTimestamp"]); @@ -1078,6 +1081,16 @@ - (NSTimeInterval)timeIntervalSince1970 { return [@{ @"entry" : entry, @"playerPath" : entry } mutableCopy]; } +static BOOL smokeFallbackRateResolves(NSDictionary *information, + BOOL expectedPlaying, + BOOL expectedResolved) { + NSMutableDictionary *entry = + smokeCandidate(@"fallback-rate", NO, NO, 0.0, NO, NO); + copyMetadata(entry, information, @"fallback-rate", NO, YES); + return [entry[@"playing"] boolValue] == expectedPlaying && + [entry[@"playingResolved"] boolValue] == expectedResolved; +} + static NSSet *markSelectedArtworkForRecords(NSArray *records) { ArtworkSelectionState state = makeArtworkSelectionState(); markSelectedArtworkForRecordsWithState(records, &state); @@ -1630,6 +1643,26 @@ static int runSmokeTests(void) { return 1; } + if (!smokeFallbackRateResolves(@{}, NO, NO) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @(NAN) }, NO, + NO) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @(INFINITY) }, NO, + NO) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @0.0 }, NO, + YES) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @1.0 }, YES, + YES) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @-1.0 }, NO, + YES)) { + fprintf(stderr, "metadata fallback playbackRate resolution failed\n"); + return 1; + } + NSMutableDictionary *fallbackEntry = smokeCandidate(@"unsupported", NO, NO, 0.0, NO, NO); NSData *fallbackArtwork = [@"artwork" dataUsingEncoding:NSUTF8StringEncoding]; From 6bc00ef41409d5f10ea4b6c12ce0e6e85c6eab63 Mon Sep 17 00:00:00 2001 From: Kirari Sawamura Date: Wed, 12 Aug 2026 15:43:56 +0900 Subject: [PATCH 20/20] fix: treat reverse playback as active --- src/media_sessions.m | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/media_sessions.m b/src/media_sessions.m index 1600feb..38b6b14 100644 --- a/src/media_sessions.m +++ b/src/media_sessions.m @@ -501,7 +501,7 @@ static void copyMetadata(NSMutableDictionary *entry, NSDictionary *information, NSNumber *rate = entry[@"playbackRate"]; if ([rate isKindOfClass:[NSNumber class]] && isfinite([rate doubleValue])) { - entry[@"playing"] = @([rate doubleValue] > 0.0); + entry[@"playing"] = @([rate doubleValue] != 0.0); entry[@"playingResolved"] = @YES; } } @@ -1657,7 +1657,10 @@ static int runSmokeTests(void) { @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @1.0 }, YES, YES) || !smokeFallbackRateResolves( - @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @-1.0 }, NO, + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @-1.0 }, YES, + YES) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @-0.0 }, NO, YES)) { fprintf(stderr, "metadata fallback playbackRate resolution failed\n"); return 1;