diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eff3525 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +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: 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 + + - 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: | + 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: | + 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/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..eaaed80 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 + -> ad-hoc signed embedded per-player session helper + -> active-session arbitration from macOS-owned state -> local image decoding and representative-colour extraction -> artwork colour @@ -27,6 +27,10 @@ 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. 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 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 - macOS 14.2 or later for reactive mode; `--mode static` does not require a Process Tap @@ -34,13 +38,12 @@ MediaRemote is a private Apple framework. Its behaviour may change after a macOS - 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 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..93018ac 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,7 @@ 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"); if env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") { return; @@ -18,6 +19,48 @@ 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("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("/usr/bin/xcrun") + .args([ + "clang", + "-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("/usr/bin/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/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 4fbada3..9f4004e 100644 --- a/src/media.rs +++ b/src/media.rs @@ -1,7 +1,58 @@ -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 { + stable_id: String, + #[serde(default)] + bundle_id: Option, + playing: bool, + #[serde(default)] + playing_resolved: 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, +} + +#[cfg(any(target_os = "macos", test))] +fn select_playback_candidate(candidates: &[PlaybackCandidate]) -> Option<&PlaybackCandidate> { + candidates + .iter() + .filter(|candidate| candidate.playing && candidate.playing_resolved) + .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, @@ -17,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()); @@ -44,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; @@ -65,6 +133,7 @@ fn artwork_signature(image: &DynamicImage) -> u64 { hash } +#[cfg(any(target_os = "macos", test))] fn position_at_reception( elapsed: Option, duration: Option, @@ -83,8 +152,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 @@ -102,13 +170,20 @@ 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 { + if !artwork_available { + self.last_key = None; + return false; + } + + let Some(key) = key else { return false; }; if self.last_key.as_deref() == Some(key) { @@ -127,47 +202,269 @@ impl ArtworkDelivery { mod platform { use std::{ cell::RefCell, - path::Path, - process::{Command, Stdio}, - sync::{Arc, RwLock}, - time::{Instant, SystemTime}, + fs, + io::{BufRead, BufReader, Cursor}, + os::unix::fs::PermissionsExt, + process::{Child, Command, Stdio}, + sync::{mpsc, Arc, RwLock}, + 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 image::{ImageReader, Limits}; + use serde::Deserialize; + use tempfile::TempDir; + + use super::{ + artwork_signature, position_at_reception, select_playback_candidate, ArtworkDelivery, + PlaybackCandidate, TrackSnapshot, + }; + + const MEDIA_SESSIONS_HELPER: &[u8] = include_bytes!(concat!( + env!("OUT_DIR"), + "/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 { + candidates: Vec, + } + + #[derive(Debug, PartialEq, Eq)] + enum HelperControlMessage { + Ready, + Reset { reason: Option }, + Invalid, + } + + #[derive(Debug, PartialEq, Eq)] + enum HelperControlKind { + Ready, + Reset, + } - use super::{artwork_signature, position_at_reception, ArtworkDelivery, TrackSnapshot}; + #[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 { + 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, + } + } + Some(HelperControlKind::Reset) => { + match serde_json::from_str::(line) { + Ok(message) => HelperControlMessage::Reset { + reason: message.reset, + }, + _ => HelperControlMessage::Invalid, + } + } + 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)] struct ReceivedNowPlaying { - info: NowPlayingInfo, + snapshot: TrackSnapshot, + artwork: Option, position_at_received: Option, received_at: Instant, } + #[derive(Default)] + struct NowPlayingState { + latest: Option, + pending_reset: Option, + } + + 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_event(info: NowPlayingInfo, previous: Option<&Self>) -> Self { + fn from_candidate(candidate: Option<&PlaybackCandidate>, previous: Option<&Self>) -> Self { let received_at = Instant::now(); + let Some(candidate) = candidate else { + return Self::stopped_from_previous(previous, received_at); + }; + let artwork = Self::artwork_from_candidate(candidate, previous); + let mut snapshot = TrackSnapshot { + 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.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 + .info_update_date + .and_then(system_time_from_unix_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 { + 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 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)) + { + return Some(artwork.clone()); + } + let bytes = general_purpose::STANDARD.decode(encoded_artwork).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 { + 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 { + 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 { - info, + snapshot, + artwork: None, position_at_received, received_at, } @@ -175,18 +472,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,73 +491,282 @@ 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 && left.album == right.album } + enum StoppedDelivery { + Authoritative, + OneShotReset, + } + + fn publish_stopped( + state: &RwLock, + reason: &str, + delivery: StoppedDelivery, + ) -> std::io::Result<()> { + let mut state = state + .write() + .map_err(|_| std::io::Error::other("MediaRemote state lock is poisoned"))?; + 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( + state: &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(state, reason, StoppedDelivery::OneShotReset); + 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 state) = state.write() { + state.latest = Some(ReceivedNowPlaying::from_candidate( + selected, + state.latest.as_ref(), + )); + } + } + + 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( + state: &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(state, &mut reported_parse_error, &line); + } + Ok(None) if publish_stopped_on_eof => { + return publish_stopped( + state, + "stdout reached EOF", + StoppedDelivery::Authoritative, + ); + } + Ok(None) => return Ok(()), + Err(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(); + let _ = reader.join(); + } + pub struct MediaRemoteSource { - _remote: NowPlayingPerl, - _subscription: ListenerToken, - latest: Arc>>, + child: Child, + reader: Option>, + _temp_dir: TempDir, + state: Arc>, artwork_delivery: RefCell, } 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 helper_path = temp_dir.path().join("media_sessions"); + fs::write(&helper_path, MEDIA_SESSIONS_HELPER) + .context("could not extract MediaRemote session helper")?; + 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 remote = std::panic::catch_unwind(NowPlayingPerl::new).map_err(|_| { - anyhow::anyhow!("MediaRemote Perl adapter could not be initialized") - })?; - 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 mut child = Command::new(&helper_path) + .env_clear() + .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 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); + 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(())); + } + HelperControlMessage::Reset { .. } => { + let _ = ready_tx.send(Err(format!( + "MediaRemote session helper reset before ready: {line}" + ))); + return; + } + HelperControlMessage::Invalid => { + let _ = ready_tx.send(Err(format!( + "malformed pre-ready output from MediaRemote session helper: {line}" + ))); + return; + } + }, + Ok(None) => { + let _ = ready_tx.send(Err( + "MediaRemote session helper exited before ready".to_owned(), + )); + return; + } + Err(error) => { + let _ = ready_tx.send(Err(format!( + "could not read MediaRemote session helper ready message: {error}" + ))); + return; + } } + let _ = process_started_helper_stream(&reader_state, &mut reader, true); }); + 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) => { + Some("MediaRemote session helper reader stopped before ready".to_owned()) + } + }; + if let Some(error) = ready_error { + terminate_helper_before_ready(&mut child, reader); + bail!("{error}"); + } Ok(Self { - _remote: remote, - _subscription: subscription, - latest, + child, + reader: Some(reader), + _temp_dir: temp_dir, + state, artwork_delivery: RefCell::new(ArtworkDelivery::default()), }) } 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 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(); - } - Some(snapshot) + snapshot_from_state(&self.state, &self.artwork_delivery) } pub fn invalidate_artwork_delivery(&self) { @@ -270,21 +776,585 @@ 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(); + } + } + } + + #[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 { + 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, + } + } + + 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) + } + } + + 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 = + 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 state = RwLock::new(NowPlayingState { + latest: Some(ReceivedNowPlaying::from_candidate( + Some(&candidate("Song", 30.0, 0.0)), + None, + )), + pending_reset: None, + }); + + publish_stopped(&state, "test termination", StoppedDelivery::Authoritative) + .expect("state update succeeds"); + + 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] + 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()); + } + + #[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 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!( + parse_helper_control_line(r#"{"ready":true}"#), + HelperControlMessage::Ready + ); + } + + #[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!( + parse_helper_control_line(r#"{"candidates":[]}"#), + HelperControlMessage::Invalid + ); + } + + #[test] + fn helper_control_line_rejects_malformed_before_ready() { + assert_eq!( + parse_helper_control_line("not json"), + 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) + ); + } + + #[test] + fn reset_control_line_after_ready_clears_cached_playing_state() { + 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( + &state, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + + assert!(!reported_parse_error); + 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 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( + &state, + &mut reported_parse_error, + r#"{"reset":"timeout"}"#, + ); + process_started_helper_line(&state, &mut reported_parse_error, r#"{"ready":true}"#); + + assert!(!reported_parse_error); + let state = state.read().expect("state lock is readable"); + assert_eq!( + state + .latest + .as_ref() + .expect("state remains published") + .snapshot + .is_playing, + Some(false) + ); + } + + #[test] + fn post_ready_stream_accepts_reset_ready_and_payload_without_eof() { + 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(&state, &mut stream, false) + .expect("stream lines are processed"); + + 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]); + + 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); } } } @@ -331,6 +1401,191 @@ 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, + playing_resolved: true, + 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 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 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 = [ + candidate("spotify", false, Some(100.0), false), + candidate("music", false, Some(200.0), true), + ]; + + 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; + stale.playback_rate = Some(1.0); + 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 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 = [ + 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 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( + r#"{ + "stableId":"com.apple.Music:default", + "bundleId":"com.apple.Music", + "playing":true, + "playingResolved":true, + "lastPlayingDate":123.5, + "elected":true, + "title":"Song" + }"#, + ) + .expect("valid helper 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!( @@ -367,6 +1622,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}; @@ -390,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!( diff --git a/src/media_sessions.m b/src/media_sessions.m new file mode 100644 index 0000000..38b6b14 --- /dev/null +++ b/src/media_sessions.m @@ -0,0 +1,1753 @@ +#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)); +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 NSUInteger artworkCacheBytes = 0; +static char **helperArgv = NULL; + +// MediaRemote is local but untrusted input. These caps cover ordinary album art +// 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 = 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 = 1; + +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)((int (*)(id, SEL))objc_msgSend)(object, selector); +} + +static NSString *boundedString(NSString *value) { + if (!value) { + return nil; + } + + NSUInteger valueLength = value.length; + if (valueLength == 0) { + return value; + } + + 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 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; + } + + NSUInteger encodedBytes = + [encoded lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + if (addWouldExceedNSUInteger(data.length, encodedBytes, NSUIntegerMax)) { + return NSUIntegerMax; + } + 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) { + 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"]; + if ([cachedData isKindOfClass:[NSData class]] && + [cachedEncoded isKindOfClass:[NSString class]] && + [cachedData isEqualToData:artwork]) { + return cachedEncoded; + } + + removeCachedArtwork(stableID); + 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 nil; + } + + 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]) { + removeCachedArtwork(stableID); + } + } +} + +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 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; + } + 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"]; + 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; + } + } + } + + evictUnselectedArtworkUntilAvailable(stableIDs, requiredBytes); +} + +static void markSelectedArtworkForRecordsWithState(NSArray *records, + ArtworkSelectionState *state) { + 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]] || + [state->seenStableIDs containsObject:stableID] || + ![entry[@"playing"] boolValue] || + ![entry[@"playingResolved"] boolValue]) { + continue; + } + [state->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(state->serializedArtworkBytes, encodedLength, + MAX_SERIALIZED_ARTWORK_BYTES) || + !artworkCacheCostForLengths(artworkData.length, encodedLength, + &cost) || + addWouldExceedNSUInteger(state->selectedCacheBytes, cost, + MAX_ARTWORK_CACHE_BYTES)) { + continue; + } + + [state->selectedStableIDs addObject:stableID]; + record[@"includeArtwork"] = @YES; + state->serializedArtworkBytes += encodedLength; + state->selectedCacheBytes += cost; + } +} + +static void copyString(NSMutableDictionary *destination, NSString *outputKey, + NSDictionary *source, NSString *sourceKey) { + id value = source[sourceKey]; + if ([value isKindOfClass:[NSString class]]) { + destination[outputKey] = boundedString((NSString *)value); + } +} + +static void copyNumber(NSMutableDictionary *destination, NSString *outputKey, + NSDictionary *source, NSString *sourceKey) { + id value = source[sourceKey]; + if ([value isKindOfClass:[NSNumber class]] && + isfinite([(NSNumber *)value doubleValue])) { + destination[outputKey] = value; + } +} + +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 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) { + 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"]; + if ([rate isKindOfClass:[NSNumber class]] && + isfinite([rate doubleValue])) { + entry[@"playing"] = @([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]; + NSUInteger serializedArtworkBytes = 0; + + for (NSDictionary *candidate in candidates) { + if (![candidate isKindOfClass:[NSDictionary class]]) { + continue; + } + + 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"]; + } + [publicCandidate removeObjectForKey:@"lastPlayingDateError"]; + [publicCandidates addObject:publicCandidate]; + } + + 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]; +} + +#if defined(MEDIA_SESSIONS_SMOKE) +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; +} +#endif + +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; + } + 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) { + 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 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; + 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 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; + } + refreshInFlight = YES; + + dispatch_queue_t queue = dispatch_get_main_queue(); + NSMutableArray *topRecords = [NSMutableArray array]; + Class playerPathClass = NSClassFromString(@"MRPlayerPath"); + 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); + } + }); + }; + + complete = ^(BOOL timedOut) { + if (completed) { + return; + } + completed = YES; + watchdogToken = nextWatchdogToken(watchdogToken); + if (timedOut) { + restartAfterTimeout(); + } else { + 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; + scheduleRefresh(); + }; + + armWatchdog(); + getNowPlayingClients(queue, ^(id clientsValue) { + if (completed) { + return; + } + NSArray *clients = [clientsValue isKindOfClass:[NSArray class]] + ? (NSArray *)clientsValue + : @[]; + __block void (^enrichTopRecords)(void); + __block void (^processBatch)(NSUInteger) = nil; + + enrichTopRecords = ^{ + if (completed || topRecords.count == 0) { + complete(NO); + return; + } + 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"]; + NSString *stableID = entry[@"stableId"]; + id playerPath = record[@"playerPath"]; + if (!playerPath || + ![stableID isKindOfClass:[NSString class]]) { + continue; + } + 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); + }); + } + + dispatch_group_notify(enrichmentGroup, queue, ^{ + if (completed) { + processEnrichmentBatch = nil; + return; + } + if (retainedRawArtworkBytesForRecords(batchRecords) > + MAX_PHASE2_BATCH_RECORDS * MAX_RAW_ARTWORK_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]]) { + continue; + } + clearMutableMediaMetadata(entry); + if (![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 +#pragma clang diagnostic ignored "-Warc-retain-cycles" + processBatch = ^(NSUInteger startIndex) { + if (completed) { + processBatch = nil; + return; + } + if (startIndex >= clients.count) { + enrichTopRecords(); + processBatch = nil; + return; + } + + armWatchdog(); + 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(batchGroup); + return; + } + if (!player || !playerPathClass) { + dispatch_group_leave(batchGroup); + return; + } + + MRPlayerPath *playerPath = + [[(id)playerPathClass alloc] initWithOrigin:nil + client:client + player:player]; + if (!playerPath) { + dispatch_group_leave(batchGroup); + return; + } + + NSString *bundleID = boundedString( + stringProperty(client, @"parentApplicationBundleIdentifier") + ?: stringProperty(client, @"bundleIdentifier") + ?: @"unknown"); + NSString *playerID = + boundedString(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, + @"playingResolved" : @NO, + @"lastPlayingDateError" : @NO, + @"elected" : @([playerPath isEqual:electedPath]), + } mutableCopy]; + MRNowPlayingRequest *request = + requestClass + ? [[(id)requestClass alloc] initWithPlayerPath:playerPath] + : nil; + SEL isPlayingSelector = NSSelectorFromString( + @"requestIsPlayingOnQueue:completion:"); + BOOL supportsScopedPlaying = + request && [request respondsToSelector:isPlayingSelector]; + NSMutableDictionary *record = [@{ + @"entry" : entry, + @"playerPath" : playerPath, + @"supportsScopedPlaying" : @(supportsScopedPlaying), + } mutableCopy]; + [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(batchGroup); + }); + } + + if (request) { + SEL lastPlayingSelector = NSSelectorFromString( + @"requestLastPlayingDateOnQueue:completion:"); + 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) { + if (error) { + entry[@"lastPlayingDateError"] = @YES; + } else { + copyDateSeconds(entry, @"lastPlayingDate", + date); + } + } + dispatch_group_leave(batchGroup); + }); + }); + } + } + + dispatch_group_leave(batchGroup); + }); + } + + 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); + }); +} + +static void scheduleRefresh(void) { + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + refreshSessions(); + }); +} + +#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 NSMutableDictionary *smokeRecord(NSMutableDictionary *entry) { + 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); + return state.selectedStableIDs; +} + +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"]; + } + 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]); + 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"); + 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 *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_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_PHASE2_BATCH_RECORDS * MAX_RAW_ARTWORK_BYTES) { + fprintf(stderr, "phase-2 retained artwork batch bound 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; + } + prepareArtworkCacheForSelectedRecords(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; + } + + 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 = + [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); + prepareArtworkCacheForSelectedRecords(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; + } + prepareArtworkCacheForSelectedRecords(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; + } + + 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( + [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; + } + + 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) { + fprintf(stderr, "public candidate count exceeded top-K cap\n"); + 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 }, YES, + YES) || + !smokeFallbackRateResolves( + @{ @"kMRMediaRemoteNowPlayingInfoPlaybackRate" : @-0.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]; + 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; + @autoreleasepool { + NSBundle *framework = [NSBundle bundleWithPath: + @"/System/Library/PrivateFrameworks/MediaRemote.framework"]; + if (![framework load]) { + fprintf(stderr, "could not load MediaRemote.framework\n"); + return 1; + } + + 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 1; + } + requestQueue = dispatch_queue_create( + "com.local.codex-micro-chroma.media-sessions", DISPATCH_QUEUE_SERIAL); + artworkCache = [NSMutableDictionary dictionary]; + + Class requestClass = NSClassFromString(@"MRNowPlayingRequest"); + 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, + "MediaRemote lastPlayingDate requests are unavailable; " + "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(), ^{ + refreshSessions(); + }); + CFRunLoopRun(); + } + return 0; +} +#endif