Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
This commit is contained in:
+553
@@ -0,0 +1,553 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_gpu_rebuild"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_mac_app_audio"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"fluxer_screen_frame_bus",
|
||||
"libc",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"objc2",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-media",
|
||||
"objc2-foundation",
|
||||
"objc2-screen-capture-kit",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_screen_frame_bus"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"fluxer_gpu_rebuild",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad513ff22558f1830b595ea6eb4091da48145d09a222ce157e781896f78be0b9"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89b3f766e04667e6da0e181e2da4f85475d5a6513b7cf6a80bea184e224a5b42"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "5.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d5af30503edf933ce7377cf6d4c877a62b0f1107ea05585f1b5e430e88d5baf"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[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-av-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"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",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-media"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-video",
|
||||
]
|
||||
|
||||
[[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-screen-capture-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-av-foundation",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-media",
|
||||
"objc2-foundation",
|
||||
"objc2-uniform-type-identifiers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-uniform-type-identifiers"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "fluxer_mac_app_audio"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8", "async"]}
|
||||
napi-derive = "3.5.6"
|
||||
parking_lot = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies.fluxer_screen_frame_bus]
|
||||
path = "../screen-frame-bus"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2.186"
|
||||
objc2 = "0.6"
|
||||
objc2-foundation = {version = "0.3", features = ["NSString", "NSArray", "NSDictionary", "NSError", "NSValue", "NSBundle", "NSProcessInfo"]}
|
||||
objc2-screen-capture-kit = {version = "0.3", features = ["SCStream", "SCShareableContent", "objc2-core-graphics", "objc2-core-media", "objc2-core-foundation", "block2", "dispatch2", "libc"]}
|
||||
objc2-core-audio = {version = "0.3", features = ["AudioHardware", "AudioHardwareDeprecated", "HostTime", "objc2-core-audio-types", "objc2-foundation"]}
|
||||
objc2-core-audio-types = "0.3"
|
||||
objc2-core-media = "0.3"
|
||||
objc2-core-foundation = {version = "0.3", features = ["CFArray", "CFDictionary", "CFNumber", "CFString", "CFBase"]}
|
||||
objc2-core-graphics = {version = "0.3.2", default-features = false, features = ["std", "CGWindow"]}
|
||||
block2 = "0.6"
|
||||
dispatch2 = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
println!("cargo:rustc-link-lib=framework=AppKit");
|
||||
println!("cargo:rustc-link-lib=framework=AVFoundation");
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EventEmitter} from 'node:events';
|
||||
|
||||
export interface MacAppAudioBackendInfo {
|
||||
backend: string;
|
||||
supported: boolean;
|
||||
reason: string;
|
||||
minMacosVersion: string;
|
||||
minMacosVersionCoreaudio: string;
|
||||
detectedMacosVersion?: string;
|
||||
sckAvailable: boolean;
|
||||
coreaudioAvailable: boolean;
|
||||
}
|
||||
|
||||
export declare function getBackendInfo(): MacAppAudioBackendInfo;
|
||||
|
||||
export interface MacBackendAvailability {
|
||||
sck?: {
|
||||
supported: boolean;
|
||||
macosVersion?: string;
|
||||
};
|
||||
coreaudio?: {
|
||||
supported: boolean;
|
||||
};
|
||||
screenPermission?: string;
|
||||
audioPermission?: string;
|
||||
}
|
||||
|
||||
export interface MacApplicationDescriptor {
|
||||
pid: number;
|
||||
bundleId?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProcessLoopbackOptions {
|
||||
excludeSelf?: boolean;
|
||||
includeProcessTree?: boolean;
|
||||
backend?: 'sck' | 'coreaudio' | 'auto';
|
||||
macBackend?: 'sck' | 'coreaudio' | 'auto';
|
||||
captureScope?: 'process' | 'system';
|
||||
macCaptureScope?: 'process' | 'system';
|
||||
scope?: 'process' | 'system';
|
||||
}
|
||||
|
||||
export interface AudioFrame {
|
||||
samples: Float32Array;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
timestampUs: number;
|
||||
}
|
||||
|
||||
export declare const loadError: Error | null;
|
||||
|
||||
export declare function __setBindingForTests(binding: unknown): void;
|
||||
|
||||
export declare function pidFromWindowId(windowId: number): number;
|
||||
|
||||
export declare function listAudibleApplications(): Promise<Array<MacApplicationDescriptor>>;
|
||||
|
||||
export declare function getBackendAvailability(): Promise<MacBackendAvailability>;
|
||||
|
||||
export declare interface ProcessLoopback {
|
||||
on(event: 'frame', listener: (frame: AudioFrame) => void): this;
|
||||
on(event: 'error', listener: (err: Error) => void): this;
|
||||
on(event: 'closed', listener: () => void): this;
|
||||
on(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
|
||||
off(event: 'frame', listener: (frame: AudioFrame) => void): this;
|
||||
off(event: 'error', listener: (err: Error) => void): this;
|
||||
off(event: 'closed', listener: () => void): this;
|
||||
off(event: string | symbol, listener: (...args: Array<unknown>) => void): this;
|
||||
emit(event: 'frame', frame: AudioFrame): boolean;
|
||||
emit(event: 'error', err: Error): boolean;
|
||||
emit(event: 'closed'): boolean;
|
||||
}
|
||||
|
||||
export declare class ProcessLoopback extends EventEmitter {
|
||||
constructor(pid: number, options?: ProcessLoopbackOptions);
|
||||
|
||||
start(): Promise<void>;
|
||||
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {EventEmitter} = require('node:events');
|
||||
const {existsSync} = require('node:fs');
|
||||
const {join, sep} = require('node:path');
|
||||
const {performance} = require('node:perf_hooks');
|
||||
const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/mac-app-audio';
|
||||
|
||||
function resolveNativeRoot() {
|
||||
const asarSegment = `${sep}app.asar${sep}`;
|
||||
if (!__dirname.includes(asarSegment)) return __dirname;
|
||||
const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`);
|
||||
return existsSync(unpackedDir) ? unpackedDir : __dirname;
|
||||
}
|
||||
|
||||
function nativeFileName() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error(`@fluxer/mac-app-audio is only supported on macOS, got ${process.platform}`);
|
||||
}
|
||||
switch (process.arch) {
|
||||
case 'x64':
|
||||
return 'mac-app-audio.darwin-x64.node';
|
||||
case 'arm64':
|
||||
return 'mac-app-audio.darwin-arm64.node';
|
||||
default:
|
||||
throw new Error(`Unsupported macOS architecture: ${process.arch}`);
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const nativePath = join(nativeRoot, nativeFileName());
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
probe: false,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
} catch (error) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: 'native loader threw before binding load completed',
|
||||
cause: error,
|
||||
});
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
function pidFromWindowId(windowId) {
|
||||
if (!binding) return 0;
|
||||
return binding.pidFromWindowId(windowId);
|
||||
}
|
||||
|
||||
function listAudibleApplications() {
|
||||
if (!binding) return Promise.resolve([]);
|
||||
return binding.listAudibleApplications();
|
||||
}
|
||||
|
||||
function getBackendInfo() {
|
||||
if (!binding) {
|
||||
return {
|
||||
backend: 'mac-app-audio',
|
||||
supported: false,
|
||||
reason:
|
||||
process.platform === 'darwin'
|
||||
? `@fluxer/mac-app-audio native binary unavailable: ${loadError?.message ?? 'unknown reason'}`
|
||||
: `@fluxer/mac-app-audio is only supported on macOS, got ${process.platform}`,
|
||||
minMacosVersion: '12.3',
|
||||
minMacosVersionCoreaudio: '14.2',
|
||||
detectedMacosVersion: undefined,
|
||||
sckAvailable: false,
|
||||
coreaudioAvailable: false,
|
||||
};
|
||||
}
|
||||
return binding.getBackendInfo();
|
||||
}
|
||||
|
||||
function getBackendAvailability() {
|
||||
if (!binding) {
|
||||
return Promise.resolve({
|
||||
sck: {supported: false},
|
||||
coreaudio: {supported: false},
|
||||
screenPermission: 'not-determined',
|
||||
audioPermission: 'not-determined',
|
||||
});
|
||||
}
|
||||
return binding.getBackendAvailability();
|
||||
}
|
||||
|
||||
function __setBindingForTests(nextBinding) {
|
||||
binding = nextBinding;
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
class ProcessLoopback extends EventEmitter {
|
||||
constructor(pid, options = {}) {
|
||||
super();
|
||||
if (!binding) {
|
||||
throw loadError || new Error('@fluxer/mac-app-audio binding unavailable');
|
||||
}
|
||||
this.pid = pid;
|
||||
void options.excludeSelf;
|
||||
this.excludeSelf = true;
|
||||
this.includeProcessTree = options.includeProcessTree ?? true;
|
||||
this.backend = options.backend ?? options.macBackend ?? 'auto';
|
||||
this.captureScope = options.captureScope ?? options.macCaptureScope ?? options.scope ?? 'process';
|
||||
this.started = false;
|
||||
this.stopped = false;
|
||||
this.closedEmitted = false;
|
||||
this.nextTimestampUs = Math.round(performance.now() * 1000);
|
||||
this.native = new binding.ProcessLoopback();
|
||||
this.native.setFrameCallback((samples) => {
|
||||
if (this.stopped) return;
|
||||
const copied = new Float32Array(samples);
|
||||
const numFrames = copied.length / 2;
|
||||
const frame = {
|
||||
samples: copied,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
timestampUs: this.nextTimestampUs,
|
||||
};
|
||||
this.nextTimestampUs += Math.round((numFrames / frame.sampleRate) * 1_000_000);
|
||||
this.emit('frame', frame);
|
||||
});
|
||||
this.native.setLifecycleCallback((type, message) => {
|
||||
if (type === 'error') {
|
||||
this.emit('error', new Error(message || 'macOS app audio stream stopped'));
|
||||
return;
|
||||
}
|
||||
if (type === 'closed') {
|
||||
if (this.stopped) {
|
||||
this.emitClosedOnce();
|
||||
return;
|
||||
}
|
||||
this.stopped = true;
|
||||
Promise.resolve()
|
||||
.then(() => this.native.stop())
|
||||
.catch(() => {});
|
||||
this.emitClosedOnce();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emitClosedOnce() {
|
||||
if (this.closedEmitted) return;
|
||||
this.closedEmitted = true;
|
||||
this.emit('closed');
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.started || this.stopped) return;
|
||||
this.started = true;
|
||||
try {
|
||||
await this.native.start(this.pid, this.excludeSelf, this.includeProcessTree, this.backend, this.captureScope);
|
||||
} catch (error) {
|
||||
this.stopped = true;
|
||||
this.emit('error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setScreenAudioSink(handle) {
|
||||
if (typeof this.native.setScreenAudioSink !== 'function') return false;
|
||||
try {
|
||||
return this.native.setScreenAudioSink(handle) !== false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
clearScreenAudioSink() {
|
||||
if (typeof this.native.clearScreenAudioSink === 'function') {
|
||||
this.native.clearScreenAudioSink();
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this.clearScreenAudioSink();
|
||||
try {
|
||||
await this.native.stop();
|
||||
} finally {
|
||||
this.emitClosedOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ProcessLoopback,
|
||||
listAudibleApplications,
|
||||
getBackendAvailability,
|
||||
getBackendInfo,
|
||||
pidFromWindowId,
|
||||
loadError,
|
||||
__setBindingForTests,
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createRequire} from 'node:module';
|
||||
import {afterEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const macAppAudio = require('./index.js');
|
||||
|
||||
function makeFakeBinding() {
|
||||
const calls = [];
|
||||
const natives = [];
|
||||
class FakeNative {
|
||||
constructor() {
|
||||
this.frameCallback = undefined;
|
||||
this.lifecycleCallback = undefined;
|
||||
this.stopCount = 0;
|
||||
natives.push(this);
|
||||
}
|
||||
|
||||
setFrameCallback(callback) {
|
||||
this.frameCallback = callback;
|
||||
}
|
||||
|
||||
setLifecycleCallback(callback) {
|
||||
this.lifecycleCallback = callback;
|
||||
}
|
||||
|
||||
async start(pid, excludeSelf, includeProcessTree, backend, captureScope) {
|
||||
calls.push({pid, excludeSelf, includeProcessTree, backend, captureScope});
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.stopCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
binding: {
|
||||
ProcessLoopback: FakeNative,
|
||||
listAudibleApplications: async () => [],
|
||||
getBackendAvailability: async () => ({}),
|
||||
pidFromWindowId: () => 0,
|
||||
},
|
||||
calls,
|
||||
natives,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
macAppAudio.__setBindingForTests(null);
|
||||
});
|
||||
|
||||
describe('mac-app-audio loader wrapper', () => {
|
||||
test('forces excludeSelf=true and preserves includeProcessTree=false', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
macAppAudio.__setBindingForTests(binding);
|
||||
const loopback = new macAppAudio.ProcessLoopback(1234, {
|
||||
excludeSelf: false,
|
||||
includeProcessTree: false,
|
||||
});
|
||||
loopback.on('error', () => {});
|
||||
await loopback.start();
|
||||
expect(calls).toEqual([
|
||||
{pid: 1234, excludeSelf: true, includeProcessTree: false, backend: 'auto', captureScope: 'process'},
|
||||
]);
|
||||
});
|
||||
test('forwards explicit backend preference to native binding', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
macAppAudio.__setBindingForTests(binding);
|
||||
const loopback = new macAppAudio.ProcessLoopback(1234, {
|
||||
backend: 'coreaudio',
|
||||
});
|
||||
loopback.on('error', () => {});
|
||||
await loopback.start();
|
||||
expect(calls).toEqual([
|
||||
{pid: 1234, excludeSelf: true, includeProcessTree: true, backend: 'coreaudio', captureScope: 'process'},
|
||||
]);
|
||||
});
|
||||
test('forwards explicit capture scope to native binding', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
macAppAudio.__setBindingForTests(binding);
|
||||
const loopback = new macAppAudio.ProcessLoopback(1234, {
|
||||
captureScope: 'system',
|
||||
backend: 'coreaudio',
|
||||
});
|
||||
loopback.on('error', () => {});
|
||||
await loopback.start();
|
||||
expect(calls).toEqual([
|
||||
{pid: 1234, excludeSelf: true, includeProcessTree: true, backend: 'coreaudio', captureScope: 'system'},
|
||||
]);
|
||||
});
|
||||
test('forwards ScreenCaptureKit system capture requests to native binding', async () => {
|
||||
const {binding, calls} = makeFakeBinding();
|
||||
macAppAudio.__setBindingForTests(binding);
|
||||
const loopback = new macAppAudio.ProcessLoopback(1234, {
|
||||
captureScope: 'system',
|
||||
backend: 'sck',
|
||||
});
|
||||
loopback.on('error', () => {});
|
||||
await loopback.start();
|
||||
expect(calls).toEqual([
|
||||
{pid: 1234, excludeSelf: true, includeProcessTree: true, backend: 'sck', captureScope: 'system'},
|
||||
]);
|
||||
});
|
||||
test('copies native frame samples before emitting', () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
macAppAudio.__setBindingForTests(binding);
|
||||
const loopback = new macAppAudio.ProcessLoopback(1234);
|
||||
const frames = [];
|
||||
loopback.on('frame', (frame) => frames.push(frame));
|
||||
const nativeSamples = new Float32Array([0.1, 0.2, 0.3, 0.4]);
|
||||
natives[0].frameCallback(nativeSamples);
|
||||
nativeSamples.fill(9);
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(Array.from(frames[0].samples)).toEqual(Array.from(new Float32Array([0.1, 0.2, 0.3, 0.4])));
|
||||
expect(frames[0].channels).toBe(2);
|
||||
expect(frames[0].sampleRate).toBe(48000);
|
||||
});
|
||||
test('emits closed once when native lifecycle closes and stop is called later', async () => {
|
||||
const {binding, natives} = makeFakeBinding();
|
||||
macAppAudio.__setBindingForTests(binding);
|
||||
const loopback = new macAppAudio.ProcessLoopback(1234);
|
||||
let closed = 0;
|
||||
loopback.on('closed', () => {
|
||||
closed += 1;
|
||||
});
|
||||
natives[0].lifecycleCallback('closed', '');
|
||||
await Promise.resolve();
|
||||
await loopback.stop();
|
||||
expect(closed).toBe(1);
|
||||
expect(natives[0].stopCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {existsSync, readdirSync, readFileSync, statSync} = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const {basename} = require('node:path');
|
||||
const {spawnSync} = require('node:child_process');
|
||||
const NATIVE_LOAD_ERROR_MARKER = Symbol.for('fluxer.nativeLoadError');
|
||||
const MAX_TEXT_LENGTH = 6000;
|
||||
const MAX_DIRECTORY_ENTRIES = 80;
|
||||
|
||||
function trimText(value, limit = MAX_TEXT_LENGTH) {
|
||||
const text = Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n...<truncated>` : trimmed;
|
||||
}
|
||||
|
||||
function errorDiagnostic(error) {
|
||||
if (!error) return null;
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name || 'Error',
|
||||
message: error.message,
|
||||
code: error.code || null,
|
||||
stack: trimText(error.stack || error.message),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: typeof error,
|
||||
message: trimText(String(error)),
|
||||
code: null,
|
||||
stack: null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const lines = [];
|
||||
if (diagnostic.code) lines.push(`code=${diagnostic.code}`);
|
||||
if (diagnostic.stack) lines.push(diagnostic.stack);
|
||||
else if (diagnostic.message) lines.push(diagnostic.message);
|
||||
return trimText(lines.join('\n'));
|
||||
}
|
||||
|
||||
function fileDiagnostic(filePath) {
|
||||
if (!filePath) return {path: null, exists: false, error: 'not resolved'};
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
return {
|
||||
path: filePath,
|
||||
exists: true,
|
||||
size: stat.size,
|
||||
mode: `0${(stat.mode & 0o777).toString(8)}`,
|
||||
mtime: stat.mtime.toISOString(),
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
return {path: filePath, exists: false, error: reason};
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return 'not resolved';
|
||||
if (!diagnostic.exists) return `exists=false, statError=${diagnostic.error || '<unknown>'}`;
|
||||
return [
|
||||
`exists=true`,
|
||||
`size=${diagnostic.size}`,
|
||||
`mode=${diagnostic.mode}`,
|
||||
`mtime=${diagnostic.mtime}`,
|
||||
`isFile=${diagnostic.isFile}`,
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function directoryDiagnostic(dirPath) {
|
||||
if (!dirPath) return {path: null, ok: false, error: 'not resolved', entries: [], total: 0, omitted: 0};
|
||||
try {
|
||||
const entries = readdirSync(dirPath, {withFileTypes: true}).map((entry) => ({
|
||||
name: entry.name,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
||||
}));
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const visible = entries.slice(0, MAX_DIRECTORY_ENTRIES);
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: true,
|
||||
entries: visible,
|
||||
total: entries.length,
|
||||
omitted: Math.max(0, entries.length - visible.length),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
path: dirPath,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
entries: [],
|
||||
total: 0,
|
||||
omitted: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatDirectoryDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return '<unavailable>';
|
||||
if (!diagnostic.ok) return `directory listing failed: ${diagnostic.error || '<unknown>'}`;
|
||||
const entries = diagnostic.entries.map((entry) => `${entry.name}${entry.type === 'directory' ? '/' : ''}`);
|
||||
const suffix = diagnostic.omitted > 0 ? [`...<${diagnostic.omitted} more entries>`] : [];
|
||||
return [...entries, ...suffix].join('\n') || '<empty>';
|
||||
}
|
||||
|
||||
function selectedEnvironmentNames(skipNativeProbeEnv) {
|
||||
const names = [
|
||||
'ELECTRON_RUN_AS_NODE',
|
||||
'FLUXER_NATIVE_MODULE_PREFLIGHT_CHILD',
|
||||
'LD_LIBRARY_PATH',
|
||||
'DYLD_LIBRARY_PATH',
|
||||
'DISPLAY',
|
||||
'WAYLAND_DISPLAY',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'XDG_SESSION_TYPE',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'PULSE_SERVER',
|
||||
'PIPEWIRE_REMOTE',
|
||||
'PATH',
|
||||
];
|
||||
if (skipNativeProbeEnv) names.push(skipNativeProbeEnv);
|
||||
return names;
|
||||
}
|
||||
|
||||
function environmentDiagnostics(skipNativeProbeEnv) {
|
||||
return Object.fromEntries(
|
||||
selectedEnvironmentNames(skipNativeProbeEnv).map((name) => [name, process.env[name] ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
function formatEnvironment(diagnostic) {
|
||||
return Object.entries(diagnostic)
|
||||
.map(([name, value]) => `${name}=${value ?? '<unset>'}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function runtimeDiagnostics() {
|
||||
const versions = process.versions || {};
|
||||
let reportHeader = null;
|
||||
if (process.report && typeof process.report.getReport === 'function') {
|
||||
try {
|
||||
reportHeader = process.report.getReport().header || null;
|
||||
} catch {
|
||||
reportHeader = null;
|
||||
}
|
||||
}
|
||||
const glibcRuntime = versions.glibcVersionRuntime || reportHeader?.glibcVersionRuntime || '<unknown>';
|
||||
const glibcCompiler = versions.glibcVersionCompiler || reportHeader?.glibcVersionCompiler || '<unknown>';
|
||||
return {
|
||||
node: versions.node || null,
|
||||
electron: versions.electron || null,
|
||||
modules: versions.modules || null,
|
||||
napi: versions.napi || null,
|
||||
v8: versions.v8 || null,
|
||||
uv: versions.uv || null,
|
||||
openssl: versions.openssl || null,
|
||||
glibcRuntime,
|
||||
glibcCompiler,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osType: os.type(),
|
||||
osRelease: os.release(),
|
||||
osVersion: typeof os.version === 'function' ? os.version() : null,
|
||||
execPath: process.execPath,
|
||||
resourcesPath: process.resourcesPath || null,
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRuntimeDiagnostics(diagnostic) {
|
||||
return [
|
||||
`node=${diagnostic.node || '<unknown>'}`,
|
||||
`electron=${diagnostic.electron || '<none>'}`,
|
||||
`modules=${diagnostic.modules || '<unknown>'}`,
|
||||
`napi=${diagnostic.napi || '<unknown>'}`,
|
||||
`v8=${diagnostic.v8 || '<unknown>'}`,
|
||||
`uv=${diagnostic.uv || '<unknown>'}`,
|
||||
`openssl=${diagnostic.openssl || '<unknown>'}`,
|
||||
`glibcRuntime=${diagnostic.glibcRuntime || '<unknown>'}`,
|
||||
`glibcCompiler=${diagnostic.glibcCompiler || '<unknown>'}`,
|
||||
`process=${diagnostic.platform}/${diagnostic.arch}`,
|
||||
`os=${diagnostic.osType} ${diagnostic.osRelease} ${diagnostic.osVersion || '<unknown>'}`,
|
||||
`execPath=${diagnostic.execPath}`,
|
||||
`resourcesPath=${diagnostic.resourcesPath || '<unknown>'}`,
|
||||
`cwd=${diagnostic.cwd}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const REDISTRIBUTABLE_RUNTIME_PATTERNS = [
|
||||
/^vcruntime\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcp\d+(?:_\d+)?\.dll$/i,
|
||||
/^msvcr\d+(?:_\d+)?\.dll$/i,
|
||||
/^concrt\d+\.dll$/i,
|
||||
/^vcamp\d+\.dll$/i,
|
||||
/^vcomp\d+\.dll$/i,
|
||||
];
|
||||
|
||||
function readPeImports(filePath) {
|
||||
let buffer;
|
||||
try {
|
||||
buffer = readFileSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (buffer.length < 0x40) return null;
|
||||
const peOffset = buffer.readUInt32LE(0x3c);
|
||||
if (peOffset <= 0 || peOffset + 24 >= buffer.length) return null;
|
||||
if (buffer.readUInt32LE(peOffset) !== 0x4550) return null;
|
||||
const coffOffset = peOffset + 4;
|
||||
const numberOfSections = buffer.readUInt16LE(coffOffset + 2);
|
||||
const sizeOfOptionalHeader = buffer.readUInt16LE(coffOffset + 16);
|
||||
const optionalHeaderOffset = coffOffset + 20;
|
||||
if (optionalHeaderOffset + sizeOfOptionalHeader > buffer.length) return null;
|
||||
const magic = buffer.readUInt16LE(optionalHeaderOffset);
|
||||
if (magic !== 0x10b && magic !== 0x20b) return null;
|
||||
const dataDirectoriesOffset = optionalHeaderOffset + (magic === 0x20b ? 112 : 96);
|
||||
const importEntryOffset = dataDirectoriesOffset + 8;
|
||||
if (importEntryOffset + 8 > buffer.length) return null;
|
||||
const importRva = buffer.readUInt32LE(importEntryOffset);
|
||||
if (importRva === 0) return [];
|
||||
const sections = [];
|
||||
const sectionTableOffset = optionalHeaderOffset + sizeOfOptionalHeader;
|
||||
for (let i = 0; i < numberOfSections; i++) {
|
||||
const base = sectionTableOffset + i * 40;
|
||||
if (base + 40 > buffer.length) return null;
|
||||
sections.push({
|
||||
virtualSize: buffer.readUInt32LE(base + 8),
|
||||
virtualAddress: buffer.readUInt32LE(base + 12),
|
||||
rawSize: buffer.readUInt32LE(base + 16),
|
||||
rawPointer: buffer.readUInt32LE(base + 20),
|
||||
});
|
||||
}
|
||||
const rvaToOffset = (rva) => {
|
||||
for (const s of sections) {
|
||||
const span = Math.max(s.virtualSize, s.rawSize);
|
||||
if (rva >= s.virtualAddress && rva < s.virtualAddress + span) {
|
||||
return rva - s.virtualAddress + s.rawPointer;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const readCString = (offset) => {
|
||||
let end = offset;
|
||||
while (end < buffer.length && buffer[end] !== 0) end++;
|
||||
return buffer.toString('ascii', offset, end);
|
||||
};
|
||||
const importTableOffset = rvaToOffset(importRva);
|
||||
if (importTableOffset < 0) return [];
|
||||
const imports = new Set();
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
const base = importTableOffset + i * 20;
|
||||
if (base + 20 > buffer.length) break;
|
||||
const lookupRva = buffer.readUInt32LE(base);
|
||||
const nameRva = buffer.readUInt32LE(base + 12);
|
||||
const iatRva = buffer.readUInt32LE(base + 16);
|
||||
if (lookupRva === 0 && nameRva === 0 && iatRva === 0) break;
|
||||
const nameOffset = rvaToOffset(nameRva);
|
||||
if (nameOffset < 0) continue;
|
||||
const name = readCString(nameOffset);
|
||||
if (name) imports.add(name);
|
||||
}
|
||||
return Array.from(imports);
|
||||
}
|
||||
|
||||
function windowsImportProbe(nativePath) {
|
||||
const imports = readPeImports(nativePath);
|
||||
if (imports === null) return null;
|
||||
const sortedImports = [...imports].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
||||
const redistributable = sortedImports.filter((dll) =>
|
||||
REDISTRIBUTABLE_RUNTIME_PATTERNS.some((pattern) => pattern.test(dll)),
|
||||
);
|
||||
return {
|
||||
command: ['pe-imports', nativePath],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
stdout: sortedImports.join('\n') || null,
|
||||
stderr: null,
|
||||
missing: [],
|
||||
redistributable,
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyProbe(nativePath) {
|
||||
if (!nativePath || !existsSync(nativePath)) return null;
|
||||
if (process.platform === 'win32') return windowsImportProbe(nativePath);
|
||||
const command =
|
||||
process.platform === 'linux'
|
||||
? ['ldd', nativePath]
|
||||
: process.platform === 'darwin'
|
||||
? ['otool', '-L', nativePath]
|
||||
: null;
|
||||
if (!command) return null;
|
||||
const [bin, ...args] = command;
|
||||
const result = spawnSync(bin, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 4000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = trimText(result.stdout);
|
||||
const stderr = trimText(result.stderr);
|
||||
const missing =
|
||||
process.platform === 'linux' && stdout
|
||||
? stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.includes('not found'))
|
||||
: [];
|
||||
return {
|
||||
command,
|
||||
status: result.status,
|
||||
signal: result.signal || null,
|
||||
error: result.error ? result.error.message : null,
|
||||
stdout,
|
||||
stderr,
|
||||
missing,
|
||||
redistributable: [],
|
||||
};
|
||||
}
|
||||
|
||||
function formatDependencyProbe(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
const status = diagnostic.error
|
||||
? `error=${diagnostic.error}`
|
||||
: diagnostic.signal
|
||||
? `signal=${diagnostic.signal}`
|
||||
: `status=${diagnostic.status}`;
|
||||
return [
|
||||
`$ ${diagnostic.command.join(' ')}`,
|
||||
status,
|
||||
diagnostic.missing?.length ? `missing:\n${diagnostic.missing.join('\n')}` : null,
|
||||
diagnostic.redistributable?.length
|
||||
? `redistributableRuntimeImports (require VC++ redist on host):\n${diagnostic.redistributable.join('\n')}`
|
||||
: null,
|
||||
diagnostic.stdout ? `stdout:\n${diagnostic.stdout}` : null,
|
||||
diagnostic.stderr ? `stderr:\n${diagnostic.stderr}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatExtraDiagnostic(diagnostic) {
|
||||
if (!diagnostic) return null;
|
||||
if (typeof diagnostic === 'string') return diagnostic;
|
||||
if (typeof diagnostic === 'object' && diagnostic.name && diagnostic.text) {
|
||||
return `${diagnostic.name}:\n${diagnostic.text}`;
|
||||
}
|
||||
return `extra:\n${trimText(JSON.stringify(diagnostic, null, 2))}`;
|
||||
}
|
||||
|
||||
function collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
moduleName,
|
||||
reason,
|
||||
target: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
packageDir: packageDir || null,
|
||||
nativeRoot: nativeRoot || null,
|
||||
nativePath: nativePath || null,
|
||||
nativeFile: nativePath ? basename(nativePath) : null,
|
||||
nativeFileStat: fileDiagnostic(nativePath),
|
||||
runtime: runtimeDiagnostics(),
|
||||
environment: environmentDiagnostics(skipNativeProbeEnv),
|
||||
nativeRootEntries: directoryDiagnostic(nativeRoot),
|
||||
dependencyProbe: dependencyProbe(nativePath),
|
||||
extraDiagnostics: extraDiagnostics.filter(Boolean),
|
||||
cause: errorDiagnostic(cause),
|
||||
};
|
||||
}
|
||||
|
||||
function formatNativeDiagnostics(diagnostics) {
|
||||
const sections = [
|
||||
`module=${diagnostics.moduleName}`,
|
||||
`reason=${diagnostics.reason}`,
|
||||
`target=${diagnostics.target.platform}/${diagnostics.target.arch}`,
|
||||
`packageDir=${diagnostics.packageDir || '<unknown>'}`,
|
||||
`nativeRoot=${diagnostics.nativeRoot || '<unknown>'}`,
|
||||
`nativePath=${diagnostics.nativePath || '<unknown>'}`,
|
||||
`nativeFile=${diagnostics.nativeFile || '<unknown>'}`,
|
||||
`nativeFileStat=${formatFileDiagnostic(diagnostics.nativeFileStat)}`,
|
||||
`runtime:\n${formatRuntimeDiagnostics(diagnostics.runtime)}`,
|
||||
`environment:\n${formatEnvironment(diagnostics.environment)}`,
|
||||
`nativeRootEntries:\n${formatDirectoryDiagnostic(diagnostics.nativeRootEntries)}`,
|
||||
...diagnostics.extraDiagnostics.map(formatExtraDiagnostic).filter(Boolean),
|
||||
];
|
||||
const dependencyOutput = formatDependencyProbe(diagnostics.dependencyProbe);
|
||||
if (dependencyOutput) sections.push(`dependencyProbe:\n${dependencyOutput}`);
|
||||
const causeText = formatErrorDiagnostic(diagnostics.cause);
|
||||
if (causeText) sections.push(`cause:\n${causeText}`);
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function isNativeLoadError(error) {
|
||||
return Boolean(error?.[NATIVE_LOAD_ERROR_MARKER]);
|
||||
}
|
||||
|
||||
function createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics = [],
|
||||
}) {
|
||||
if (isNativeLoadError(cause)) return cause;
|
||||
const diagnostics = collectNativeDiagnostics({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
cause,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics,
|
||||
});
|
||||
const error = new Error(`${moduleName} native module failed to load.\n${formatNativeDiagnostics(diagnostics)}`);
|
||||
error.name = 'NativeModuleLoadError';
|
||||
error[NATIVE_LOAD_ERROR_MARKER] = true;
|
||||
error.nativeDiagnostics = diagnostics;
|
||||
error.toJSON = () => ({
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
nativeDiagnostics: diagnostics,
|
||||
});
|
||||
if (cause) error.cause = cause;
|
||||
return error;
|
||||
}
|
||||
|
||||
function probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, timeoutMs = 5000}) {
|
||||
if (!skipNativeProbeEnv || process.env[skipNativeProbeEnv] === '1') {
|
||||
return null;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['-e', 'require(process.argv[1])', nativePath], {
|
||||
env: {...process.env, ELECTRON_RUN_AS_NODE: '1', [skipNativeProbeEnv]: '1'},
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (result.status === 0) return null;
|
||||
const reason = result.error
|
||||
? result.error.message
|
||||
: result.signal
|
||||
? `safety probe terminated by signal ${result.signal}`
|
||||
: `safety probe exited with code ${result.status}`;
|
||||
return createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason,
|
||||
skipNativeProbeEnv,
|
||||
extraDiagnostics: [
|
||||
result.stdout ? {name: 'probeStdout', text: trimText(result.stdout)} : null,
|
||||
result.stderr ? {name: 'probeStderr', text: trimText(result.stderr)} : null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function loadNativeBinding({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv, probe = true}) {
|
||||
if (!existsSync(nativePath)) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'native binary not found',
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const nativeProbeError = probe
|
||||
? probeNativeBinary({moduleName, nativePath, nativeRoot, packageDir, skipNativeProbeEnv})
|
||||
: null;
|
||||
if (nativeProbeError) {
|
||||
return {binding: null, loadError: nativeProbeError};
|
||||
}
|
||||
try {
|
||||
return {binding: require(nativePath), loadError: null};
|
||||
} catch (error) {
|
||||
return {
|
||||
binding: null,
|
||||
loadError: createNativeLoadError({
|
||||
moduleName,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir,
|
||||
reason: 'require(nativePath) threw',
|
||||
cause: error,
|
||||
skipNativeProbeEnv,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectNativeDiagnostics,
|
||||
createNativeLoadError,
|
||||
formatNativeDiagnostics,
|
||||
isNativeLoadError,
|
||||
loadNativeBinding,
|
||||
probeNativeBinary,
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@fluxer/mac-app-audio",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"loader-diagnostics.cjs",
|
||||
"mac-app-audio.darwin-x64.node",
|
||||
"mac-app-audio.darwin-arm64.node"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||
"test": "pnpm test:cargo && ../../../node_modules/.bin/vitest run",
|
||||
"test:cargo": "cargo test --manifest-path Cargo.toml"
|
||||
},
|
||||
"binary": {
|
||||
"napi_versions": [
|
||||
8
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,462 @@
|
||||
#![allow(non_snake_case, non_camel_case_types)]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use core::ffi::c_void;
|
||||
use core::ptr;
|
||||
use std::sync::{
|
||||
Mutex, MutexGuard,
|
||||
atomic::{AtomicPtr, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::{NSObject, NSObjectProtocol, ProtocolObject};
|
||||
use objc2::{AllocAnyThread, DefinedClass, define_class, msg_send};
|
||||
use objc2_core_foundation::CFAllocator;
|
||||
use objc2_core_media::CMSampleBuffer;
|
||||
use objc2_foundation::NSError;
|
||||
use objc2_screen_capture_kit::{SCStream, SCStreamDelegate, SCStreamOutput, SCStreamOutputType};
|
||||
|
||||
use crate::audio_converter::{self as ac, AudioBufferList, AudioStreamBasicDescription};
|
||||
use crate::pcm_pool::{PcmFramePool, PooledPcmFrame};
|
||||
use crate::source_state::Machine;
|
||||
|
||||
const MAX_CALLBACK_INPUT_FRAMES: u32 = 48_000;
|
||||
const MIN_INPUT_SAMPLE_RATE: f64 = 8_000.0;
|
||||
const MAX_ABL_BUFFERS: usize = 32;
|
||||
|
||||
pub type PcmCallback =
|
||||
unsafe extern "C" fn(ctx: *mut c_void, slot: *mut Option<PooledPcmFrame>, frames: u32);
|
||||
pub type StopCallback = unsafe extern "C" fn(ctx: *mut c_void, err: *mut NSError);
|
||||
|
||||
pub struct SourceOptions {
|
||||
pub target_sample_rate: f64,
|
||||
pub target_channels: u32,
|
||||
}
|
||||
|
||||
impl Default for SourceOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_sample_rate: 48_000.0,
|
||||
target_channels: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_AUDIO_BUFFER: ac::AudioBuffer = ac::AudioBuffer {
|
||||
m_number_channels: 0,
|
||||
m_data_byte_size: 0,
|
||||
m_data: ptr::null_mut(),
|
||||
};
|
||||
|
||||
#[repr(C, align(16))]
|
||||
struct AblScratch {
|
||||
n_buffers: u32,
|
||||
_pad: u32,
|
||||
buffers: [ac::AudioBuffer; MAX_ABL_BUFFERS],
|
||||
}
|
||||
|
||||
impl AblScratch {
|
||||
const fn empty() -> Self {
|
||||
Self {
|
||||
n_buffers: 0,
|
||||
_pad: 0,
|
||||
buffers: [EMPTY_AUDIO_BUFFER; MAX_ABL_BUFFERS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _: () = {
|
||||
assert!(core::mem::align_of::<AblScratch>() == 16);
|
||||
assert!(
|
||||
core::mem::size_of::<AblScratch>()
|
||||
>= core::mem::size_of::<u32>()
|
||||
+ MAX_ABL_BUFFERS * core::mem::size_of::<ac::AudioBuffer>()
|
||||
);
|
||||
assert!(core::mem::offset_of!(AblScratch, buffers) == 8);
|
||||
assert!(core::mem::size_of::<AblScratch>() >= core::mem::size_of::<AudioBufferList>());
|
||||
};
|
||||
|
||||
pub struct Source {
|
||||
pub delegate: Retained<FluxerSCKAudioSource>,
|
||||
pub state: Machine,
|
||||
pub target_sample_rate: f64,
|
||||
pub target_channels: u32,
|
||||
scratch: Mutex<SourceScratch>,
|
||||
callbacks: Mutex<Callbacks>,
|
||||
pub dropped_buffers: AtomicU64,
|
||||
pub output_queue: dispatch2::DispatchRetained<dispatch2::DispatchQueue>,
|
||||
}
|
||||
|
||||
struct SourceScratch {
|
||||
abl: AblScratch,
|
||||
}
|
||||
|
||||
struct Callbacks {
|
||||
pcm_callback: Option<PcmCallback>,
|
||||
pcm_callback_ctx: *mut c_void,
|
||||
stop_callback: Option<StopCallback>,
|
||||
stop_callback_ctx: *mut c_void,
|
||||
pcm_pool: Option<PcmFramePool>,
|
||||
}
|
||||
|
||||
unsafe impl Send for Source {}
|
||||
unsafe impl Sync for Source {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SourceCreateError {
|
||||
DispatchQueue,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClassRegistrationError;
|
||||
|
||||
pub struct DelegateIvars {
|
||||
pub source: AtomicPtr<Source>,
|
||||
}
|
||||
|
||||
define_class!(
|
||||
#[unsafe(super(NSObject))]
|
||||
#[name = "FluxerSCKAudioSource"]
|
||||
#[ivars = DelegateIvars]
|
||||
pub struct FluxerSCKAudioSource;
|
||||
|
||||
unsafe impl NSObjectProtocol for FluxerSCKAudioSource {}
|
||||
|
||||
unsafe impl SCStreamDelegate for FluxerSCKAudioSource {
|
||||
#[unsafe(method(stream:didStopWithError:))]
|
||||
unsafe fn did_stop_with_error(&self, _stream: &SCStream, err: &NSError) {
|
||||
let ptr = self.ivars().source.load(Ordering::Acquire);
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
let src = unsafe { &*ptr };
|
||||
let _ = src.state.request_stop();
|
||||
let _ = src.state.mark_stopped();
|
||||
let (cb, ctx) = {
|
||||
let callbacks = src.lock_callbacks();
|
||||
(callbacks.stop_callback, callbacks.stop_callback_ctx)
|
||||
};
|
||||
if let Some(cb) = cb {
|
||||
unsafe {
|
||||
cb(ctx, err as *const NSError as *mut NSError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl SCStreamOutput for FluxerSCKAudioSource {
|
||||
#[unsafe(method(stream:didOutputSampleBuffer:ofType:))]
|
||||
unsafe fn did_output_sample_buffer(
|
||||
&self,
|
||||
_stream: &SCStream,
|
||||
sample_buffer: &CMSampleBuffer,
|
||||
output_type: SCStreamOutputType,
|
||||
) {
|
||||
if output_type != SCStreamOutputType::Audio {
|
||||
return;
|
||||
}
|
||||
let ptr = self.ivars().source.load(Ordering::Acquire);
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
unsafe {
|
||||
handle_audio_sample(&*ptr, sample_buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl FluxerSCKAudioSource {
|
||||
pub fn new() -> Retained<Self> {
|
||||
let this = Self::alloc().set_ivars(DelegateIvars {
|
||||
source: AtomicPtr::new(core::ptr::null_mut()),
|
||||
});
|
||||
unsafe { msg_send![super(this), init] }
|
||||
}
|
||||
}
|
||||
|
||||
struct RetainedBlockGuard(*mut objc2_core_media::CMBlockBuffer);
|
||||
|
||||
impl Drop for RetainedBlockGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe {
|
||||
let _ = objc2_core_foundation::CFRetained::from_raw(
|
||||
core::ptr::NonNull::new_unchecked(self.0),
|
||||
);
|
||||
}
|
||||
self.0 = ptr::null_mut();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AblFillOutcome {
|
||||
Filled(RetainedBlockGuard),
|
||||
TooLarge,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
fn read_stream_asbd(sample_buffer: &CMSampleBuffer) -> Option<AudioStreamBasicDescription> {
|
||||
let desc = unsafe { sample_buffer.format_description() }?;
|
||||
unsafe extern "C-unwind" {
|
||||
fn CMAudioFormatDescriptionGetStreamBasicDescription(
|
||||
desc: &objc2_core_media::CMFormatDescription,
|
||||
) -> *const AudioStreamBasicDescription;
|
||||
}
|
||||
let asbd_ptr = unsafe { CMAudioFormatDescriptionGetStreamBasicDescription(&desc) };
|
||||
if asbd_ptr.is_null() {
|
||||
return None;
|
||||
}
|
||||
Some(unsafe { *asbd_ptr })
|
||||
}
|
||||
|
||||
unsafe fn admit_sample(
|
||||
src: &Source,
|
||||
sample_buffer: &CMSampleBuffer,
|
||||
) -> Option<(u32, AudioStreamBasicDescription)> {
|
||||
if !unsafe { sample_buffer.data_is_ready() } {
|
||||
return None;
|
||||
}
|
||||
let num_samples = unsafe { sample_buffer.num_samples() };
|
||||
if num_samples <= 0 || (num_samples as u32) > MAX_CALLBACK_INPUT_FRAMES {
|
||||
return None;
|
||||
}
|
||||
let asbd = read_stream_asbd(sample_buffer)?;
|
||||
if !asbd.m_sample_rate.is_finite() {
|
||||
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
if asbd.m_sample_rate < MIN_INPUT_SAMPLE_RATE {
|
||||
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
Some((num_samples as u32, asbd))
|
||||
}
|
||||
|
||||
unsafe fn fill_abl_scratch(sample_buffer: &CMSampleBuffer, abl: &mut AblScratch) -> AblFillOutcome {
|
||||
assert!(core::mem::size_of::<AblScratch>() >= core::mem::size_of::<AudioBufferList>());
|
||||
let mut abl_size: usize = 0;
|
||||
let size_status = unsafe {
|
||||
sample_buffer.audio_buffer_list_with_retained_block_buffer(
|
||||
&mut abl_size,
|
||||
ptr::null_mut(),
|
||||
0,
|
||||
None::<&CFAllocator>,
|
||||
None::<&CFAllocator>,
|
||||
0,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if size_status != 0 || abl_size < core::mem::size_of::<AudioBufferList>() {
|
||||
return AblFillOutcome::Unavailable;
|
||||
}
|
||||
if abl_size > core::mem::size_of::<AblScratch>() {
|
||||
return AblFillOutcome::TooLarge;
|
||||
}
|
||||
let abl_ptr = (abl as *mut AblScratch) as *mut objc2_core_audio_types::AudioBufferList;
|
||||
let mut retained_block: *mut objc2_core_media::CMBlockBuffer = ptr::null_mut();
|
||||
let list_status = unsafe {
|
||||
sample_buffer.audio_buffer_list_with_retained_block_buffer(
|
||||
ptr::null_mut(),
|
||||
abl_ptr,
|
||||
core::mem::size_of::<AblScratch>(),
|
||||
None::<&CFAllocator>,
|
||||
None::<&CFAllocator>,
|
||||
0,
|
||||
&mut retained_block,
|
||||
)
|
||||
};
|
||||
let guard = RetainedBlockGuard(retained_block);
|
||||
if list_status != 0 {
|
||||
return AblFillOutcome::Unavailable;
|
||||
}
|
||||
if abl.n_buffers as usize > MAX_ABL_BUFFERS {
|
||||
return AblFillOutcome::Unavailable;
|
||||
}
|
||||
AblFillOutcome::Filled(guard)
|
||||
}
|
||||
|
||||
unsafe fn handle_audio_sample(src: &Source, sample_buffer: &CMSampleBuffer) {
|
||||
let Some((frames, asbd)) = (unsafe { admit_sample(src, sample_buffer) }) else {
|
||||
return;
|
||||
};
|
||||
let (cb, ctx, pool) = {
|
||||
let callbacks = src.lock_callbacks();
|
||||
(
|
||||
callbacks.pcm_callback,
|
||||
callbacks.pcm_callback_ctx,
|
||||
callbacks.pcm_pool.clone(),
|
||||
)
|
||||
};
|
||||
let Some(cb) = cb else { return };
|
||||
let Some(pool) = pool else { return };
|
||||
let out_capacity =
|
||||
ac::output_frame_capacity(frames, asbd.m_sample_rate, src.target_sample_rate);
|
||||
let needed = (out_capacity as usize) * (src.target_channels as usize);
|
||||
if needed > pool.samples_per_slot() as usize {
|
||||
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let mut scratch = match src.scratch.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => {
|
||||
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let block_guard = match unsafe { fill_abl_scratch(sample_buffer, &mut scratch.abl) } {
|
||||
AblFillOutcome::Filled(guard) => guard,
|
||||
AblFillOutcome::TooLarge => {
|
||||
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
AblFillOutcome::Unavailable => return,
|
||||
};
|
||||
let Some(mut slot) = pool.try_acquire() else {
|
||||
src.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
let out_frames = unsafe {
|
||||
ac::convert_buffer_list_to_interleaved_f32(
|
||||
&asbd,
|
||||
(&raw const scratch.abl) as *const AudioBufferList,
|
||||
frames,
|
||||
src.target_sample_rate,
|
||||
src.target_channels,
|
||||
slot.unfilled_mut(),
|
||||
)
|
||||
};
|
||||
drop(block_guard);
|
||||
drop(scratch);
|
||||
let frames_emitted = match out_frames {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
};
|
||||
let filled = (frames_emitted as usize) * (src.target_channels as usize);
|
||||
assert!(filled <= slot.capacity());
|
||||
slot.set_filled_len(filled);
|
||||
let mut handoff = Some(slot);
|
||||
unsafe {
|
||||
cb(
|
||||
ctx,
|
||||
&mut handoff as *mut Option<PooledPcmFrame>,
|
||||
frames_emitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn create(opts: SourceOptions) -> Result<Box<Source>, SourceCreateError> {
|
||||
assert!(opts.target_sample_rate > 0.0);
|
||||
assert!(opts.target_channels > 0);
|
||||
let delegate = FluxerSCKAudioSource::new();
|
||||
|
||||
let queue_attr = dispatch2::DispatchQueueAttr::with_qos_class(
|
||||
dispatch2::DispatchQueueAttr::SERIAL,
|
||||
dispatch2::DispatchQoS::UserInteractive,
|
||||
0,
|
||||
);
|
||||
let queue =
|
||||
dispatch2::DispatchQueue::new("app.fluxer.mac-app-audio.sck", Some(&queue_attr));
|
||||
|
||||
let mut src = Box::new(Source {
|
||||
delegate,
|
||||
state: Machine::new(),
|
||||
target_sample_rate: opts.target_sample_rate,
|
||||
target_channels: opts.target_channels,
|
||||
scratch: Mutex::new(SourceScratch {
|
||||
abl: AblScratch::empty(),
|
||||
}),
|
||||
callbacks: Mutex::new(Callbacks {
|
||||
pcm_callback: None,
|
||||
pcm_callback_ctx: ptr::null_mut(),
|
||||
stop_callback: None,
|
||||
stop_callback_ctx: ptr::null_mut(),
|
||||
pcm_pool: None,
|
||||
}),
|
||||
dropped_buffers: AtomicU64::new(0),
|
||||
output_queue: queue,
|
||||
});
|
||||
|
||||
let raw: *mut Source = &mut *src;
|
||||
src.delegate.ivars().source.store(raw, Ordering::Release);
|
||||
Ok(src)
|
||||
}
|
||||
|
||||
fn lock_callbacks(&self) -> MutexGuard<'_, Callbacks> {
|
||||
self.callbacks
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
pub fn set_pcm_callback(&mut self, cb: PcmCallback, ctx: *mut c_void) {
|
||||
let mut callbacks = self.lock_callbacks();
|
||||
callbacks.pcm_callback = Some(cb);
|
||||
callbacks.pcm_callback_ctx = ctx;
|
||||
}
|
||||
|
||||
pub fn set_pcm_pool(&mut self, pool: PcmFramePool) {
|
||||
assert!(pool.capacity() > 0);
|
||||
assert!(pool.samples_per_slot() > 0);
|
||||
let mut callbacks = self.lock_callbacks();
|
||||
callbacks.pcm_pool = Some(pool);
|
||||
}
|
||||
|
||||
pub fn set_stop_callback(&mut self, cb: StopCallback, ctx: *mut c_void) {
|
||||
let mut callbacks = self.lock_callbacks();
|
||||
callbacks.stop_callback = Some(cb);
|
||||
callbacks.stop_callback_ctx = ctx;
|
||||
}
|
||||
|
||||
pub fn clear_stop_callback(&mut self) {
|
||||
let mut callbacks = self.lock_callbacks();
|
||||
callbacks.stop_callback = None;
|
||||
callbacks.stop_callback_ctx = ptr::null_mut();
|
||||
}
|
||||
|
||||
pub fn delegate_as_output(&self) -> &ProtocolObject<dyn SCStreamOutput> {
|
||||
ProtocolObject::from_ref(&*self.delegate)
|
||||
}
|
||||
|
||||
pub fn delegate_as_delegate(&self) -> &ProtocolObject<dyn SCStreamDelegate> {
|
||||
ProtocolObject::from_ref(&*self.delegate)
|
||||
}
|
||||
|
||||
pub fn output_queue(&self) -> &dispatch2::DispatchQueue {
|
||||
&self.output_queue
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Source {
|
||||
fn drop(&mut self) {
|
||||
self.delegate
|
||||
.ivars()
|
||||
.source
|
||||
.store(ptr::null_mut(), Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod abl_scratch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn abl_scratch_layout_matches_audio_buffer_list() {
|
||||
assert_eq!(16, core::mem::align_of::<AblScratch>());
|
||||
assert_eq!(8, core::mem::offset_of!(AblScratch, buffers));
|
||||
assert!(
|
||||
core::mem::size_of::<AblScratch>()
|
||||
>= core::mem::size_of::<u32>()
|
||||
+ MAX_ABL_BUFFERS * core::mem::size_of::<ac::AudioBuffer>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abl_scratch_empty_has_no_buffers() {
|
||||
let scratch = AblScratch::empty();
|
||||
assert_eq!(0, scratch.n_buffers);
|
||||
assert!(scratch.buffers[0].m_data.is_null());
|
||||
assert_eq!(0, scratch.buffers[MAX_ABL_BUFFERS - 1].m_data_byte_size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,953 @@
|
||||
#![allow(non_camel_case_types, non_upper_case_globals)]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use core::ffi::{CStr, c_void};
|
||||
use core::ptr;
|
||||
use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
use objc2::AllocAnyThread;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_core_audio::{
|
||||
AudioDeviceCreateIOProcID, AudioDeviceDestroyIOProcID, AudioDeviceIOProcID, AudioDeviceStart,
|
||||
AudioDeviceStop, AudioHardwareCreateAggregateDevice, AudioHardwareCreateProcessTap,
|
||||
AudioHardwareDestroyAggregateDevice, AudioHardwareDestroyProcessTap,
|
||||
AudioObjectGetPropertyData, AudioObjectID, AudioObjectPropertyAddress,
|
||||
AudioObjectSetPropertyData, CATapDescription, CATapMuteBehavior,
|
||||
kAudioHardwarePropertyTranslatePIDToProcessObject, kAudioObjectPropertyElementMain,
|
||||
kAudioObjectPropertyScopeGlobal, kAudioObjectSystemObject, kAudioObjectUnknown,
|
||||
kAudioTapPropertyDescription, kAudioTapPropertyFormat, kAudioTapPropertyUID,
|
||||
};
|
||||
use objc2_core_foundation::CFDictionary;
|
||||
use objc2_foundation::{
|
||||
NSArray, NSBundle, NSMutableArray, NSMutableDictionary, NSNumber, NSObject, NSString, NSUUID,
|
||||
};
|
||||
|
||||
use crate::audio_converter::{self as ac, AudioBufferList, AudioStreamBasicDescription};
|
||||
use crate::foundation;
|
||||
use crate::pcm_pool::{PcmFramePool, PooledPcmFrame};
|
||||
use crate::process_tree;
|
||||
|
||||
pub type OSStatus = i32;
|
||||
|
||||
const NO_ERR: OSStatus = 0;
|
||||
const K_AGGREGATE_DRIFT_COMPENSATION_MEDIUM_QUALITY: u32 = 0x40;
|
||||
|
||||
const MAX_RELATED_PROCESSES: usize = 512;
|
||||
const MAX_CALLBACK_INPUT_FRAMES: u32 = 48_000;
|
||||
const TARGET_SAMPLE_RATE: f64 = 48_000.0;
|
||||
const TARGET_CHANNELS: u32 = 2;
|
||||
|
||||
const LATE_SPAWN_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
const HELPER_BUNDLE_SUFFIXES: &[&str] = &[
|
||||
".helper",
|
||||
".helper.Renderer",
|
||||
".helper.GPU",
|
||||
".helper.Plugin",
|
||||
];
|
||||
|
||||
static AGGREGATE_UID_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
static DEBUG_COREAUDIO: AtomicBool = AtomicBool::new(false);
|
||||
static DEBUG_CALLBACK_COUNT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CaptureScope {
|
||||
Process,
|
||||
System,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CreateError {
|
||||
Unsupported,
|
||||
NoRelatedProcesses,
|
||||
NoProcessObjects,
|
||||
FoundationObjectFailed,
|
||||
CreateProcessTapFailed,
|
||||
ReadTapUidFailed,
|
||||
ReadTapFormatFailed,
|
||||
CreateAggregateDeviceFailed,
|
||||
CreateIOProcFailed,
|
||||
StartDeviceFailed,
|
||||
}
|
||||
|
||||
pub type PcmCallback =
|
||||
unsafe extern "C" fn(ctx: *mut c_void, slot: *mut Option<PooledPcmFrame>, frames: u32);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct TapDiagnostics {
|
||||
pub convert_failures: u64,
|
||||
pub dropped_buffers: u64,
|
||||
}
|
||||
|
||||
pub struct Capture {
|
||||
pub tap_id: AudioObjectID,
|
||||
pub aggregate_device_id: AudioObjectID,
|
||||
pub io_proc_id: AudioDeviceIOProcID,
|
||||
pub input_format: AudioStreamBasicDescription,
|
||||
pub running: AtomicBool,
|
||||
pub pcm_callback: Option<PcmCallback>,
|
||||
pub pcm_callback_ctx: *mut c_void,
|
||||
pub pcm_pool: Option<PcmFramePool>,
|
||||
pub convert_failures: AtomicU64,
|
||||
pub dropped_buffers: AtomicU64,
|
||||
|
||||
refresher: Option<RefresherHandle>,
|
||||
}
|
||||
|
||||
unsafe impl Send for Capture {}
|
||||
unsafe impl Sync for Capture {}
|
||||
|
||||
struct RefresherHandle {
|
||||
alive: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RefresherHandle {
|
||||
fn shutdown(&mut self) {
|
||||
self.alive.store(false, Ordering::Release);
|
||||
if let Some(handle) = self.thread.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RefresherHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_supported() -> bool {
|
||||
use objc2::runtime::AnyClass;
|
||||
AnyClass::get(c"CATapDescription").is_some()
|
||||
}
|
||||
|
||||
fn getenv_set(name: &CStr) -> bool {
|
||||
unsafe { !libc::getenv(name.as_ptr()).is_null() }
|
||||
}
|
||||
|
||||
fn collect_target_pids(pid: i32, include_process_tree: bool) -> Vec<i32> {
|
||||
if !include_process_tree {
|
||||
return vec![pid];
|
||||
}
|
||||
process_tree::collect_related_pids(pid, MAX_RELATED_PROCESSES)
|
||||
}
|
||||
|
||||
fn collect_process_objects(pids: &[i32], skip_current_process: bool) -> Vec<AudioObjectID> {
|
||||
let self_pid = unsafe { libc::getpid() };
|
||||
let mut out = Vec::with_capacity(pids.len());
|
||||
for &pid in pids {
|
||||
if pid <= 0 {
|
||||
continue;
|
||||
}
|
||||
if skip_current_process && pid == self_pid {
|
||||
continue;
|
||||
}
|
||||
let object = translate_pid_to_process_object(pid);
|
||||
if object == kAudioObjectUnknown {
|
||||
continue;
|
||||
}
|
||||
if out.contains(&object) {
|
||||
continue;
|
||||
}
|
||||
out.push(object);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn translate_pid_to_process_object(pid: i32) -> AudioObjectID {
|
||||
let mut out: AudioObjectID = kAudioObjectUnknown;
|
||||
let mut size: u32 = core::mem::size_of::<AudioObjectID>() as u32;
|
||||
let mut qualifier_pid = pid;
|
||||
let address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioHardwarePropertyTranslatePIDToProcessObject,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain,
|
||||
};
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
kAudioObjectSystemObject as AudioObjectID,
|
||||
ptr::NonNull::from(&address),
|
||||
core::mem::size_of::<i32>() as u32,
|
||||
(&raw mut qualifier_pid) as *const _ as *const c_void,
|
||||
ptr::NonNull::from(&mut size),
|
||||
ptr::NonNull::from(&mut out).cast::<c_void>(),
|
||||
)
|
||||
};
|
||||
if status != NO_ERR {
|
||||
return kAudioObjectUnknown;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build_process_array(objects: &[AudioObjectID]) -> Retained<NSArray<NSNumber>> {
|
||||
let nums: Vec<Retained<NSNumber>> = objects.iter().map(|o| NSNumber::new_u32(*o)).collect();
|
||||
let refs: Vec<&NSNumber> = nums.iter().map(|n| n.as_ref()).collect();
|
||||
NSArray::from_slice(&refs)
|
||||
}
|
||||
|
||||
fn create_tap_description(
|
||||
process_objects: &[AudioObjectID],
|
||||
pid: i32,
|
||||
scope: CaptureScope,
|
||||
) -> Result<Retained<CATapDescription>, CreateError> {
|
||||
let process_array = build_process_array(process_objects);
|
||||
|
||||
let alloc = CATapDescription::alloc();
|
||||
let description = match scope {
|
||||
CaptureScope::Process => unsafe {
|
||||
CATapDescription::initStereoMixdownOfProcesses(alloc, &process_array)
|
||||
},
|
||||
CaptureScope::System => unsafe {
|
||||
CATapDescription::initStereoGlobalTapButExcludeProcesses(alloc, &process_array)
|
||||
},
|
||||
};
|
||||
|
||||
let excludes_by_bundle_id = if scope == CaptureScope::System {
|
||||
apply_main_bundle_id_excludes(&description)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if scope == CaptureScope::Process && process_objects.is_empty() {
|
||||
return Err(CreateError::NoProcessObjects);
|
||||
}
|
||||
if scope == CaptureScope::System && process_objects.is_empty() && !excludes_by_bundle_id {
|
||||
return Err(CreateError::NoProcessObjects);
|
||||
}
|
||||
|
||||
let name = match scope {
|
||||
CaptureScope::Process => format!("Fluxer app audio tap pid {pid}"),
|
||||
CaptureScope::System => format!("Fluxer desktop audio tap excluding pid {pid}"),
|
||||
};
|
||||
unsafe {
|
||||
description.setUUID(&NSUUID::UUID());
|
||||
description.setName(&NSString::from_str(&name));
|
||||
description.setPrivate(true);
|
||||
description.setExclusive(scope == CaptureScope::System);
|
||||
description.setMuteBehavior(CATapMuteBehavior(0));
|
||||
}
|
||||
Ok(description)
|
||||
}
|
||||
|
||||
fn apply_main_bundle_id_excludes(description: &CATapDescription) -> bool {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &NSObject = description.as_ref();
|
||||
if !obj.respondsToSelector(sel!(setBundleIDs:)) {
|
||||
return false;
|
||||
}
|
||||
let bundle_array = match build_main_bundle_id_array() {
|
||||
Some(a) => a,
|
||||
None => return false,
|
||||
};
|
||||
if bundle_array.count() == 0 {
|
||||
return false;
|
||||
}
|
||||
unsafe {
|
||||
description.setBundleIDs(&bundle_array);
|
||||
if obj.respondsToSelector(sel!(setProcessRestoreEnabled:)) {
|
||||
description.setProcessRestoreEnabled(true);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn build_main_bundle_id_array() -> Option<Retained<NSArray<NSString>>> {
|
||||
let base = copy_main_bundle_identifier()?;
|
||||
let base_str = base.to_string();
|
||||
if base_str.is_empty() || base_str.len() > 192 {
|
||||
return None;
|
||||
}
|
||||
let mut entries: Vec<Retained<NSString>> = Vec::with_capacity(1 + HELPER_BUNDLE_SUFFIXES.len());
|
||||
entries.push(base);
|
||||
for suffix in HELPER_BUNDLE_SUFFIXES {
|
||||
let helper = format!("{base_str}{suffix}");
|
||||
entries.push(NSString::from_str(&helper));
|
||||
}
|
||||
let refs: Vec<&NSString> = entries.iter().map(|s| s.as_ref()).collect();
|
||||
Some(NSArray::from_slice(&refs))
|
||||
}
|
||||
|
||||
fn copy_main_bundle_identifier() -> Option<Retained<NSString>> {
|
||||
let bundle = NSBundle::mainBundle();
|
||||
bundle.bundleIdentifier()
|
||||
}
|
||||
|
||||
fn copy_tap_uid(tap_id: AudioObjectID) -> Result<Retained<NSString>, CreateError> {
|
||||
let mut tap_uid: *const NSString = ptr::null();
|
||||
let mut size: u32 = core::mem::size_of::<*const NSString>() as u32;
|
||||
let address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioTapPropertyUID,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain,
|
||||
};
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
tap_id,
|
||||
ptr::NonNull::from(&address),
|
||||
0,
|
||||
ptr::null(),
|
||||
ptr::NonNull::from(&mut size),
|
||||
ptr::NonNull::from(&mut tap_uid).cast::<c_void>(),
|
||||
)
|
||||
};
|
||||
if status != NO_ERR || tap_uid.is_null() {
|
||||
return Err(CreateError::ReadTapUidFailed);
|
||||
}
|
||||
|
||||
unsafe { Retained::from_raw(tap_uid as *mut NSString).ok_or(CreateError::ReadTapUidFailed) }
|
||||
}
|
||||
|
||||
fn read_tap_format(tap_id: AudioObjectID) -> Result<AudioStreamBasicDescription, CreateError> {
|
||||
let mut format: AudioStreamBasicDescription = unsafe { core::mem::zeroed() };
|
||||
let mut size: u32 = core::mem::size_of::<AudioStreamBasicDescription>() as u32;
|
||||
let address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioTapPropertyFormat,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain,
|
||||
};
|
||||
let status = unsafe {
|
||||
AudioObjectGetPropertyData(
|
||||
tap_id,
|
||||
ptr::NonNull::from(&address),
|
||||
0,
|
||||
ptr::null(),
|
||||
ptr::NonNull::from(&mut size),
|
||||
ptr::NonNull::from(&mut format).cast::<c_void>(),
|
||||
)
|
||||
};
|
||||
if status != NO_ERR {
|
||||
return Err(CreateError::ReadTapFormatFailed);
|
||||
}
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
fn create_aggregate_description(
|
||||
tap_uid: &NSString,
|
||||
pid: i32,
|
||||
) -> Result<Retained<NSMutableDictionary<NSString, NSObject>>, CreateError> {
|
||||
let tap_dict: Retained<NSMutableDictionary<NSString, NSObject>> = NSMutableDictionary::new();
|
||||
foundation::dict_set_str_key(&tap_dict, c"uid", tap_uid.as_ref());
|
||||
let drift = NSNumber::new_bool(true);
|
||||
foundation::dict_set_str_key(&tap_dict, c"drift", drift.as_ref());
|
||||
let drift_quality = NSNumber::new_u32(K_AGGREGATE_DRIFT_COMPENSATION_MEDIUM_QUALITY);
|
||||
foundation::dict_set_str_key(&tap_dict, c"drift quality", drift_quality.as_ref());
|
||||
|
||||
let dict_obj: &NSObject = tap_dict.as_ref();
|
||||
let tap_list: Retained<NSMutableArray<NSObject>> = NSMutableArray::arrayWithCapacity(1);
|
||||
tap_list.addObject(dict_obj);
|
||||
|
||||
let aggregate_dict: Retained<NSMutableDictionary<NSString, NSObject>> =
|
||||
NSMutableDictionary::new();
|
||||
let counter = AGGREGATE_UID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let uid_string = format!("app.fluxer.mac-app-audio.tap.{pid}.{counter}");
|
||||
let uid = NSString::from_str(&uid_string);
|
||||
let name = NSString::from_str("Fluxer app audio capture");
|
||||
foundation::dict_set_str_key(&aggregate_dict, c"uid", uid.as_ref());
|
||||
foundation::dict_set_str_key(&aggregate_dict, c"name", name.as_ref());
|
||||
let priv_n = NSNumber::new_bool(true);
|
||||
foundation::dict_set_str_key(&aggregate_dict, c"private", priv_n.as_ref());
|
||||
foundation::dict_set_str_key(&aggregate_dict, c"taps", tap_list.as_ref());
|
||||
let auto = NSNumber::new_bool(true);
|
||||
foundation::dict_set_str_key(&aggregate_dict, c"tapautostart", auto.as_ref());
|
||||
Ok(aggregate_dict)
|
||||
}
|
||||
|
||||
unsafe extern "C-unwind" fn io_proc(
|
||||
_in_device: AudioObjectID,
|
||||
_in_now: core::ptr::NonNull<objc2_core_audio_types::AudioTimeStamp>,
|
||||
in_input_data: core::ptr::NonNull<objc2_core_audio_types::AudioBufferList>,
|
||||
_in_input_time: core::ptr::NonNull<objc2_core_audio_types::AudioTimeStamp>,
|
||||
_out_output_data: core::ptr::NonNull<objc2_core_audio_types::AudioBufferList>,
|
||||
_in_output_time: core::ptr::NonNull<objc2_core_audio_types::AudioTimeStamp>,
|
||||
client_data: *mut c_void,
|
||||
) -> OSStatus {
|
||||
if client_data.is_null() {
|
||||
return NO_ERR;
|
||||
}
|
||||
let self_ptr = client_data as *mut Capture;
|
||||
let capture = unsafe { &*self_ptr };
|
||||
if !capture.running.load(Ordering::Acquire) {
|
||||
return NO_ERR;
|
||||
}
|
||||
let local_abl = in_input_data.as_ptr() as *const AudioBufferList;
|
||||
let frames =
|
||||
match unsafe { ac::input_frame_count_for_buffer_list(&capture.input_format, local_abl) } {
|
||||
Ok(f) => f,
|
||||
Err(_) => {
|
||||
capture.convert_failures.fetch_add(1, Ordering::Relaxed);
|
||||
return NO_ERR;
|
||||
}
|
||||
};
|
||||
if frames == 0 {
|
||||
return NO_ERR;
|
||||
}
|
||||
if frames > MAX_CALLBACK_INPUT_FRAMES {
|
||||
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return NO_ERR;
|
||||
}
|
||||
unsafe {
|
||||
convert_and_deliver(capture, local_abl, frames);
|
||||
}
|
||||
NO_ERR
|
||||
}
|
||||
|
||||
unsafe fn convert_and_deliver(capture: &Capture, local_abl: *const AudioBufferList, frames: u32) {
|
||||
assert!(frames > 0);
|
||||
assert!(frames <= MAX_CALLBACK_INPUT_FRAMES);
|
||||
let Some(cb) = capture.pcm_callback else {
|
||||
return;
|
||||
};
|
||||
let Some(pool) = capture.pcm_pool.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let needed = (ac::output_frame_capacity(
|
||||
frames,
|
||||
capture.input_format.m_sample_rate,
|
||||
TARGET_SAMPLE_RATE,
|
||||
) as usize)
|
||||
* (TARGET_CHANNELS as usize);
|
||||
if needed > pool.samples_per_slot() as usize {
|
||||
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
let Some(mut slot) = pool.try_acquire() else {
|
||||
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
};
|
||||
let out_frames = unsafe {
|
||||
ac::convert_buffer_list_to_interleaved_f32(
|
||||
&capture.input_format,
|
||||
local_abl,
|
||||
frames,
|
||||
TARGET_SAMPLE_RATE,
|
||||
TARGET_CHANNELS,
|
||||
slot.unfilled_mut(),
|
||||
)
|
||||
};
|
||||
let n = match out_frames {
|
||||
Ok(0) => {
|
||||
capture.dropped_buffers.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
capture.convert_failures.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
Ok(n) => n,
|
||||
};
|
||||
let filled = (n as usize) * (TARGET_CHANNELS as usize);
|
||||
assert!(filled <= slot.capacity());
|
||||
slot.set_filled_len(filled);
|
||||
let mut handoff = Some(slot);
|
||||
unsafe {
|
||||
cb(
|
||||
capture.pcm_callback_ctx,
|
||||
&mut handoff as *mut Option<PooledPcmFrame>,
|
||||
n,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Capture {
|
||||
pub fn create(
|
||||
pid: i32,
|
||||
include_process_tree: bool,
|
||||
scope: CaptureScope,
|
||||
) -> Result<Box<Capture>, CreateError> {
|
||||
if !is_supported() {
|
||||
return Err(CreateError::Unsupported);
|
||||
}
|
||||
DEBUG_COREAUDIO.store(
|
||||
getenv_set(c"FLUXER_MAC_APP_AUDIO_DEBUG_COREAUDIO"),
|
||||
Ordering::Release,
|
||||
);
|
||||
DEBUG_CALLBACK_COUNT.store(0, Ordering::Release);
|
||||
|
||||
let root_pid = if scope == CaptureScope::System {
|
||||
unsafe { libc::getpid() }
|
||||
} else {
|
||||
pid
|
||||
};
|
||||
let related_pids = collect_target_pids(root_pid, include_process_tree);
|
||||
if related_pids.is_empty() {
|
||||
return Err(CreateError::NoRelatedProcesses);
|
||||
}
|
||||
let process_objects =
|
||||
collect_process_objects(&related_pids, scope == CaptureScope::Process);
|
||||
|
||||
let description = create_tap_description(&process_objects, root_pid, scope)?;
|
||||
let mut tap_id: AudioObjectID = kAudioObjectUnknown;
|
||||
let tap_status = unsafe { AudioHardwareCreateProcessTap(Some(&description), &mut tap_id) };
|
||||
|
||||
if tap_status != NO_ERR || tap_id == kAudioObjectUnknown {
|
||||
return Err(CreateError::CreateProcessTapFailed);
|
||||
}
|
||||
|
||||
let tap_uid = match copy_tap_uid(tap_id) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyProcessTap(tap_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let input_format = match read_tap_format(tap_id) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyProcessTap(tap_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let aggregate_description = match create_aggregate_description(&tap_uid, pid) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyProcessTap(tap_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let mut aggregate_device_id: AudioObjectID = kAudioObjectUnknown;
|
||||
let aggregate_status = unsafe {
|
||||
let dict_ref: &CFDictionary =
|
||||
&*(&*aggregate_description as *const _ as *const CFDictionary);
|
||||
AudioHardwareCreateAggregateDevice(
|
||||
dict_ref,
|
||||
ptr::NonNull::from(&mut aggregate_device_id),
|
||||
)
|
||||
};
|
||||
if aggregate_status != NO_ERR || aggregate_device_id == kAudioObjectUnknown {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyProcessTap(tap_id);
|
||||
}
|
||||
return Err(CreateError::CreateAggregateDeviceFailed);
|
||||
}
|
||||
|
||||
let mut capture = Box::new(Capture {
|
||||
tap_id,
|
||||
aggregate_device_id,
|
||||
io_proc_id: None,
|
||||
input_format,
|
||||
running: AtomicBool::new(false),
|
||||
pcm_callback: None,
|
||||
pcm_callback_ctx: ptr::null_mut(),
|
||||
pcm_pool: None,
|
||||
convert_failures: AtomicU64::new(0),
|
||||
dropped_buffers: AtomicU64::new(0),
|
||||
refresher: None,
|
||||
});
|
||||
let mut io_proc_id: AudioDeviceIOProcID = None;
|
||||
let io_status = unsafe {
|
||||
AudioDeviceCreateIOProcID(
|
||||
aggregate_device_id,
|
||||
Some(io_proc),
|
||||
&mut *capture as *mut Capture as *mut c_void,
|
||||
ptr::NonNull::from(&mut io_proc_id),
|
||||
)
|
||||
};
|
||||
if io_status != NO_ERR || io_proc_id.is_none() {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyAggregateDevice(aggregate_device_id);
|
||||
let _ = AudioHardwareDestroyProcessTap(tap_id);
|
||||
}
|
||||
return Err(CreateError::CreateIOProcFailed);
|
||||
}
|
||||
capture.io_proc_id = io_proc_id;
|
||||
|
||||
if scope == CaptureScope::Process && include_process_tree {
|
||||
capture.refresher =
|
||||
spawn_late_spawn_refresher(tap_id, root_pid, scope, process_objects.to_vec());
|
||||
}
|
||||
Ok(capture)
|
||||
}
|
||||
|
||||
pub fn set_pcm_callback(&mut self, cb: PcmCallback, ctx: *mut c_void) {
|
||||
self.pcm_callback = Some(cb);
|
||||
self.pcm_callback_ctx = ctx;
|
||||
}
|
||||
|
||||
pub fn set_pcm_pool(&mut self, pool: PcmFramePool) {
|
||||
assert!(pool.capacity() > 0);
|
||||
assert!(pool.samples_per_slot() > 0);
|
||||
self.pcm_pool = Some(pool);
|
||||
}
|
||||
|
||||
pub fn diagnostics(&self) -> TapDiagnostics {
|
||||
let diagnostics = TapDiagnostics {
|
||||
convert_failures: self.convert_failures.load(Ordering::Relaxed),
|
||||
dropped_buffers: self.dropped_buffers.load(Ordering::Relaxed),
|
||||
};
|
||||
assert!(diagnostics.convert_failures <= u64::MAX / 2);
|
||||
assert!(diagnostics.dropped_buffers <= u64::MAX / 2);
|
||||
diagnostics
|
||||
}
|
||||
|
||||
pub fn start(&mut self) -> Result<(), CreateError> {
|
||||
if self.io_proc_id.is_none() || self.aggregate_device_id == kAudioObjectUnknown {
|
||||
return Err(CreateError::StartDeviceFailed);
|
||||
}
|
||||
self.running.store(true, Ordering::Release);
|
||||
let status = unsafe { AudioDeviceStart(self.aggregate_device_id, self.io_proc_id) };
|
||||
if status != NO_ERR {
|
||||
self.running.store(false, Ordering::Release);
|
||||
return Err(CreateError::StartDeviceFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(&mut self) {
|
||||
self.running.store(false, Ordering::Release);
|
||||
if self.aggregate_device_id != kAudioObjectUnknown && self.io_proc_id.is_some() {
|
||||
unsafe {
|
||||
let _ = AudioDeviceStop(self.aggregate_device_id, self.io_proc_id);
|
||||
let _ = AudioDeviceDestroyIOProcID(self.aggregate_device_id, self.io_proc_id);
|
||||
}
|
||||
self.io_proc_id = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Capture {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut r) = self.refresher.take() {
|
||||
r.shutdown();
|
||||
}
|
||||
self.stop();
|
||||
if self.aggregate_device_id != kAudioObjectUnknown {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyAggregateDevice(self.aggregate_device_id);
|
||||
}
|
||||
self.aggregate_device_id = kAudioObjectUnknown;
|
||||
}
|
||||
if self.tap_id != kAudioObjectUnknown {
|
||||
unsafe {
|
||||
let _ = AudioHardwareDestroyProcessTap(self.tap_id);
|
||||
}
|
||||
self.tap_id = kAudioObjectUnknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_process_objects_to_tap(
|
||||
tap_id: AudioObjectID,
|
||||
objects: &[AudioObjectID],
|
||||
root_pid: i32,
|
||||
scope: CaptureScope,
|
||||
) -> OSStatus {
|
||||
if tap_id == kAudioObjectUnknown {
|
||||
return -1;
|
||||
}
|
||||
let description = match create_tap_description(objects, root_pid, scope) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
let address = AudioObjectPropertyAddress {
|
||||
mSelector: kAudioTapPropertyDescription,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain,
|
||||
};
|
||||
|
||||
let desc_ptr: *const CATapDescription = &*description;
|
||||
let mut desc_holder: *const CATapDescription = desc_ptr;
|
||||
unsafe {
|
||||
AudioObjectSetPropertyData(
|
||||
tap_id,
|
||||
ptr::NonNull::from(&address),
|
||||
0,
|
||||
ptr::null(),
|
||||
core::mem::size_of::<*const CATapDescription>() as u32,
|
||||
ptr::NonNull::from(&mut desc_holder).cast::<c_void>(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_late_spawn_refresher(
|
||||
tap_id: AudioObjectID,
|
||||
root_pid: i32,
|
||||
scope: CaptureScope,
|
||||
initial_objects: Vec<AudioObjectID>,
|
||||
) -> Option<RefresherHandle> {
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let alive_for_thread = alive.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("fluxer-mac-tap-refresh".into())
|
||||
.spawn(move || {
|
||||
run_late_spawn_refresher(tap_id, root_pid, scope, initial_objects, alive_for_thread);
|
||||
})
|
||||
.ok()?;
|
||||
Some(RefresherHandle {
|
||||
alive,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_late_spawn_refresher(
|
||||
tap_id: AudioObjectID,
|
||||
root_pid: i32,
|
||||
scope: CaptureScope,
|
||||
initial_objects: Vec<AudioObjectID>,
|
||||
alive: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut previous: Vec<AudioObjectID> = initial_objects;
|
||||
previous.sort_unstable();
|
||||
previous.dedup();
|
||||
|
||||
while alive.load(Ordering::Acquire) {
|
||||
thread::sleep(LATE_SPAWN_REFRESH_INTERVAL);
|
||||
if !alive.load(Ordering::Acquire) {
|
||||
break;
|
||||
}
|
||||
let related = process_tree::collect_related_pids(root_pid, MAX_RELATED_PROCESSES);
|
||||
if related.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut current = collect_process_objects(&related, scope == CaptureScope::Process);
|
||||
current.sort_unstable();
|
||||
current.dedup();
|
||||
if current == previous {
|
||||
continue;
|
||||
}
|
||||
|
||||
if current.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let status = apply_process_objects_to_tap(tap_id, ¤t, root_pid, scope);
|
||||
if status == NO_ERR {
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coreaudio_error_message(e: &CreateError) -> &'static str {
|
||||
match e {
|
||||
CreateError::Unsupported => "CoreAudio process taps unavailable",
|
||||
CreateError::NoRelatedProcesses => "No related process for selected app",
|
||||
CreateError::NoProcessObjects => "No CoreAudio process object for selected app",
|
||||
CreateError::FoundationObjectFailed => "CoreAudio tap configuration failed",
|
||||
CreateError::CreateProcessTapFailed => "CoreAudio process tap creation failed",
|
||||
CreateError::ReadTapUidFailed => "CoreAudio process tap UID lookup failed",
|
||||
CreateError::ReadTapFormatFailed => "CoreAudio process tap format lookup failed",
|
||||
CreateError::CreateAggregateDeviceFailed => {
|
||||
"CoreAudio process tap aggregate device creation failed"
|
||||
}
|
||||
CreateError::CreateIOProcFailed => "CoreAudio process tap IOProc creation failed",
|
||||
CreateError::StartDeviceFailed => "CoreAudio process tap start failed",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod io_proc_diagnostics_tests {
|
||||
use super::*;
|
||||
use crate::audio_converter::AudioBuffer;
|
||||
use core::mem::size_of;
|
||||
use core::ptr::NonNull;
|
||||
use core::sync::atomic::AtomicU32 as TestAtomicU32;
|
||||
use objc2_core_audio_types::AudioTimeStamp;
|
||||
|
||||
fn make_idle_capture(input_format: AudioStreamBasicDescription) -> Box<Capture> {
|
||||
Box::new(Capture {
|
||||
tap_id: kAudioObjectUnknown,
|
||||
aggregate_device_id: kAudioObjectUnknown,
|
||||
io_proc_id: None,
|
||||
input_format,
|
||||
running: AtomicBool::new(true),
|
||||
pcm_callback: None,
|
||||
pcm_callback_ctx: ptr::null_mut(),
|
||||
pcm_pool: None,
|
||||
convert_failures: AtomicU64::new(0),
|
||||
dropped_buffers: AtomicU64::new(0),
|
||||
refresher: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn make_test_pool() -> PcmFramePool {
|
||||
PcmFramePool::new(2, 8_192).expect("test pool builds")
|
||||
}
|
||||
|
||||
fn call_io_proc(capture: &Capture, abl: &mut AudioBufferList) -> OSStatus {
|
||||
let mut ts_now: AudioTimeStamp = unsafe { core::mem::zeroed() };
|
||||
let mut ts_input: AudioTimeStamp = unsafe { core::mem::zeroed() };
|
||||
let mut ts_output: AudioTimeStamp = unsafe { core::mem::zeroed() };
|
||||
let mut out_abl = AudioBufferList {
|
||||
m_number_buffers: 0,
|
||||
buffers: [AudioBuffer {
|
||||
m_number_channels: 0,
|
||||
m_data_byte_size: 0,
|
||||
m_data: ptr::null_mut(),
|
||||
}],
|
||||
};
|
||||
unsafe {
|
||||
io_proc(
|
||||
0,
|
||||
NonNull::from(&mut ts_now),
|
||||
NonNull::new(
|
||||
abl as *mut AudioBufferList as *mut objc2_core_audio_types::AudioBufferList,
|
||||
)
|
||||
.expect("input abl non-null"),
|
||||
NonNull::from(&mut ts_input),
|
||||
NonNull::new(
|
||||
&mut out_abl as *mut AudioBufferList
|
||||
as *mut objc2_core_audio_types::AudioBufferList,
|
||||
)
|
||||
.expect("output abl non-null"),
|
||||
NonNull::from(&mut ts_output),
|
||||
capture as *const Capture as *mut c_void,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn interleaved_abl(data: &mut [f32]) -> AudioBufferList {
|
||||
AudioBufferList {
|
||||
m_number_buffers: 1,
|
||||
buffers: [AudioBuffer {
|
||||
m_number_channels: TARGET_CHANNELS,
|
||||
m_data_byte_size: (data.len() * size_of::<f32>()) as u32,
|
||||
m_data: data.as_mut_ptr() as *mut c_void,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_failures_counted_for_unsupported_format() {
|
||||
let capture = make_idle_capture(AudioStreamBasicDescription::default());
|
||||
let mut data = [0.0_f32; 8];
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
assert_eq!(status, NO_ERR);
|
||||
let diagnostics = capture.diagnostics();
|
||||
assert_eq!(diagnostics.convert_failures, 1);
|
||||
assert_eq!(diagnostics.dropped_buffers, 0);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn noop_pcm_cb(
|
||||
_ctx: *mut c_void,
|
||||
_slot: *mut Option<PooledPcmFrame>,
|
||||
_frames: u32,
|
||||
) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_buffers_counted_when_pool_exhausted() {
|
||||
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
|
||||
let pool = make_test_pool();
|
||||
capture.set_pcm_callback(noop_pcm_cb, ptr::null_mut());
|
||||
capture.set_pcm_pool(pool.clone());
|
||||
let mut held = Vec::with_capacity(pool.capacity() as usize);
|
||||
for _ in 0..pool.capacity() {
|
||||
held.push(pool.try_acquire().expect("slot in capacity"));
|
||||
}
|
||||
let mut data = [0.25_f32; 96];
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
drop(held);
|
||||
assert_eq!(status, NO_ERR);
|
||||
let diagnostics = capture.diagnostics();
|
||||
assert_eq!(diagnostics.dropped_buffers, 1);
|
||||
assert_eq!(diagnostics.convert_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_buffers_counted_when_slot_too_small_for_conversion() {
|
||||
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
|
||||
let pool = PcmFramePool::new(2, 16).expect("tiny pool builds");
|
||||
capture.set_pcm_callback(noop_pcm_cb, ptr::null_mut());
|
||||
capture.set_pcm_pool(pool);
|
||||
let mut data = [0.25_f32; 96];
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
assert_eq!(status, NO_ERR);
|
||||
let diagnostics = capture.diagnostics();
|
||||
assert_eq!(diagnostics.dropped_buffers, 1);
|
||||
assert_eq!(diagnostics.convert_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_buffers_counted_for_oversize_input() {
|
||||
let capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
|
||||
let mut data = [0.0_f32; 8];
|
||||
let bytes_per_frame = (size_of::<f32>() as u32) * TARGET_CHANNELS;
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
abl.buffers[0].m_data_byte_size = (MAX_CALLBACK_INPUT_FRAMES + 1) * bytes_per_frame;
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
assert_eq!(status, NO_ERR);
|
||||
let diagnostics = capture.diagnostics();
|
||||
assert_eq!(diagnostics.dropped_buffers, 1);
|
||||
assert_eq!(diagnostics.convert_failures, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_capture_counts_nothing() {
|
||||
let capture = make_idle_capture(AudioStreamBasicDescription::default());
|
||||
capture.running.store(false, Ordering::Release);
|
||||
let mut data = [0.0_f32; 8];
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
assert_eq!(status, NO_ERR);
|
||||
let diagnostics = capture.diagnostics();
|
||||
assert_eq!(diagnostics.convert_failures, 0);
|
||||
assert_eq!(diagnostics.dropped_buffers, 0);
|
||||
}
|
||||
|
||||
static OBSERVED_FRAMES: TestAtomicU32 = TestAtomicU32::new(0);
|
||||
static OBSERVED_FILLED: TestAtomicU32 = TestAtomicU32::new(0);
|
||||
|
||||
unsafe extern "C" fn observing_pcm_cb(
|
||||
_ctx: *mut c_void,
|
||||
slot: *mut Option<PooledPcmFrame>,
|
||||
frames: u32,
|
||||
) {
|
||||
assert!(!slot.is_null());
|
||||
let frame = unsafe { (*slot).take() }.expect("slot delivered");
|
||||
assert_eq!(
|
||||
frame.filled_len(),
|
||||
(frames as usize) * (TARGET_CHANNELS as usize)
|
||||
);
|
||||
assert_eq!(frame.data_slice()[0], 0.25);
|
||||
OBSERVED_FILLED.store(frame.filled_len() as u32, Ordering::Release);
|
||||
OBSERVED_FRAMES.store(frames, Ordering::Release);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_conversion_counts_nothing_and_invokes_callback() {
|
||||
OBSERVED_FRAMES.store(0, Ordering::Release);
|
||||
OBSERVED_FILLED.store(0, Ordering::Release);
|
||||
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
|
||||
let pool = make_test_pool();
|
||||
capture.set_pcm_callback(observing_pcm_cb, ptr::null_mut());
|
||||
capture.set_pcm_pool(pool.clone());
|
||||
let mut data = [0.25_f32; 96];
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
assert_eq!(status, NO_ERR);
|
||||
let diagnostics = capture.diagnostics();
|
||||
assert_eq!(diagnostics.convert_failures, 0);
|
||||
assert_eq!(diagnostics.dropped_buffers, 0);
|
||||
assert_eq!(OBSERVED_FRAMES.load(Ordering::Acquire), 48);
|
||||
assert_eq!(OBSERVED_FILLED.load(Ordering::Acquire), 96);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
assert_eq!(pool.stats().released, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untaken_slot_returns_to_pool() {
|
||||
let mut capture = make_idle_capture(ac::build_input_asbd(48_000.0, TARGET_CHANNELS, false));
|
||||
let pool = make_test_pool();
|
||||
capture.set_pcm_callback(noop_pcm_cb, ptr::null_mut());
|
||||
capture.set_pcm_pool(pool.clone());
|
||||
let mut data = [0.25_f32; 96];
|
||||
let mut abl = interleaved_abl(&mut data);
|
||||
let status = call_io_proc(&capture, &mut abl);
|
||||
assert_eq!(status, NO_ERR);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
assert_eq!(pool.stats().acquired, 1);
|
||||
assert_eq!(pool.stats().released, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use core::ffi::CStr;
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2::runtime::ProtocolObject;
|
||||
use objc2_foundation::{
|
||||
NSError, NSMutableArray, NSMutableDictionary, NSNumber, NSObject, NSProcessInfo, NSString,
|
||||
};
|
||||
|
||||
pub fn nsstring_from_cstr(s: &CStr) -> Retained<NSString> {
|
||||
match s.to_str() {
|
||||
Ok(v) => NSString::from_str(v),
|
||||
Err(_) => NSString::from_str(""),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nsstring_from_str(s: &str) -> Retained<NSString> {
|
||||
NSString::from_str(s)
|
||||
}
|
||||
|
||||
pub fn nsstring_to_string(s: Option<&NSString>) -> String {
|
||||
match s {
|
||||
Some(v) => v.to_string(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ns_mutable_array_with_capacity(capacity: usize) -> Retained<NSMutableArray<NSObject>> {
|
||||
NSMutableArray::<NSObject>::arrayWithCapacity(capacity)
|
||||
}
|
||||
|
||||
pub fn ns_mutable_dictionary_with_capacity() -> Retained<NSMutableDictionary<NSString, NSObject>> {
|
||||
NSMutableDictionary::<NSString, NSObject>::new()
|
||||
}
|
||||
|
||||
pub fn dict_set_str_key(
|
||||
dict: &NSMutableDictionary<NSString, NSObject>,
|
||||
key: &CStr,
|
||||
value: &NSObject,
|
||||
) {
|
||||
let key_ns = nsstring_from_cstr(key);
|
||||
let key_proto = ProtocolObject::from_ref(&*key_ns);
|
||||
unsafe { dict.setObject_forKey(value, key_proto) };
|
||||
}
|
||||
|
||||
pub fn operating_system_version_string() -> String {
|
||||
let info = NSProcessInfo::processInfo();
|
||||
info.operatingSystemVersionString().to_string()
|
||||
}
|
||||
|
||||
pub fn ns_error_localized_description(err: &NSError) -> String {
|
||||
err.localizedDescription().to_string()
|
||||
}
|
||||
|
||||
pub fn number_with_unsigned_int(value: u32) -> Retained<NSNumber> {
|
||||
NSNumber::new_u32(value)
|
||||
}
|
||||
|
||||
pub fn number_with_bool(value: bool) -> Retained<NSNumber> {
|
||||
NSNumber::new_bool(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nsstring_round_trip() {
|
||||
let s = nsstring_from_cstr(c"hello fluxer");
|
||||
assert_eq!("hello fluxer", nsstring_to_string(Some(&s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nsstring_to_string_handles_none() {
|
||||
assert_eq!(String::new(), nsstring_to_string(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_array_initial_count_is_zero() {
|
||||
let arr = ns_mutable_array_with_capacity(4);
|
||||
assert_eq!(0, arr.count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutable_dictionary_initial_count_is_zero() {
|
||||
let d = ns_mutable_dictionary_with_capacity();
|
||||
assert_eq!(0, d.count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#![deny(clippy::all)]
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#![allow(dead_code)]
|
||||
#![allow(clippy::missing_safety_doc)]
|
||||
#![allow(clippy::collapsible_if)]
|
||||
#![allow(clippy::manual_is_multiple_of)]
|
||||
#![allow(clippy::manual_slice_size_calculation)]
|
||||
#![allow(clippy::unnecessary_cast)]
|
||||
#![allow(clippy::not_unsafe_ptr_arg_deref)]
|
||||
#![allow(clippy::missing_transmute_annotations)]
|
||||
#![allow(clippy::missing_const_for_thread_local)]
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
pub mod audio_converter;
|
||||
pub mod os_version;
|
||||
pub mod pcm_pool;
|
||||
pub mod process_tree;
|
||||
pub mod related_app;
|
||||
pub mod source_state;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod audio_source;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod coreaudio_tap;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod foundation;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod sck;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod sck_async;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod napi_surface_macos;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod napi_surface_stub;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use napi::bindgen_prelude::{Error, Result, Status};
|
||||
use napi_derive::napi;
|
||||
|
||||
fn unsupported() -> Error {
|
||||
Error::new(
|
||||
Status::GenericFailure,
|
||||
"@fluxer/mac-app-audio is only supported on macOS",
|
||||
)
|
||||
}
|
||||
|
||||
#[napi(js_name = "pidFromWindowId")]
|
||||
pub fn pid_from_window_id(_window_id: i64) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
#[napi(js_name = "listAudibleApplications")]
|
||||
pub fn list_audible_applications() -> Result<Vec<String>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[napi(js_name = "getBackendAvailability")]
|
||||
pub fn get_backend_availability() -> Result<()> {
|
||||
Err(unsupported())
|
||||
}
|
||||
|
||||
#[napi(object, js_name = "MacAppAudioBackendInfo")]
|
||||
pub struct MacAppAudioBackendInfo {
|
||||
pub backend: String,
|
||||
pub supported: bool,
|
||||
pub reason: String,
|
||||
#[napi(js_name = "minMacosVersion")]
|
||||
pub min_macos_version: String,
|
||||
#[napi(js_name = "minMacosVersionCoreaudio")]
|
||||
pub min_macos_version_coreaudio: String,
|
||||
#[napi(js_name = "detectedMacosVersion")]
|
||||
pub detected_macos_version: Option<String>,
|
||||
#[napi(js_name = "sckAvailable")]
|
||||
pub sck_available: bool,
|
||||
#[napi(js_name = "coreaudioAvailable")]
|
||||
pub coreaudio_available: bool,
|
||||
}
|
||||
|
||||
#[napi(js_name = "getBackendInfo")]
|
||||
pub fn get_backend_info() -> MacAppAudioBackendInfo {
|
||||
use crate::os_version::{COREAUDIO_TAP_MIN_MACOS, SCK_MIN_MACOS, format_version};
|
||||
MacAppAudioBackendInfo {
|
||||
backend: "mac-app-audio".to_owned(),
|
||||
supported: false,
|
||||
reason: "@fluxer/mac-app-audio is only supported on macOS".to_owned(),
|
||||
min_macos_version: format_version(SCK_MIN_MACOS),
|
||||
min_macos_version_coreaudio: format_version(COREAUDIO_TAP_MIN_MACOS),
|
||||
detected_macos_version: None,
|
||||
sck_available: false,
|
||||
coreaudio_available: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct ProcessLoopback;
|
||||
|
||||
#[napi]
|
||||
impl ProcessLoopback {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Result<Self> {
|
||||
Err(unsupported())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub const SCK_MIN_MACOS: (i64, i64, i64) = (12, 3, 0);
|
||||
|
||||
pub const COREAUDIO_TAP_MIN_MACOS: (i64, i64, i64) = (14, 2, 0);
|
||||
|
||||
pub fn meets_floor(version: (i64, i64, i64), floor: (i64, i64, i64)) -> bool {
|
||||
if version.0 != floor.0 {
|
||||
return version.0 > floor.0;
|
||||
}
|
||||
if version.1 != floor.1 {
|
||||
return version.1 > floor.1;
|
||||
}
|
||||
version.2 >= floor.2
|
||||
}
|
||||
|
||||
pub fn format_version(version: (i64, i64, i64)) -> String {
|
||||
if version.2 == 0 {
|
||||
format!("{}.{}", version.0, version.1)
|
||||
} else {
|
||||
format!("{}.{}.{}", version.0, version.1, version.2)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
|
||||
use objc2_foundation::NSProcessInfo;
|
||||
let info = NSProcessInfo::processInfo();
|
||||
let v = info.operatingSystemVersion();
|
||||
Some((
|
||||
v.majorVersion as i64,
|
||||
v.minorVersion as i64,
|
||||
v.patchVersion as i64,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn current_macos_version() -> Option<(i64, i64, i64)> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SupportClassification {
|
||||
pub supported: bool,
|
||||
pub sck_available: bool,
|
||||
pub coreaudio_available: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn classify_support(detected: Option<(i64, i64, i64)>) -> SupportClassification {
|
||||
let min_sck = format_version(SCK_MIN_MACOS);
|
||||
let min_coreaudio = format_version(COREAUDIO_TAP_MIN_MACOS);
|
||||
match detected {
|
||||
None => SupportClassification {
|
||||
supported: false,
|
||||
sck_available: false,
|
||||
coreaudio_available: false,
|
||||
reason: "mac-app-audio could not detect the running macOS version. \
|
||||
Per-app and self-excluding desktop audio capture unavailable."
|
||||
.to_owned(),
|
||||
},
|
||||
Some(v) => {
|
||||
let detected_str = format_version(v);
|
||||
let sck_ok = meets_floor(v, SCK_MIN_MACOS);
|
||||
let coreaudio_ok = meets_floor(v, COREAUDIO_TAP_MIN_MACOS);
|
||||
let supported = sck_ok || coreaudio_ok;
|
||||
let reason = if supported {
|
||||
if coreaudio_ok {
|
||||
format!(
|
||||
"mac-app-audio supported on macOS {detected_str} \
|
||||
(CoreAudio process tap, requires macOS {min_coreaudio}+; \
|
||||
ScreenCaptureKit fallback requires macOS {min_sck}+)."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"mac-app-audio supported on macOS {detected_str} \
|
||||
(ScreenCaptureKit per-app capture, requires macOS {min_sck}+). \
|
||||
CoreAudio process tap requires macOS {min_coreaudio}+ \
|
||||
and is unavailable here."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"mac-app-audio requires macOS {min_sck}+ (ScreenCaptureKit). \
|
||||
This Mac is running macOS {detected_str}. Per-app audio capture \
|
||||
unavailable; Fluxer must not use a broader audio route that could \
|
||||
include unrelated apps or call audio."
|
||||
)
|
||||
};
|
||||
SupportClassification {
|
||||
supported,
|
||||
sck_available: sck_ok,
|
||||
coreaudio_available: coreaudio_ok,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn meets_floor_exact_match() {
|
||||
assert!(meets_floor((12, 3, 0), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meets_floor_higher_major() {
|
||||
assert!(meets_floor((14, 0, 0), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meets_floor_higher_minor() {
|
||||
assert!(meets_floor((12, 4, 0), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_below_floor_minor() {
|
||||
assert!(!meets_floor((12, 2, 9), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_below_floor_major() {
|
||||
assert!(!meets_floor((11, 7, 10), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_macos_10_15_for_sck() {
|
||||
assert!(!meets_floor((10, 15, 7), SCK_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coreaudio_floor_14_2() {
|
||||
assert!(meets_floor((14, 2, 0), COREAUDIO_TAP_MIN_MACOS));
|
||||
assert!(!meets_floor((14, 1, 9), COREAUDIO_TAP_MIN_MACOS));
|
||||
assert!(meets_floor((15, 0, 0), COREAUDIO_TAP_MIN_MACOS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_version_trims_zero_patch() {
|
||||
assert_eq!("12.3", format_version((12, 3, 0)));
|
||||
assert_eq!("14.2.1", format_version((14, 2, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_version_is_unsupported() {
|
||||
let c = classify_support(None);
|
||||
assert!(!c.supported);
|
||||
assert!(!c.sck_available);
|
||||
assert!(!c.coreaudio_available);
|
||||
assert!(c.reason.contains("could not detect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_macos_10_15_is_unsupported_and_mentions_min_version() {
|
||||
let c = classify_support(Some((10, 15, 7)));
|
||||
assert!(!c.supported);
|
||||
assert!(!c.sck_available);
|
||||
assert!(!c.coreaudio_available);
|
||||
assert!(c.reason.contains("macOS 12.3+"), "reason: {}", c.reason);
|
||||
assert!(c.reason.contains("macOS 10.15.7"), "reason: {}", c.reason);
|
||||
assert!(c.reason.contains("ScreenCaptureKit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_macos_12_3_is_sck_only() {
|
||||
let c = classify_support(Some((12, 3, 0)));
|
||||
assert!(c.supported);
|
||||
assert!(c.sck_available);
|
||||
assert!(!c.coreaudio_available);
|
||||
assert!(c.reason.contains("ScreenCaptureKit per-app capture"));
|
||||
assert!(
|
||||
c.reason
|
||||
.contains("CoreAudio process tap requires macOS 14.2+")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_macos_14_2_has_both_backends() {
|
||||
let c = classify_support(Some((14, 2, 0)));
|
||||
assert!(c.supported);
|
||||
assert!(c.sck_available);
|
||||
assert!(c.coreaudio_available);
|
||||
assert!(c.reason.contains("CoreAudio process tap"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_macos_15_is_supported() {
|
||||
let c = classify_support(Some((15, 0, 0)));
|
||||
assert!(c.supported);
|
||||
assert!(c.sck_available);
|
||||
assert!(c.coreaudio_available);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::cell::UnsafeCell;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
pub const PCM_POOL_CAP: usize = 16;
|
||||
pub const PCM_SLOT_SAMPLES_MAX: usize = 16_384;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PcmPoolError {
|
||||
ZeroCapacity,
|
||||
ZeroSamplesPerSlot,
|
||||
SamplesPerSlotTooLarge(usize),
|
||||
PayloadTooLarge { offered: usize, capacity: usize },
|
||||
}
|
||||
|
||||
impl fmt::Display for PcmPoolError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::ZeroCapacity => write!(f, "PcmFramePool capacity must be > 0"),
|
||||
Self::ZeroSamplesPerSlot => write!(f, "PcmFramePool samples_per_slot must be > 0"),
|
||||
Self::SamplesPerSlotTooLarge(n) => write!(
|
||||
f,
|
||||
"PcmFramePool samples_per_slot {n} exceeds PCM_SLOT_SAMPLES_MAX={PCM_SLOT_SAMPLES_MAX}"
|
||||
),
|
||||
Self::PayloadTooLarge { offered, capacity } => write!(
|
||||
f,
|
||||
"PcmFramePool payload {offered} samples exceeds slot capacity {capacity}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PcmPoolError {}
|
||||
|
||||
struct PcmSlotCell {
|
||||
inner: UnsafeCell<Box<[f32]>>,
|
||||
}
|
||||
|
||||
unsafe impl Send for PcmSlotCell {}
|
||||
unsafe impl Sync for PcmSlotCell {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PcmPoolStats {
|
||||
pub acquired: u64,
|
||||
pub released: u64,
|
||||
pub dropped: u64,
|
||||
pub in_flight: u32,
|
||||
}
|
||||
|
||||
pub(crate) struct PcmFramePoolInner {
|
||||
slots: Vec<PcmSlotCell>,
|
||||
free: Mutex<Vec<usize>>,
|
||||
capacity: u32,
|
||||
samples_per_slot: u32,
|
||||
acquired_total: AtomicU64,
|
||||
released_total: AtomicU64,
|
||||
dropped_total: AtomicU64,
|
||||
in_flight: AtomicU32,
|
||||
}
|
||||
|
||||
pub struct PcmFramePool {
|
||||
inner: Arc<PcmFramePoolInner>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PcmFramePool {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let stats = self.stats();
|
||||
f.debug_struct("PcmFramePool")
|
||||
.field("capacity", &self.inner.capacity)
|
||||
.field("samples_per_slot", &self.inner.samples_per_slot)
|
||||
.field("stats", &stats)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PcmFramePool {
|
||||
pub fn new(capacity: usize, samples_per_slot: usize) -> Result<Self, PcmPoolError> {
|
||||
if capacity == 0 {
|
||||
return Err(PcmPoolError::ZeroCapacity);
|
||||
}
|
||||
if samples_per_slot == 0 {
|
||||
return Err(PcmPoolError::ZeroSamplesPerSlot);
|
||||
}
|
||||
if samples_per_slot > PCM_SLOT_SAMPLES_MAX {
|
||||
return Err(PcmPoolError::SamplesPerSlotTooLarge(samples_per_slot));
|
||||
}
|
||||
assert!(capacity > 0);
|
||||
assert!(samples_per_slot > 0);
|
||||
assert!(samples_per_slot <= PCM_SLOT_SAMPLES_MAX);
|
||||
|
||||
let mut slots: Vec<PcmSlotCell> = Vec::with_capacity(capacity);
|
||||
for _ in 0..capacity {
|
||||
let buf: Box<[f32]> = vec![0.0_f32; samples_per_slot].into_boxed_slice();
|
||||
assert_eq!(buf.len(), samples_per_slot);
|
||||
slots.push(PcmSlotCell {
|
||||
inner: UnsafeCell::new(buf),
|
||||
});
|
||||
}
|
||||
assert_eq!(slots.len(), capacity);
|
||||
|
||||
let mut free: Vec<usize> = Vec::with_capacity(capacity);
|
||||
for index in 0..capacity {
|
||||
free.push(index);
|
||||
}
|
||||
assert_eq!(free.len(), capacity);
|
||||
|
||||
let cap_u32 = u32::try_from(capacity).map_err(|_| PcmPoolError::ZeroCapacity)?;
|
||||
let sps_u32 =
|
||||
u32::try_from(samples_per_slot).map_err(|_| PcmPoolError::ZeroSamplesPerSlot)?;
|
||||
let inner = PcmFramePoolInner {
|
||||
slots,
|
||||
free: Mutex::new(free),
|
||||
capacity: cap_u32,
|
||||
samples_per_slot: sps_u32,
|
||||
acquired_total: AtomicU64::new(0),
|
||||
released_total: AtomicU64::new(0),
|
||||
dropped_total: AtomicU64::new(0),
|
||||
in_flight: AtomicU32::new(0),
|
||||
};
|
||||
Ok(Self {
|
||||
inner: Arc::new(inner),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn try_acquire(&self) -> Option<PooledPcmFrame> {
|
||||
assert!(self.inner.capacity > 0);
|
||||
assert!(self.inner.samples_per_slot > 0);
|
||||
|
||||
let mut free = self.inner.free.lock();
|
||||
assert!(free.len() <= self.inner.capacity as usize);
|
||||
let index = match free.pop() {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
drop(free);
|
||||
self.inner.dropped_total.fetch_add(1, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
assert!(index < self.inner.capacity as usize);
|
||||
self.inner.acquired_total.fetch_add(1, Ordering::Relaxed);
|
||||
let after = self.inner.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
assert!(after <= self.inner.capacity);
|
||||
drop(free);
|
||||
|
||||
Some(PooledPcmFrame {
|
||||
slot_index: index,
|
||||
filled_len: 0,
|
||||
pool: Arc::clone(&self.inner),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 {
|
||||
let cap = self.inner.capacity;
|
||||
assert!(cap > 0);
|
||||
assert!(cap as usize == self.inner.slots.len());
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn samples_per_slot(&self) -> u32 {
|
||||
let sps = self.inner.samples_per_slot;
|
||||
assert!(sps > 0);
|
||||
assert!(sps as usize <= PCM_SLOT_SAMPLES_MAX);
|
||||
sps
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> PcmPoolStats {
|
||||
assert!(self.inner.capacity > 0);
|
||||
let in_flight = self.inner.in_flight.load(Ordering::Acquire);
|
||||
assert!(in_flight <= self.inner.capacity);
|
||||
let acquired = self.inner.acquired_total.load(Ordering::Relaxed);
|
||||
let released = self.inner.released_total.load(Ordering::Relaxed);
|
||||
let dropped = self.inner.dropped_total.load(Ordering::Relaxed);
|
||||
assert!(released <= acquired);
|
||||
PcmPoolStats {
|
||||
acquired,
|
||||
released,
|
||||
dropped,
|
||||
in_flight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for PcmFramePool {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PooledPcmFrame {
|
||||
slot_index: usize,
|
||||
filled_len: usize,
|
||||
pool: Arc<PcmFramePoolInner>,
|
||||
}
|
||||
|
||||
impl PooledPcmFrame {
|
||||
pub fn write(&mut self, samples: &[f32]) -> Result<(), PcmPoolError> {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let cap = self.pool.samples_per_slot as usize;
|
||||
if samples.len() > cap {
|
||||
return Err(PcmPoolError::PayloadTooLarge {
|
||||
offered: samples.len(),
|
||||
capacity: cap,
|
||||
});
|
||||
}
|
||||
assert!(samples.len() <= cap);
|
||||
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
|
||||
assert_eq!(buf.len(), cap);
|
||||
if !samples.is_empty() {
|
||||
buf[..samples.len()].copy_from_slice(samples);
|
||||
}
|
||||
self.filled_len = samples.len();
|
||||
assert!(self.filled_len <= cap);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn unfilled_mut(&mut self) -> &mut [f32] {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let cap = self.pool.samples_per_slot as usize;
|
||||
assert!(cap > 0);
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
|
||||
assert_eq!(buf.len(), cap);
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn set_filled_len(&mut self, len: usize) {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(len <= self.pool.samples_per_slot as usize);
|
||||
self.filled_len = len;
|
||||
}
|
||||
|
||||
pub fn data_slice(&self) -> &[f32] {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &[f32] = unsafe { &*cell.inner.get() };
|
||||
&buf[..self.filled_len]
|
||||
}
|
||||
|
||||
pub fn filled_len(&self) -> usize {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
|
||||
self.filled_len
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> usize {
|
||||
let cap = self.pool.samples_per_slot as usize;
|
||||
assert!(cap > 0);
|
||||
assert!(cap <= PCM_SLOT_SAMPLES_MAX);
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn as_mut_ptr(&mut self) -> *mut f32 {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
|
||||
assert_eq!(buf.len(), self.pool.samples_per_slot as usize);
|
||||
buf.as_mut_ptr()
|
||||
}
|
||||
|
||||
pub fn into_external_parts(mut self) -> (*mut f32, usize, Self) {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
assert!(self.filled_len <= self.pool.samples_per_slot as usize);
|
||||
let len = self.filled_len;
|
||||
let ptr = self.as_mut_ptr();
|
||||
assert!(!ptr.is_null());
|
||||
(ptr, len, self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PooledPcmFrame {
|
||||
fn drop(&mut self) {
|
||||
assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let mut free = self.pool.free.lock();
|
||||
assert!(free.len() < self.pool.capacity as usize);
|
||||
free.push(self.slot_index);
|
||||
let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
assert!(before >= 1);
|
||||
self.pool.released_total.fetch_add(1, Ordering::Relaxed);
|
||||
drop(free);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
const SLOT_SAMPLES: usize = 2_048;
|
||||
|
||||
fn default_pool() -> PcmFramePool {
|
||||
PcmFramePool::new(PCM_POOL_CAP, SLOT_SAMPLES).expect("default pool builds")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_capacity() {
|
||||
let err = PcmFramePool::new(0, SLOT_SAMPLES).unwrap_err();
|
||||
assert_eq!(err, PcmPoolError::ZeroCapacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_samples_per_slot() {
|
||||
let err = PcmFramePool::new(4, 0).unwrap_err();
|
||||
assert_eq!(err, PcmPoolError::ZeroSamplesPerSlot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_samples_per_slot_above_max() {
|
||||
let err = PcmFramePool::new(4, PCM_SLOT_SAMPLES_MAX + 1).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
PcmPoolError::SamplesPerSlotTooLarge(PCM_SLOT_SAMPLES_MAX + 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_release_cycle_increments_counters() {
|
||||
let pool = PcmFramePool::new(4, SLOT_SAMPLES).expect("pool");
|
||||
{
|
||||
let _slot = pool.try_acquire().expect("slot");
|
||||
let stats_held = pool.stats();
|
||||
assert_eq!(stats_held.acquired, 1);
|
||||
assert_eq!(stats_held.in_flight, 1);
|
||||
}
|
||||
let stats_after = pool.stats();
|
||||
assert_eq!(stats_after.acquired, 1);
|
||||
assert_eq!(stats_after.released, 1);
|
||||
assert_eq!(stats_after.in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_exhausts_at_cap_and_counts_drop() {
|
||||
let pool = default_pool();
|
||||
let mut held = Vec::with_capacity(PCM_POOL_CAP);
|
||||
for _ in 0..PCM_POOL_CAP {
|
||||
held.push(pool.try_acquire().expect("slot in capacity"));
|
||||
}
|
||||
assert!(pool.try_acquire().is_none());
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.dropped, 1);
|
||||
assert_eq!(stats.acquired as usize, PCM_POOL_CAP);
|
||||
assert_eq!(stats.in_flight as usize, PCM_POOL_CAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_data_slice_matches_payload() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let payload = [0.25_f32; 32];
|
||||
slot.write(&payload).expect("payload fits");
|
||||
assert_eq!(slot.data_slice(), &payload[..]);
|
||||
assert_eq!(slot.filled_len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_payload_larger_than_slot() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let big = [0.0_f32; 128];
|
||||
let err = slot.write(&big).unwrap_err();
|
||||
assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfilled_mut_then_set_filled_len_matches_data_slice() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
{
|
||||
let buf = slot.unfilled_mut();
|
||||
assert_eq!(buf.len(), 64);
|
||||
buf[0] = 0.5;
|
||||
buf[1] = -0.5;
|
||||
buf[2] = 1.0;
|
||||
}
|
||||
slot.set_filled_len(3);
|
||||
assert_eq!(slot.filled_len(), 3);
|
||||
assert_eq!(slot.data_slice(), &[0.5, -0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn set_filled_len_rejects_overflow() {
|
||||
let pool = PcmFramePool::new(1, 32).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.set_filled_len(33);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_external_parts_exposes_filled_pointer_and_length() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let payload = [1.5_f32; 16];
|
||||
slot.write(&payload).expect("write fits");
|
||||
let (ptr, len, owned) = slot.into_external_parts();
|
||||
assert!(!ptr.is_null());
|
||||
assert_eq!(len, 16);
|
||||
let observed = unsafe { core::slice::from_raw_parts(ptr, len) };
|
||||
assert_eq!(observed, &payload[..]);
|
||||
assert_eq!(pool.stats().in_flight, 1);
|
||||
drop(owned);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn into_external_parts_drop_returns_slot_to_pool() {
|
||||
let pool = PcmFramePool::new(1, 32).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[0.75_f32; 8]).expect("write");
|
||||
let (_ptr, _len, owned) = slot.into_external_parts();
|
||||
assert!(pool.try_acquire().is_none());
|
||||
drop(owned);
|
||||
let revived = pool.try_acquire().expect("revived");
|
||||
assert_eq!(revived.filled_len(), 0);
|
||||
drop(revived);
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pooled_frame_survives_send_across_threads() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[0.5_f32; 32]).expect("write");
|
||||
let handle = thread::spawn(move || {
|
||||
assert_eq!(slot.filled_len(), 32);
|
||||
assert_eq!(slot.data_slice()[0], 0.5);
|
||||
drop(slot);
|
||||
});
|
||||
handle.join().expect("worker");
|
||||
assert_eq!(pool.stats().in_flight, 0);
|
||||
assert_eq!(pool.stats().released, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_one_pool_round_trips() {
|
||||
let pool = PcmFramePool::new(1, 32).expect("pool");
|
||||
for _ in 0..5 {
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[1.0, 2.0, 3.0]).expect("write");
|
||||
assert_eq!(slot.data_slice(), &[1.0, 2.0, 3.0]);
|
||||
drop(slot);
|
||||
}
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.acquired, 5);
|
||||
assert_eq!(stats.released, 5);
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_pool_dimensions_match_constants() {
|
||||
let pool = default_pool();
|
||||
assert_eq!(pool.capacity() as usize, PCM_POOL_CAP);
|
||||
assert_eq!(pool.samples_per_slot() as usize, SLOT_SAMPLES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Info {
|
||||
pub pid: i32,
|
||||
pub parent_pid: i32,
|
||||
pub process_group_id: i32,
|
||||
}
|
||||
|
||||
pub type ResolverFn = fn(ctx: Option<&dyn ResolverCtx>, pid: i32) -> Option<Info>;
|
||||
|
||||
pub trait ResolverCtx {
|
||||
fn resolve(&self, pid: i32) -> Option<Info>;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod sys {
|
||||
use super::Info;
|
||||
use std::mem::{offset_of, size_of};
|
||||
|
||||
const PROC_PIDTBSDINFO: i32 = 3;
|
||||
const MAXCOMLEN: usize = 16;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default, Copy, Clone)]
|
||||
pub struct ProcBsdInfo {
|
||||
pub pbi_flags: u32,
|
||||
pub pbi_status: u32,
|
||||
pub pbi_xstatus: u32,
|
||||
pub pbi_pid: u32,
|
||||
pub pbi_ppid: u32,
|
||||
pub pbi_uid: u32,
|
||||
pub pbi_gid: u32,
|
||||
pub pbi_ruid: u32,
|
||||
pub pbi_rgid: u32,
|
||||
pub pbi_svuid: u32,
|
||||
pub pbi_svgid: u32,
|
||||
pub rfu_1: u32,
|
||||
pub pbi_comm: [u8; MAXCOMLEN],
|
||||
pub pbi_name: [u8; 2 * MAXCOMLEN],
|
||||
pub pbi_nfiles: u32,
|
||||
pub pbi_pgid: u32,
|
||||
pub pbi_pjobc: u32,
|
||||
pub e_tdev: u32,
|
||||
pub e_tpgid: u32,
|
||||
pub pbi_nice: i32,
|
||||
pub pbi_start_tvsec: u64,
|
||||
pub pbi_start_tvusec: u64,
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn proc_pidinfo(
|
||||
pid: i32,
|
||||
flavor: i32,
|
||||
arg: u64,
|
||||
buffer: *mut core::ffi::c_void,
|
||||
buffersize: i32,
|
||||
) -> i32;
|
||||
pub fn proc_listallpids(buffer: *mut core::ffi::c_void, buffersize: i32) -> i32;
|
||||
}
|
||||
|
||||
pub fn info_for_pid(pid: i32) -> Option<Info> {
|
||||
if pid <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut raw = ProcBsdInfo::default();
|
||||
let copied = unsafe {
|
||||
proc_pidinfo(
|
||||
pid,
|
||||
PROC_PIDTBSDINFO,
|
||||
0,
|
||||
(&raw mut raw) as *mut _ as *mut _,
|
||||
size_of::<ProcBsdInfo>() as i32,
|
||||
)
|
||||
};
|
||||
let min = (offset_of!(ProcBsdInfo, pbi_pgid) + size_of::<u32>()) as i32;
|
||||
if copied < min {
|
||||
return None;
|
||||
}
|
||||
Some(Info {
|
||||
pid: raw.pbi_pid as i32,
|
||||
parent_pid: raw.pbi_ppid as i32,
|
||||
process_group_id: raw.pbi_pgid as i32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn info_for_pid(pid: i32) -> Option<Info> {
|
||||
sys::info_for_pid(pid)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn info_for_pid(_pid: i32) -> Option<Info> {
|
||||
None
|
||||
}
|
||||
|
||||
fn live_resolver(_ctx: Option<&dyn ResolverCtx>, pid: i32) -> Option<Info> {
|
||||
info_for_pid(pid)
|
||||
}
|
||||
|
||||
pub fn is_same_launch_tree(candidate_pid: i32, target_pid: i32, target_info: Option<Info>) -> bool {
|
||||
is_same_launch_tree_with_resolver(candidate_pid, target_pid, target_info, None, live_resolver)
|
||||
}
|
||||
|
||||
pub fn is_same_launch_tree_with_resolver(
|
||||
candidate_pid: i32,
|
||||
target_pid: i32,
|
||||
target_info: Option<Info>,
|
||||
ctx: Option<&dyn ResolverCtx>,
|
||||
resolver: ResolverFn,
|
||||
) -> bool {
|
||||
if candidate_pid <= 0 || target_pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
if candidate_pid == target_pid {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut current = match resolver(ctx, candidate_pid) {
|
||||
Some(i) => i,
|
||||
None => return false,
|
||||
};
|
||||
if shares_process_group(current, target_pid, target_info) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut depth = 0usize;
|
||||
while depth < 64 {
|
||||
let parent = current.parent_pid;
|
||||
if parent == target_pid {
|
||||
return true;
|
||||
}
|
||||
if parent <= 1 || parent == current.pid {
|
||||
return false;
|
||||
}
|
||||
current = match resolver(ctx, parent) {
|
||||
Some(i) => i,
|
||||
None => return false,
|
||||
};
|
||||
depth += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn shares_process_group(candidate: Info, target_pid: i32, target_info: Option<Info>) -> bool {
|
||||
let cg = candidate.process_group_id;
|
||||
if cg <= 0 {
|
||||
return false;
|
||||
}
|
||||
if cg == target_pid {
|
||||
return true;
|
||||
}
|
||||
if let Some(t) = target_info {
|
||||
return t.process_group_id > 0 && cg == t.process_group_id;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn append_pid(out: &mut Vec<i32>, max: usize, pid: i32) -> bool {
|
||||
if out.contains(&pid) {
|
||||
return true;
|
||||
}
|
||||
if out.len() >= max {
|
||||
return false;
|
||||
}
|
||||
out.push(pid);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn collect_related_pids_with_resolver(
|
||||
target_pid: i32,
|
||||
target_info: Option<Info>,
|
||||
candidates: &[i32],
|
||||
max_count: usize,
|
||||
ctx: Option<&dyn ResolverCtx>,
|
||||
resolver: ResolverFn,
|
||||
) -> Vec<i32> {
|
||||
if target_pid <= 0 || max_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cap = candidates.len().max(1).min(max_count);
|
||||
let mut out: Vec<i32> = Vec::with_capacity(cap);
|
||||
let _ = append_pid(&mut out, cap, target_pid);
|
||||
for &pid in candidates {
|
||||
if out.len() >= cap {
|
||||
break;
|
||||
}
|
||||
if pid <= 0 || pid == target_pid {
|
||||
continue;
|
||||
}
|
||||
if !is_same_launch_tree_with_resolver(pid, target_pid, target_info, ctx, resolver) {
|
||||
continue;
|
||||
}
|
||||
let _ = append_pid(&mut out, cap, pid);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn collect_related_pids(target_pid: i32, max_count: usize) -> Vec<i32> {
|
||||
if target_pid <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let reported = unsafe { sys::proc_listallpids(core::ptr::null_mut(), 0) };
|
||||
if reported <= 0 {
|
||||
return vec![target_pid];
|
||||
}
|
||||
let mut all_pids: Vec<i32> = vec![0; reported as usize];
|
||||
let copied = unsafe {
|
||||
sys::proc_listallpids(
|
||||
all_pids.as_mut_ptr() as *mut _,
|
||||
(all_pids.len() * core::mem::size_of::<i32>()) as i32,
|
||||
)
|
||||
};
|
||||
if copied <= 0 {
|
||||
return vec![target_pid];
|
||||
}
|
||||
let count = (copied as usize).min(all_pids.len());
|
||||
collect_related_pids_with_resolver(
|
||||
target_pid,
|
||||
info_for_pid(target_pid),
|
||||
&all_pids[..count],
|
||||
max_count,
|
||||
None,
|
||||
live_resolver,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn collect_related_pids(target_pid: i32, _max_count: usize) -> Vec<i32> {
|
||||
if target_pid <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![target_pid]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct StaticCtx<'a> {
|
||||
infos: &'a [Info],
|
||||
}
|
||||
impl<'a> ResolverCtx for StaticCtx<'a> {
|
||||
fn resolve(&self, pid: i32) -> Option<Info> {
|
||||
self.infos.iter().copied().find(|i| i.pid == pid)
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_resolver(ctx: Option<&dyn ResolverCtx>, pid: i32) -> Option<Info> {
|
||||
ctx?.resolve(pid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_launch_tree_includes_direct_child() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 100,
|
||||
parent_pid: 1,
|
||||
process_group_id: 100,
|
||||
},
|
||||
Info {
|
||||
pid: 101,
|
||||
parent_pid: 100,
|
||||
process_group_id: 100,
|
||||
},
|
||||
];
|
||||
let ctx = StaticCtx { infos: &infos };
|
||||
assert!(is_same_launch_tree_with_resolver(
|
||||
101,
|
||||
100,
|
||||
Some(infos[0]),
|
||||
Some(&ctx),
|
||||
ctx_resolver
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_launch_tree_includes_pgrp_peer() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 200,
|
||||
parent_pid: 1,
|
||||
process_group_id: 200,
|
||||
},
|
||||
Info {
|
||||
pid: 201,
|
||||
parent_pid: 1,
|
||||
process_group_id: 200,
|
||||
},
|
||||
];
|
||||
let ctx = StaticCtx { infos: &infos };
|
||||
assert!(is_same_launch_tree_with_resolver(
|
||||
201,
|
||||
200,
|
||||
Some(infos[0]),
|
||||
Some(&ctx),
|
||||
ctx_resolver
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_launch_tree_excludes_unrelated() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 300,
|
||||
parent_pid: 1,
|
||||
process_group_id: 300,
|
||||
},
|
||||
Info {
|
||||
pid: 301,
|
||||
parent_pid: 1,
|
||||
process_group_id: 301,
|
||||
},
|
||||
];
|
||||
let ctx = StaticCtx { infos: &infos };
|
||||
assert!(!is_same_launch_tree_with_resolver(
|
||||
301,
|
||||
300,
|
||||
Some(infos[0]),
|
||||
Some(&ctx),
|
||||
ctx_resolver
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_related_returns_tree() {
|
||||
let infos = [
|
||||
Info {
|
||||
pid: 400,
|
||||
parent_pid: 1,
|
||||
process_group_id: 400,
|
||||
},
|
||||
Info {
|
||||
pid: 401,
|
||||
parent_pid: 400,
|
||||
process_group_id: 400,
|
||||
},
|
||||
Info {
|
||||
pid: 402,
|
||||
parent_pid: 401,
|
||||
process_group_id: 400,
|
||||
},
|
||||
Info {
|
||||
pid: 500,
|
||||
parent_pid: 1,
|
||||
process_group_id: 500,
|
||||
},
|
||||
];
|
||||
let candidates = [500i32, 401, 402, 400];
|
||||
let ctx = StaticCtx { infos: &infos };
|
||||
let pids = collect_related_pids_with_resolver(
|
||||
400,
|
||||
Some(infos[0]),
|
||||
&candidates,
|
||||
8,
|
||||
Some(&ctx),
|
||||
ctx_resolver,
|
||||
);
|
||||
assert_eq!(pids, vec![400, 401, 402]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub fn trim_ascii_whitespace(value: &str) -> &str {
|
||||
value.trim_matches(|c: char| matches!(c, ' ' | '\t' | '\r' | '\n'))
|
||||
}
|
||||
|
||||
pub fn has_prefix_with_suffix(value: &str, prefix: &str, suffix: &str) -> bool {
|
||||
let pb = prefix.as_bytes();
|
||||
let sb = suffix.as_bytes();
|
||||
let vb = value.as_bytes();
|
||||
if vb.len() < pb.len() + sb.len() {
|
||||
return false;
|
||||
}
|
||||
&vb[..pb.len()] == pb && &vb[pb.len()..pb.len() + sb.len()] == sb
|
||||
}
|
||||
|
||||
pub fn related_by_prefix_either_way(a: &str, b: &str, suffix: &str) -> bool {
|
||||
has_prefix_with_suffix(a, b, suffix) || has_prefix_with_suffix(b, a, suffix)
|
||||
}
|
||||
|
||||
pub fn helper_bundle_base(value: &str) -> &str {
|
||||
let suffixes = [".helper", ".Helper", "-helper", "-Helper"];
|
||||
for suffix in suffixes {
|
||||
if let Some(idx) = value.rfind(suffix) {
|
||||
if idx == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if value[..idx].find('.').is_none() {
|
||||
continue;
|
||||
}
|
||||
let after = idx + suffix.len();
|
||||
let bytes = value.as_bytes();
|
||||
if after == bytes.len() || bytes[after] == b'.' || bytes[after] == b'-' {
|
||||
return &value[..idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub fn helper_name_base(value: &str) -> &str {
|
||||
let trimmed = trim_ascii_whitespace(value);
|
||||
if let Some(idx) = trimmed.find(" Helper") {
|
||||
if idx > 0 {
|
||||
return trim_ascii_whitespace(&trimmed[..idx]);
|
||||
}
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
pub fn looks_related_by_strings(
|
||||
candidate_bundle: &str,
|
||||
target_bundle: &str,
|
||||
candidate_name: &str,
|
||||
target_name: &str,
|
||||
) -> bool {
|
||||
if !target_bundle.is_empty() && !candidate_bundle.is_empty() {
|
||||
let tb = helper_bundle_base(target_bundle);
|
||||
let cb = helper_bundle_base(candidate_bundle);
|
||||
if candidate_bundle == target_bundle
|
||||
|| cb == tb
|
||||
|| related_by_prefix_either_way(candidate_bundle, target_bundle, ".")
|
||||
|| related_by_prefix_either_way(candidate_bundle, target_bundle, "-")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let tn = trim_ascii_whitespace(target_name);
|
||||
let cn = trim_ascii_whitespace(candidate_name);
|
||||
if !tn.is_empty() {
|
||||
let tnb = helper_name_base(tn);
|
||||
let cnb = helper_name_base(cn);
|
||||
if cn == tn
|
||||
|| cnb == tnb
|
||||
|| related_by_prefix_either_way(cn, tn, " ")
|
||||
|| related_by_prefix_either_way(cn, tn, " Helper")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn helper_bundle_strips_suffix() {
|
||||
assert_eq!(
|
||||
"com.example.app",
|
||||
helper_bundle_base("com.example.app.helper")
|
||||
);
|
||||
assert_eq!(
|
||||
"com.example.app",
|
||||
helper_bundle_base("com.example.app.Helper")
|
||||
);
|
||||
assert_eq!(
|
||||
"com.example.app",
|
||||
helper_bundle_base("com.example.app-helper")
|
||||
);
|
||||
|
||||
assert_eq!("helper", helper_bundle_base("helper"));
|
||||
|
||||
assert_eq!(
|
||||
"com.example.app",
|
||||
helper_bundle_base("com.example.app.helper.Plugin")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_name_strips_helper_suffix() {
|
||||
assert_eq!("Example", helper_name_base("Example Helper"));
|
||||
assert_eq!("Example", helper_name_base("Example Helper (Renderer)"));
|
||||
assert_eq!("Example", helper_name_base(" Example "));
|
||||
assert_eq!("Foo", helper_name_base("Foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn related_via_bundle_helper_base() {
|
||||
assert!(looks_related_by_strings(
|
||||
"com.example.app.helper",
|
||||
"com.example.app",
|
||||
"",
|
||||
""
|
||||
));
|
||||
assert!(looks_related_by_strings(
|
||||
"com.example.app",
|
||||
"com.example.app.Helper",
|
||||
"",
|
||||
""
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn related_via_name_helper_base() {
|
||||
assert!(looks_related_by_strings(
|
||||
"",
|
||||
"",
|
||||
"Example Helper",
|
||||
"Example"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_returns_false() {
|
||||
assert!(!looks_related_by_strings(
|
||||
"com.firefox.app",
|
||||
"com.chrome.app",
|
||||
"Firefox",
|
||||
"Chrome"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn related_by_dot_prefix() {
|
||||
assert!(looks_related_by_strings(
|
||||
"com.example.app.renderer",
|
||||
"com.example.app",
|
||||
"",
|
||||
""
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use objc2::rc::Retained;
|
||||
use objc2_core_foundation::CGRect;
|
||||
use objc2_core_media::{CMTime, CMTimeFlags};
|
||||
use objc2_foundation::NSString;
|
||||
use objc2_screen_capture_kit::{
|
||||
SCDisplay, SCRunningApplication, SCStream, SCStreamConfiguration, SCWindow,
|
||||
};
|
||||
|
||||
pub use objc2_screen_capture_kit::SCStreamOutputType;
|
||||
|
||||
pub fn cgrect_standardized(r: CGRect) -> CGRect {
|
||||
let mut out = r;
|
||||
if out.size.width < 0.0 {
|
||||
out.origin.x += out.size.width;
|
||||
out.size.width = -out.size.width;
|
||||
}
|
||||
if out.size.height < 0.0 {
|
||||
out.origin.y += out.size.height;
|
||||
out.size.height = -out.size.height;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn cgrect_intersection_area(a_raw: CGRect, b_raw: CGRect) -> f64 {
|
||||
let a = cgrect_standardized(a_raw);
|
||||
let b = cgrect_standardized(b_raw);
|
||||
if a.size.width <= 0.0 || a.size.height <= 0.0 || b.size.width <= 0.0 || b.size.height <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let ax2 = a.origin.x + a.size.width;
|
||||
let ay2 = a.origin.y + a.size.height;
|
||||
let bx2 = b.origin.x + b.size.width;
|
||||
let by2 = b.origin.y + b.size.height;
|
||||
let x1 = a.origin.x.max(b.origin.x);
|
||||
let y1 = a.origin.y.max(b.origin.y);
|
||||
let x2 = ax2.min(bx2);
|
||||
let y2 = ay2.min(by2);
|
||||
if x2 <= x1 || y2 <= y1 {
|
||||
return 0.0;
|
||||
}
|
||||
(x2 - x1) * (y2 - y1)
|
||||
}
|
||||
|
||||
pub fn cmtime_seconds(value: i64, timescale: i32) -> CMTime {
|
||||
CMTime {
|
||||
value,
|
||||
timescale,
|
||||
flags: CMTimeFlags(1),
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sc_running_application_process_id(app: &SCRunningApplication) -> i32 {
|
||||
unsafe { app.processID() }
|
||||
}
|
||||
|
||||
pub fn sc_running_application_bundle_identifier(app: &SCRunningApplication) -> Retained<NSString> {
|
||||
unsafe { app.bundleIdentifier() }
|
||||
}
|
||||
|
||||
pub fn sc_running_application_name(app: &SCRunningApplication) -> Retained<NSString> {
|
||||
unsafe { app.applicationName() }
|
||||
}
|
||||
|
||||
pub fn sc_display_frame(display: &SCDisplay) -> CGRect {
|
||||
unsafe { display.frame() }
|
||||
}
|
||||
|
||||
pub fn sc_window_owning_application(win: &SCWindow) -> Option<Retained<SCRunningApplication>> {
|
||||
unsafe { win.owningApplication() }
|
||||
}
|
||||
|
||||
pub fn sc_window_frame(win: &SCWindow) -> CGRect {
|
||||
unsafe { win.frame() }
|
||||
}
|
||||
|
||||
pub fn cfg_set_captures_audio(cfg: &SCStreamConfiguration, v: bool) {
|
||||
unsafe { cfg.setCapturesAudio(v) }
|
||||
}
|
||||
pub fn cfg_set_excludes_current_process_audio(cfg: &SCStreamConfiguration, v: bool) {
|
||||
unsafe { cfg.setExcludesCurrentProcessAudio(v) }
|
||||
}
|
||||
pub fn cfg_set_sample_rate(cfg: &SCStreamConfiguration, v: isize) {
|
||||
unsafe { cfg.setSampleRate(v) }
|
||||
}
|
||||
pub fn cfg_set_channel_count(cfg: &SCStreamConfiguration, v: isize) {
|
||||
unsafe { cfg.setChannelCount(v) }
|
||||
}
|
||||
pub fn cfg_set_queue_depth(cfg: &SCStreamConfiguration, v: isize) {
|
||||
unsafe { cfg.setQueueDepth(v) }
|
||||
}
|
||||
pub fn cfg_set_width(cfg: &SCStreamConfiguration, v: usize) {
|
||||
unsafe { cfg.setWidth(v) }
|
||||
}
|
||||
pub fn cfg_set_height(cfg: &SCStreamConfiguration, v: usize) {
|
||||
unsafe { cfg.setHeight(v) }
|
||||
}
|
||||
pub fn cfg_set_shows_cursor(cfg: &SCStreamConfiguration, v: bool) {
|
||||
unsafe { cfg.setShowsCursor(v) }
|
||||
}
|
||||
pub fn cfg_set_minimum_frame_interval(cfg: &SCStreamConfiguration, t: CMTime) {
|
||||
unsafe { cfg.setMinimumFrameInterval(t) }
|
||||
}
|
||||
|
||||
pub fn cfg_set_capture_dynamic_range_sdr_if_available(cfg: &SCStreamConfiguration) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setCaptureDynamicRange:)) {
|
||||
unsafe {
|
||||
cfg.setCaptureDynamicRange(objc2_screen_capture_kit::SCCaptureDynamicRange(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cfg_set_stream_name_if_available(cfg: &SCStreamConfiguration, name: &NSString) {
|
||||
use objc2::runtime::NSObjectProtocol;
|
||||
use objc2::sel;
|
||||
let obj: &objc2::runtime::NSObject = cfg.as_ref();
|
||||
if obj.respondsToSelector(sel!(setStreamName:)) {
|
||||
unsafe {
|
||||
cfg.setStreamName(Some(name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sc_stream_add_stream_output(
|
||||
stream: &SCStream,
|
||||
output: &objc2::runtime::ProtocolObject<dyn objc2_screen_capture_kit::SCStreamOutput>,
|
||||
kind: SCStreamOutputType,
|
||||
queue: Option<&dispatch2::DispatchQueue>,
|
||||
) -> Result<(), Retained<objc2_foundation::NSError>> {
|
||||
unsafe { stream.addStreamOutput_type_sampleHandlerQueue_error(output, kind, queue) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use objc2_core_foundation::{CGPoint, CGSize};
|
||||
|
||||
#[test]
|
||||
fn cgrect_intersection_handles_negative_and_disjoint() {
|
||||
let a = CGRect {
|
||||
origin: CGPoint { x: 0.0, y: 0.0 },
|
||||
size: CGSize {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
let b = CGRect {
|
||||
origin: CGPoint { x: 5.0, y: 5.0 },
|
||||
size: CGSize {
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(25.0, cgrect_intersection_area(a, b));
|
||||
|
||||
let c = CGRect {
|
||||
origin: CGPoint { x: 10.0, y: 10.0 },
|
||||
size: CGSize {
|
||||
width: -5.0,
|
||||
height: -5.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(25.0, cgrect_intersection_area(a, c));
|
||||
|
||||
let d = CGRect {
|
||||
origin: CGPoint { x: 20.0, y: 20.0 },
|
||||
size: CGSize {
|
||||
width: 2.0,
|
||||
height: 2.0,
|
||||
},
|
||||
};
|
||||
assert_eq!(0.0, cgrect_intersection_area(a, d));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
|
||||
use std::time::Duration;
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::Retained;
|
||||
use objc2_foundation::NSError;
|
||||
use objc2_screen_capture_kit::{SCShareableContent, SCStream};
|
||||
|
||||
pub const DEFAULT_TIMEOUT_NS: u64 = 30 * 1_000_000_000;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum AsyncError {
|
||||
AsyncTimedOut,
|
||||
SCKReturnedError,
|
||||
}
|
||||
|
||||
struct WakerInner {
|
||||
state: Mutex<WakerState>,
|
||||
cv: Condvar,
|
||||
}
|
||||
|
||||
struct WakerState {
|
||||
done: bool,
|
||||
failed: bool,
|
||||
err: Option<Retained<NSError>>,
|
||||
content: Option<Retained<SCShareableContent>>,
|
||||
}
|
||||
|
||||
unsafe impl Send for WakerInner {}
|
||||
unsafe impl Sync for WakerInner {}
|
||||
|
||||
fn new_waker() -> Arc<WakerInner> {
|
||||
Arc::new(WakerInner {
|
||||
state: Mutex::new(WakerState {
|
||||
done: false,
|
||||
failed: false,
|
||||
err: None,
|
||||
content: None,
|
||||
}),
|
||||
cv: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock_state(w: &WakerInner) -> MutexGuard<'_, WakerState> {
|
||||
w.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn wait_deadline(w: &Arc<WakerInner>, timeout_ns: u64) -> bool {
|
||||
let s = lock_state(w);
|
||||
if s.done {
|
||||
return true;
|
||||
}
|
||||
let dur = Duration::from_nanos(timeout_ns);
|
||||
match w.cv.wait_timeout(s, dur) {
|
||||
Ok((state, _)) => state.done,
|
||||
Err(poisoned) => poisoned.into_inner().0.done,
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_error(err: *mut NSError) -> Result<Option<Retained<NSError>>, AsyncError> {
|
||||
if err.is_null() {
|
||||
Ok(None)
|
||||
} else {
|
||||
unsafe { Retained::retain(err) }
|
||||
.map(Some)
|
||||
.ok_or(AsyncError::SCKReturnedError)
|
||||
}
|
||||
}
|
||||
|
||||
fn retain_content(
|
||||
content: *mut SCShareableContent,
|
||||
) -> Result<Option<Retained<SCShareableContent>>, AsyncError> {
|
||||
if content.is_null() {
|
||||
Ok(None)
|
||||
} else {
|
||||
unsafe { Retained::retain(content) }
|
||||
.map(Some)
|
||||
.ok_or(AsyncError::SCKReturnedError)
|
||||
}
|
||||
}
|
||||
|
||||
fn complete(
|
||||
waker: &WakerInner,
|
||||
err: Option<Retained<NSError>>,
|
||||
content: Option<Retained<SCShareableContent>>,
|
||||
failed: bool,
|
||||
) {
|
||||
let mut s = lock_state(waker);
|
||||
s.err = err;
|
||||
s.content = content;
|
||||
s.failed = failed;
|
||||
s.done = true;
|
||||
waker.cv.notify_all();
|
||||
}
|
||||
|
||||
pub fn await_ns_error_block_start(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
|
||||
let waker = new_waker();
|
||||
let waker_cb = waker.clone();
|
||||
let blk = RcBlock::new(move |err: *mut NSError| {
|
||||
let (err_opt, failed) = match retain_error(err) {
|
||||
Ok(err_opt) => (err_opt, false),
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
complete(&waker_cb, err_opt, None, failed);
|
||||
});
|
||||
|
||||
unsafe {
|
||||
stream.startCaptureWithCompletionHandler(Some(&blk));
|
||||
}
|
||||
|
||||
if !wait_deadline(&waker, timeout_ns) {
|
||||
return Err(AsyncError::AsyncTimedOut);
|
||||
}
|
||||
let s = lock_state(&waker);
|
||||
if s.failed || s.err.is_some() {
|
||||
return Err(AsyncError::SCKReturnedError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn await_ns_error_block_stop(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
|
||||
let waker = new_waker();
|
||||
let waker_cb = waker.clone();
|
||||
let blk = RcBlock::new(move |err: *mut NSError| {
|
||||
let (err_opt, failed) = match retain_error(err) {
|
||||
Ok(err_opt) => (err_opt, false),
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
complete(&waker_cb, err_opt, None, failed);
|
||||
});
|
||||
|
||||
unsafe {
|
||||
stream.stopCaptureWithCompletionHandler(Some(&blk));
|
||||
}
|
||||
|
||||
if !wait_deadline(&waker, timeout_ns) {
|
||||
return Err(AsyncError::AsyncTimedOut);
|
||||
}
|
||||
let s = lock_state(&waker);
|
||||
if s.failed || s.err.is_some() {
|
||||
return Err(AsyncError::SCKReturnedError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_capture(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
|
||||
await_ns_error_block_start(stream, timeout_ns)
|
||||
}
|
||||
|
||||
pub fn stop_capture(stream: &SCStream, timeout_ns: u64) -> Result<(), AsyncError> {
|
||||
await_ns_error_block_stop(stream, timeout_ns)
|
||||
}
|
||||
|
||||
pub struct ShareableContent {
|
||||
pub content: Retained<SCShareableContent>,
|
||||
}
|
||||
|
||||
pub fn get_shareable_content(
|
||||
excluding_desktop_windows: bool,
|
||||
on_screen_windows_only: bool,
|
||||
timeout_ns: u64,
|
||||
) -> Result<ShareableContent, AsyncError> {
|
||||
let waker = new_waker();
|
||||
let waker_cb = waker.clone();
|
||||
let blk = RcBlock::new(move |content: *mut SCShareableContent, err: *mut NSError| {
|
||||
let (err_opt, err_failed) = match retain_error(err) {
|
||||
Ok(err_opt) => (err_opt, false),
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
let (content_opt, content_failed) = match retain_content(content) {
|
||||
Ok(content_opt) => (content_opt, false),
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
complete(
|
||||
&waker_cb,
|
||||
err_opt,
|
||||
content_opt,
|
||||
err_failed || content_failed,
|
||||
);
|
||||
});
|
||||
|
||||
unsafe {
|
||||
SCShareableContent::getShareableContentExcludingDesktopWindows_onScreenWindowsOnly_completionHandler(
|
||||
excluding_desktop_windows,
|
||||
on_screen_windows_only,
|
||||
&blk,
|
||||
);
|
||||
}
|
||||
|
||||
if !wait_deadline(&waker, timeout_ns) {
|
||||
return Err(AsyncError::AsyncTimedOut);
|
||||
}
|
||||
let mut s = lock_state(&waker);
|
||||
if s.failed || s.err.is_some() {
|
||||
return Err(AsyncError::SCKReturnedError);
|
||||
}
|
||||
match s.content.take() {
|
||||
Some(content) => Ok(ShareableContent { content }),
|
||||
None => Err(AsyncError::SCKReturnedError),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
#[repr(u32)]
|
||||
pub enum State {
|
||||
Idle = 0,
|
||||
Starting = 1,
|
||||
Running = 2,
|
||||
Stopping = 3,
|
||||
Stopped = 4,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn from_u32(v: u32) -> State {
|
||||
match v {
|
||||
0 => State::Idle,
|
||||
1 => State::Starting,
|
||||
2 => State::Running,
|
||||
3 => State::Stopping,
|
||||
_ => State::Stopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TransitionError {
|
||||
IllegalTransition,
|
||||
DoubleStart,
|
||||
StopBeforeStart,
|
||||
StartWhileStopping,
|
||||
}
|
||||
|
||||
pub fn is_allowed(from: State, to: State) -> bool {
|
||||
match from {
|
||||
State::Idle => matches!(to, State::Starting | State::Stopped),
|
||||
State::Starting => matches!(to, State::Running | State::Stopped),
|
||||
State::Running => matches!(to, State::Stopping | State::Stopped),
|
||||
State::Stopping => matches!(to, State::Stopped),
|
||||
State::Stopped => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Machine {
|
||||
state: AtomicU32,
|
||||
}
|
||||
|
||||
impl Machine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: AtomicU32::new(State::Idle as u32),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> State {
|
||||
State::from_u32(self.state.load(Ordering::Acquire))
|
||||
}
|
||||
|
||||
fn cas(&self, from: State, to: State) -> Result<(), TransitionError> {
|
||||
if !is_allowed(from, to) {
|
||||
return Err(TransitionError::IllegalTransition);
|
||||
}
|
||||
self.state
|
||||
.compare_exchange(from as u32, to as u32, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map(|_| ())
|
||||
.map_err(|_| TransitionError::IllegalTransition)
|
||||
}
|
||||
|
||||
pub fn request_start(&self) -> Result<(), TransitionError> {
|
||||
match self.current() {
|
||||
State::Idle => self.cas(State::Idle, State::Starting),
|
||||
State::Starting | State::Running => Err(TransitionError::DoubleStart),
|
||||
State::Stopping => Err(TransitionError::StartWhileStopping),
|
||||
State::Stopped => Err(TransitionError::IllegalTransition),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_running(&self) -> Result<(), TransitionError> {
|
||||
self.cas(State::Starting, State::Running)
|
||||
}
|
||||
|
||||
pub fn request_stop(&self) -> Result<(), TransitionError> {
|
||||
match self.current() {
|
||||
State::Running => self.cas(State::Running, State::Stopping),
|
||||
State::Idle => Err(TransitionError::StopBeforeStart),
|
||||
State::Starting | State::Stopping | State::Stopped => {
|
||||
Err(TransitionError::IllegalTransition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_stopped(&self) -> Result<(), TransitionError> {
|
||||
self.cas(State::Stopping, State::Stopped)
|
||||
}
|
||||
|
||||
pub fn cancel_idle(&self) -> Result<(), TransitionError> {
|
||||
self.cas(State::Idle, State::Stopped)
|
||||
}
|
||||
|
||||
pub fn mark_fatal(&self) -> State {
|
||||
loop {
|
||||
let raw = self.state.load(Ordering::Acquire);
|
||||
let prev = State::from_u32(raw);
|
||||
if prev == State::Stopped {
|
||||
return prev;
|
||||
}
|
||||
if self
|
||||
.state
|
||||
.compare_exchange(
|
||||
raw,
|
||||
State::Stopped as u32,
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Machine {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_allowed_exhaustive() {
|
||||
let all = [
|
||||
State::Idle,
|
||||
State::Starting,
|
||||
State::Running,
|
||||
State::Stopping,
|
||||
State::Stopped,
|
||||
];
|
||||
let allowed: &[(State, State)] = &[
|
||||
(State::Idle, State::Starting),
|
||||
(State::Idle, State::Stopped),
|
||||
(State::Starting, State::Running),
|
||||
(State::Starting, State::Stopped),
|
||||
(State::Running, State::Stopping),
|
||||
(State::Running, State::Stopped),
|
||||
(State::Stopping, State::Stopped),
|
||||
];
|
||||
for &from in &all {
|
||||
for &to in &all {
|
||||
let expected = allowed.iter().any(|p| p.0 == from && p.1 == to);
|
||||
assert_eq!(expected, is_allowed(from, to), "{:?} -> {:?}", from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path() {
|
||||
let m = Machine::new();
|
||||
assert_eq!(State::Idle, m.current());
|
||||
m.request_start().unwrap();
|
||||
assert_eq!(State::Starting, m.current());
|
||||
m.mark_running().unwrap();
|
||||
assert_eq!(State::Running, m.current());
|
||||
m.request_stop().unwrap();
|
||||
assert_eq!(State::Stopping, m.current());
|
||||
m.mark_stopped().unwrap();
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_start_rejected() {
|
||||
let m = Machine::new();
|
||||
m.request_start().unwrap();
|
||||
assert_eq!(Err(TransitionError::DoubleStart), m.request_start());
|
||||
m.mark_running().unwrap();
|
||||
assert_eq!(Err(TransitionError::DoubleStart), m.request_start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_before_start_rejected() {
|
||||
let m = Machine::new();
|
||||
assert_eq!(Err(TransitionError::StopBeforeStart), m.request_stop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_while_stopping_rejected() {
|
||||
let m = Machine::new();
|
||||
m.request_start().unwrap();
|
||||
m.mark_running().unwrap();
|
||||
m.request_stop().unwrap();
|
||||
assert_eq!(Err(TransitionError::StartWhileStopping), m.request_start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_idle_short_circuits() {
|
||||
let m = Machine::new();
|
||||
m.cancel_idle().unwrap();
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
assert_eq!(Err(TransitionError::IllegalTransition), m.request_start());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_fatal_forces_stopped() {
|
||||
for &start in &[
|
||||
State::Idle,
|
||||
State::Starting,
|
||||
State::Running,
|
||||
State::Stopping,
|
||||
] {
|
||||
let m = Machine::new();
|
||||
m.state.store(start as u32, Ordering::Release);
|
||||
let prev = m.mark_fatal();
|
||||
assert_eq!(start, prev);
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_fatal_idempotent() {
|
||||
let m = Machine::new();
|
||||
m.state.store(State::Stopped as u32, Ordering::Release);
|
||||
let prev = m.mark_fatal();
|
||||
assert_eq!(State::Stopped, prev);
|
||||
assert_eq!(State::Stopped, m.current());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user