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:
+545
@@ -0,0 +1,545 @@
|
||||
# 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 = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[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 = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_desktop_native"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_gpu_rebuild"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_rt_thread"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_screen_frame_bus"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"fluxer_gpu_rebuild",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluxer_win_process_loopback"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"fluxer_desktop_native",
|
||||
"fluxer_rt_thread",
|
||||
"fluxer_screen_frame_bus",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[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 = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
|
||||
[[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 = "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 = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[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.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[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 = "typenum"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[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"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "fluxer_win_process_loopback"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
fluxer_desktop_native = {path = "../rust"}
|
||||
fluxer_rt_thread = {path = "../rt-thread"}
|
||||
fluxer_screen_frame_bus = {path = "../screen-frame-bus"}
|
||||
napi = {version = "3.9.1", default-features = false, features = ["dyn-symbols", "napi8"]}
|
||||
napi-derive = "3.5.6"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows = {version = "0.62.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Media_Audio",
|
||||
"Win32_Media_KernelStreaming",
|
||||
"Win32_Media_Multimedia",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Com_StructuredStorage",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Variant",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
]}
|
||||
windows-core = "0.62.2"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.3.2"
|
||||
@@ -0,0 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EventEmitter} from 'node:events';
|
||||
|
||||
export interface ProcessLoopbackOptions {
|
||||
includeProcessTree?: boolean;
|
||||
captureScope?: 'process' | 'system' | 'session-mixer';
|
||||
winCaptureScope?: 'process' | 'system' | 'session-mixer';
|
||||
scope?: 'process' | 'system' | 'session-mixer';
|
||||
sampleRate?: 48000;
|
||||
channels?: 2;
|
||||
}
|
||||
|
||||
export interface AudioFrame {
|
||||
samples: Float32Array;
|
||||
sampleRate: number;
|
||||
channels: number;
|
||||
timestampUs: bigint;
|
||||
}
|
||||
|
||||
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: 'started', 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: 'started', 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;
|
||||
emit(event: 'started'): boolean;
|
||||
}
|
||||
|
||||
export declare class ProcessLoopback extends EventEmitter {
|
||||
constructor(pid: number, opts?: ProcessLoopbackOptions);
|
||||
|
||||
start(): Promise<void>;
|
||||
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export declare function isSupported(): boolean;
|
||||
|
||||
export interface WinProcessLoopbackBackendInfo {
|
||||
backend: string;
|
||||
supported: boolean;
|
||||
reason: string;
|
||||
processSupported: boolean;
|
||||
systemSupported: boolean;
|
||||
systemExcludesSelf: boolean;
|
||||
processIncludeSupported: boolean;
|
||||
processExcludeSupported: boolean;
|
||||
sessionMixerSupported: boolean;
|
||||
systemLoopbackMode: 'process-exclude' | 'session-mixer' | 'unavailable';
|
||||
minWindowsBuild: number;
|
||||
minWindowsVersionLabel: string;
|
||||
detectedWindowsBuild?: number;
|
||||
}
|
||||
|
||||
export declare function getBackendInfo(): WinProcessLoopbackBackendInfo;
|
||||
|
||||
export declare function pidFromHwnd(hwnd: bigint): number;
|
||||
|
||||
export declare function resolveAudioRootPid(pid: number): number;
|
||||
@@ -0,0 +1,249 @@
|
||||
// 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 {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs');
|
||||
const MODULE_NAME = '@fluxer/win-process-loopback';
|
||||
|
||||
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(arch) {
|
||||
switch (arch) {
|
||||
case 'x64':
|
||||
return 'win-process-loopback.win32-x64-msvc.node';
|
||||
case 'arm64':
|
||||
return 'win-process-loopback.win32-arm64-msvc.node';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let binding = null;
|
||||
let loadError = null;
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const nativeRoot = resolveNativeRoot();
|
||||
const fileName = nativeFileName(process.arch);
|
||||
if (!fileName) {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
reason: `unsupported Windows architecture: ${process.arch}`,
|
||||
});
|
||||
throw loadError;
|
||||
} else {
|
||||
const nativePath = join(nativeRoot, fileName);
|
||||
const loaded = loadNativeBinding({
|
||||
moduleName: MODULE_NAME,
|
||||
nativePath,
|
||||
nativeRoot,
|
||||
packageDir: __dirname,
|
||||
probe: false,
|
||||
});
|
||||
binding = loaded.binding;
|
||||
loadError = loaded.loadError;
|
||||
if (loadError) throw loadError;
|
||||
}
|
||||
} else {
|
||||
loadError = createNativeLoadError({
|
||||
moduleName: MODULE_NAME,
|
||||
nativeRoot: resolveNativeRoot(),
|
||||
packageDir: __dirname,
|
||||
reason: `not supported on platform ${process.platform}`,
|
||||
});
|
||||
}
|
||||
|
||||
function isSupported() {
|
||||
if (!binding) return false;
|
||||
try {
|
||||
return Boolean(binding.isSupported());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getBackendInfo() {
|
||||
if (!binding) {
|
||||
const reason =
|
||||
process.platform !== 'win32'
|
||||
? `@fluxer/win-process-loopback only supports Windows, got ${process.platform}. Microsoft's documented process-loopback minimum is Windows build 20348.`
|
||||
: `@fluxer/win-process-loopback native binary unavailable: ${loadError?.message ?? 'unknown reason'}`;
|
||||
return {
|
||||
backend: 'win-process-loopback',
|
||||
supported: false,
|
||||
reason,
|
||||
processSupported: false,
|
||||
systemSupported: false,
|
||||
systemExcludesSelf: false,
|
||||
processIncludeSupported: false,
|
||||
processExcludeSupported: false,
|
||||
sessionMixerSupported: false,
|
||||
systemLoopbackMode: 'unavailable',
|
||||
minWindowsBuild: 20348,
|
||||
minWindowsVersionLabel: 'Windows build 20348 (documented)',
|
||||
detectedWindowsBuild: undefined,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return binding.getBackendInfo();
|
||||
} catch (err) {
|
||||
return {
|
||||
backend: 'win-process-loopback',
|
||||
supported: false,
|
||||
reason: `@fluxer/win-process-loopback getBackendInfo failed: ${err?.message ?? String(err)}`,
|
||||
processSupported: false,
|
||||
systemSupported: false,
|
||||
systemExcludesSelf: false,
|
||||
processIncludeSupported: false,
|
||||
processExcludeSupported: false,
|
||||
sessionMixerSupported: false,
|
||||
systemLoopbackMode: 'unavailable',
|
||||
minWindowsBuild: 20348,
|
||||
minWindowsVersionLabel: 'Windows build 20348 (documented)',
|
||||
detectedWindowsBuild: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function pidFromHwnd(hwnd) {
|
||||
if (!binding) return 0;
|
||||
try {
|
||||
return binding.pidFromHwnd(hwnd);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAudioRootPid(pid) {
|
||||
if (!binding) return pid;
|
||||
try {
|
||||
return binding.resolveAudioRootPid(pid);
|
||||
} catch {
|
||||
return pid;
|
||||
}
|
||||
}
|
||||
|
||||
class ProcessLoopback extends EventEmitter {
|
||||
constructor(pid, opts = {}) {
|
||||
super();
|
||||
if (!binding) {
|
||||
throw loadError ?? new Error('@fluxer/win-process-loopback binding unavailable');
|
||||
}
|
||||
const captureScope = opts.captureScope ?? opts.winCaptureScope ?? opts.scope ?? 'process';
|
||||
const includeProcessTree =
|
||||
captureScope === 'system' || captureScope === 'session-mixer' ? false : opts.includeProcessTree !== false;
|
||||
const sampleRate = opts.sampleRate;
|
||||
const channels = opts.channels;
|
||||
this._stopped = false;
|
||||
this._started = false;
|
||||
this._startPromise = null;
|
||||
this._resolveStart = null;
|
||||
this._rejectStart = null;
|
||||
this._native = new binding.ProcessLoopback(
|
||||
pid,
|
||||
{includeProcessTree, sampleRate, channels, captureScope},
|
||||
(frame) => this.emit('frame', frame),
|
||||
(err) => {
|
||||
if (!this._started && this._rejectStart) {
|
||||
this._rejectStart(err);
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
this.emit('error', err);
|
||||
},
|
||||
() => {
|
||||
if (this._stopped) return;
|
||||
this._stopped = true;
|
||||
if (!this._started && this._rejectStart) {
|
||||
this._rejectStart(new Error('ProcessLoopback closed before audio capture started'));
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
this._native.dispose();
|
||||
} catch {}
|
||||
});
|
||||
this.emit('closed');
|
||||
},
|
||||
() => {
|
||||
this._started = true;
|
||||
if (this._resolveStart) {
|
||||
this._resolveStart();
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
this.emit('started');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_clearStartWaiters() {
|
||||
this._resolveStart = null;
|
||||
this._rejectStart = null;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this._stopped) return Promise.resolve();
|
||||
if (this._started) return Promise.resolve();
|
||||
if (this._startPromise) return this._startPromise;
|
||||
this._startPromise = new Promise((resolve, reject) => {
|
||||
this._resolveStart = resolve;
|
||||
this._rejectStart = reject;
|
||||
try {
|
||||
this._native.start();
|
||||
} catch (error) {
|
||||
this._clearStartWaiters();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
return this._startPromise;
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._stopped) return;
|
||||
this._stopped = true;
|
||||
this.clearScreenAudioSink();
|
||||
if (!this._started && this._rejectStart) {
|
||||
this._rejectStart(new Error('ProcessLoopback stopped before audio capture started'));
|
||||
this._clearStartWaiters();
|
||||
}
|
||||
this._native.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function __setBindingForTests(nextBinding, nextLoadError = null) {
|
||||
binding = nextBinding;
|
||||
loadError = nextLoadError;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isSupported,
|
||||
getBackendInfo,
|
||||
pidFromHwnd,
|
||||
resolveAudioRootPid,
|
||||
ProcessLoopback,
|
||||
get loadError() {
|
||||
return loadError;
|
||||
},
|
||||
__setBindingForTests,
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
import {test} from 'node:test';
|
||||
|
||||
const requireSrc = createRequire(import.meta.url);
|
||||
const modulePath = requireSrc.resolve('./index.js');
|
||||
|
||||
function freshModule() {
|
||||
delete requireSrc.cache[modulePath];
|
||||
return requireSrc('./index.js');
|
||||
}
|
||||
|
||||
test('module loads without throwing on non-Windows hosts', () => {
|
||||
const mod = freshModule();
|
||||
assert.equal(typeof mod.isSupported, 'function');
|
||||
assert.equal(typeof mod.pidFromHwnd, 'function');
|
||||
assert.equal(typeof mod.resolveAudioRootPid, 'function');
|
||||
assert.equal(typeof mod.ProcessLoopback, 'function');
|
||||
});
|
||||
|
||||
test('non-Windows hosts surface a load error', {skip: process.platform === 'win32'}, () => {
|
||||
const mod = freshModule();
|
||||
assert.ok(mod.loadError instanceof Error, 'expected loadError to be set on non-Windows');
|
||||
assert.match(mod.loadError.message, /not supported on platform/i);
|
||||
});
|
||||
|
||||
test('isSupported / pidFromHwnd / resolveAudioRootPid fail soft when binding is absent', () => {
|
||||
const mod = freshModule();
|
||||
mod.__setBindingForTests(null, new Error('test: binary missing'));
|
||||
assert.equal(mod.isSupported(), false);
|
||||
assert.equal(mod.pidFromHwnd(123n), 0);
|
||||
assert.equal(mod.resolveAudioRootPid(1234), 1234);
|
||||
assert.deepEqual(
|
||||
{
|
||||
processSupported: mod.getBackendInfo().processSupported,
|
||||
systemSupported: mod.getBackendInfo().systemSupported,
|
||||
sessionMixerSupported: mod.getBackendInfo().sessionMixerSupported,
|
||||
systemLoopbackMode: mod.getBackendInfo().systemLoopbackMode,
|
||||
},
|
||||
{
|
||||
processSupported: false,
|
||||
systemSupported: false,
|
||||
sessionMixerSupported: false,
|
||||
systemLoopbackMode: 'unavailable',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('constructing ProcessLoopback without a binding throws the recorded loadError', () => {
|
||||
const mod = freshModule();
|
||||
const recorded = new Error('synthetic: binary unavailable');
|
||||
mod.__setBindingForTests(null, recorded);
|
||||
assert.throws(
|
||||
() => new mod.ProcessLoopback(1234, {}),
|
||||
(error) => error === recorded,
|
||||
);
|
||||
});
|
||||
|
||||
test('isSupported swallows binding-side exceptions and returns false', () => {
|
||||
const mod = freshModule();
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => {
|
||||
throw new Error('binding crashed during isSupported');
|
||||
},
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeProcessLoopback() {},
|
||||
});
|
||||
assert.equal(mod.isSupported(), false);
|
||||
});
|
||||
|
||||
test('ProcessLoopback wires native frame/error/closed events to the EventEmitter', async () => {
|
||||
const mod = freshModule();
|
||||
let frameCb;
|
||||
let errorCb;
|
||||
let closedCb;
|
||||
let startedCb;
|
||||
class FakeNative {
|
||||
constructor(pid, opts, onFrame, onError, onClosed, onStarted) {
|
||||
this.pid = pid;
|
||||
this.opts = opts;
|
||||
frameCb = onFrame;
|
||||
errorCb = onError;
|
||||
closedCb = onClosed;
|
||||
startedCb = onStarted;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: FakeNative,
|
||||
});
|
||||
const loopback = new mod.ProcessLoopback(4242, {includeProcessTree: false});
|
||||
assert.ok(loopback instanceof EventEmitter);
|
||||
const frames = [];
|
||||
const errors = [];
|
||||
let closedCount = 0;
|
||||
loopback.on('frame', (frame) => frames.push(frame));
|
||||
loopback.on('error', (error) => errors.push(error));
|
||||
loopback.on('closed', () => closedCount++);
|
||||
const startPromise = loopback.start();
|
||||
startedCb();
|
||||
await startPromise;
|
||||
frameCb({samples: new Float32Array(2), sampleRate: 48000, channels: 2, timestampUs: 0});
|
||||
errorCb(new Error('boom'));
|
||||
closedCb();
|
||||
closedCb();
|
||||
assert.equal(frames.length, 1);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(closedCount, 1);
|
||||
});
|
||||
|
||||
test('ProcessLoopback.start waits for the native started callback', async () => {
|
||||
const mod = freshModule();
|
||||
let startedCb;
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative(_pid, _opts, _onFrame, _onError, _onClosed, onStarted) {
|
||||
startedCb = onStarted;
|
||||
return {start() {}, dispose() {}};
|
||||
},
|
||||
});
|
||||
const loopback = new mod.ProcessLoopback(4242);
|
||||
let resolved = false;
|
||||
const startPromise = loopback.start().then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(resolved, false);
|
||||
startedCb();
|
||||
await startPromise;
|
||||
assert.equal(resolved, true);
|
||||
});
|
||||
|
||||
test('ProcessLoopback.start rejects if native capture errors before start', async () => {
|
||||
const mod = freshModule();
|
||||
let errorCb;
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative(_pid, _opts, _onFrame, onError) {
|
||||
errorCb = onError;
|
||||
return {start() {}, dispose() {}};
|
||||
},
|
||||
});
|
||||
const loopback = new mod.ProcessLoopback(4242);
|
||||
loopback.on('error', () => {});
|
||||
const startPromise = loopback.start();
|
||||
const error = new Error('activation failed');
|
||||
errorCb(error);
|
||||
await assert.rejects(startPromise, (actual) => actual === error);
|
||||
});
|
||||
|
||||
test('opts default includeProcessTree to true and forward sample/channel options', () => {
|
||||
const mod = freshModule();
|
||||
let observedOpts;
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative(_pid, opts) {
|
||||
observedOpts = opts;
|
||||
return {start() {}, dispose() {}};
|
||||
},
|
||||
});
|
||||
const ctor = mod.ProcessLoopback;
|
||||
void new ctor(1, {sampleRate: 44100, channels: 1});
|
||||
assert.deepEqual(observedOpts, {includeProcessTree: true, sampleRate: 44100, channels: 1, captureScope: 'process'});
|
||||
void new ctor(1, {includeProcessTree: false});
|
||||
assert.equal(observedOpts.includeProcessTree, false);
|
||||
});
|
||||
|
||||
test('system capture scope forwards EXCLUDE target process tree mode', () => {
|
||||
const mod = freshModule();
|
||||
let observedOpts;
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative(_pid, opts) {
|
||||
observedOpts = opts;
|
||||
return {start() {}, dispose() {}};
|
||||
},
|
||||
});
|
||||
void new mod.ProcessLoopback(1, {captureScope: 'system', includeProcessTree: true});
|
||||
assert.deepEqual(observedOpts, {
|
||||
includeProcessTree: false,
|
||||
sampleRate: undefined,
|
||||
channels: undefined,
|
||||
captureScope: 'system',
|
||||
});
|
||||
});
|
||||
|
||||
test('session mixer capture scope is forwarded and never captures the target process tree directly', () => {
|
||||
const mod = freshModule();
|
||||
let observedOpts;
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative(_pid, opts) {
|
||||
observedOpts = opts;
|
||||
return {start() {}, dispose() {}};
|
||||
},
|
||||
});
|
||||
void new mod.ProcessLoopback(1, {captureScope: 'session-mixer', includeProcessTree: true});
|
||||
assert.deepEqual(observedOpts, {
|
||||
includeProcessTree: false,
|
||||
sampleRate: undefined,
|
||||
channels: undefined,
|
||||
captureScope: 'session-mixer',
|
||||
});
|
||||
});
|
||||
|
||||
function loopbackWithNativeSinkResult(setScreenAudioSink) {
|
||||
const mod = freshModule();
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative() {
|
||||
return {start() {}, dispose() {}, setScreenAudioSink};
|
||||
},
|
||||
});
|
||||
return new mod.ProcessLoopback(4242);
|
||||
}
|
||||
|
||||
test('setScreenAudioSink reports true when the native attach succeeds', () => {
|
||||
const loopback = loopbackWithNativeSinkResult(() => undefined);
|
||||
assert.equal(loopback.setScreenAudioSink({}), true);
|
||||
});
|
||||
|
||||
test('setScreenAudioSink reports false when the native attach returns false', () => {
|
||||
const loopback = loopbackWithNativeSinkResult(() => false);
|
||||
assert.equal(loopback.setScreenAudioSink({}), false);
|
||||
});
|
||||
|
||||
test('setScreenAudioSink reports false when the native attach throws', () => {
|
||||
const loopback = loopbackWithNativeSinkResult(() => {
|
||||
throw new Error('attach failed');
|
||||
});
|
||||
assert.equal(loopback.setScreenAudioSink({}), false);
|
||||
});
|
||||
|
||||
test('setScreenAudioSink reports false when the native method is missing', () => {
|
||||
const mod = freshModule();
|
||||
mod.__setBindingForTests({
|
||||
isSupported: () => true,
|
||||
pidFromHwnd: () => 0,
|
||||
resolveAudioRootPid: (pid) => pid,
|
||||
ProcessLoopback: function FakeNative() {
|
||||
return {start() {}, dispose() {}};
|
||||
},
|
||||
});
|
||||
const loopback = new mod.ProcessLoopback(4242);
|
||||
assert.equal(loopback.setScreenAudioSink({}), false);
|
||||
});
|
||||
@@ -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,28 @@
|
||||
{
|
||||
"name": "@fluxer/win-process-loopback",
|
||||
"version": "0.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"loader-diagnostics.cjs",
|
||||
"win-process-loopback.win32-x64-msvc.node",
|
||||
"win-process-loopback.win32-arm64-msvc.node"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
|
||||
"test": "cargo test --manifest-path Cargo.toml && node --test index.test.mjs",
|
||||
"test:loader": "node --test index.test.mjs"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,511 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use std::cell::UnsafeCell;
|
||||
use std::fmt;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
fn lock_free_list(free: &Mutex<Vec<usize>>) -> MutexGuard<'_, Vec<usize>> {
|
||||
free.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
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 = lock_free_list(&self.inner.free);
|
||||
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> {
|
||||
debug_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,
|
||||
});
|
||||
}
|
||||
debug_assert!(samples.len() <= cap);
|
||||
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
|
||||
debug_assert_eq!(buf.len(), cap);
|
||||
if !samples.is_empty() {
|
||||
buf[..samples.len()].copy_from_slice(samples);
|
||||
}
|
||||
self.filled_len = samples.len();
|
||||
debug_assert!(self.filled_len <= cap);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_with<F>(&mut self, sample_count: usize, fill: F) -> Result<(), PcmPoolError>
|
||||
where
|
||||
F: FnOnce(&mut [f32]),
|
||||
{
|
||||
debug_assert!(self.slot_index < self.pool.capacity as usize);
|
||||
let cap = self.pool.samples_per_slot as usize;
|
||||
if sample_count > cap {
|
||||
return Err(PcmPoolError::PayloadTooLarge {
|
||||
offered: sample_count,
|
||||
capacity: cap,
|
||||
});
|
||||
}
|
||||
debug_assert!(sample_count <= cap);
|
||||
|
||||
let cell = &self.pool.slots[self.slot_index];
|
||||
let buf: &mut [f32] = unsafe { &mut *cell.inner.get() };
|
||||
debug_assert_eq!(buf.len(), cap);
|
||||
fill(&mut buf[..sample_count]);
|
||||
self.filled_len = sample_count;
|
||||
debug_assert!(self.filled_len <= cap);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn data_slice(&self) -> &[f32] {
|
||||
debug_assert!(self.slot_index < self.pool.capacity as usize);
|
||||
debug_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 {
|
||||
debug_assert!(self.slot_index < self.pool.capacity as usize);
|
||||
debug_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;
|
||||
debug_assert!(cap > 0);
|
||||
debug_assert!(cap <= PCM_SLOT_SAMPLES_MAX);
|
||||
cap
|
||||
}
|
||||
|
||||
pub fn as_mut_ptr(&mut self) -> *mut f32 {
|
||||
debug_assert!(self.slot_index < self.pool.capacity as usize);
|
||||
debug_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() };
|
||||
debug_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) {
|
||||
debug_assert!(self.slot_index < self.pool.capacity as usize);
|
||||
debug_assert!(self.filled_len <= self.pool.samples_per_slot as usize);
|
||||
let len = self.filled_len;
|
||||
let ptr = self.as_mut_ptr();
|
||||
debug_assert!(!ptr.is_null());
|
||||
(ptr, len, self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PooledPcmFrame {
|
||||
fn drop(&mut self) {
|
||||
debug_assert!(self.slot_index < self.pool.capacity as usize);
|
||||
if self.slot_index >= self.pool.capacity as usize {
|
||||
return;
|
||||
}
|
||||
let mut free = lock_free_list(&self.pool.free);
|
||||
debug_assert!(free.len() < self.pool.capacity as usize);
|
||||
free.push(self.slot_index);
|
||||
let before = self.pool.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
debug_assert!(before >= 1);
|
||||
self.pool.released_total.fetch_add(1, Ordering::Relaxed);
|
||||
drop(free);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
const POOL_CAP: usize = 16;
|
||||
const SLOT_SAMPLES: usize = 2_048;
|
||||
|
||||
fn default_pool() -> PcmFramePool {
|
||||
PcmFramePool::new(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(POOL_CAP);
|
||||
for _ in 0..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, POOL_CAP);
|
||||
assert_eq!(stats.in_flight as usize, 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 write_with_fills_exactly_sample_count_samples() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write_with(8, |out| {
|
||||
assert_eq!(out.len(), 8);
|
||||
for (index, sample) in out.iter_mut().enumerate() {
|
||||
*sample = index as f32;
|
||||
}
|
||||
})
|
||||
.expect("payload fits");
|
||||
assert_eq!(slot.filled_len(), 8);
|
||||
assert_eq!(slot.data_slice(), &[0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_with_rejects_sample_count_larger_than_slot() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
let err = slot.write_with(128, |_| {}).unwrap_err();
|
||||
assert!(matches!(err, PcmPoolError::PayloadTooLarge { .. }));
|
||||
assert_eq!(slot.filled_len(), 0);
|
||||
}
|
||||
|
||||
#[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, POOL_CAP);
|
||||
assert_eq!(pool.samples_per_slot() as usize, SLOT_SAMPLES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_acquire_drop_cycle_keeps_accounting_consistent() {
|
||||
let pool = default_pool();
|
||||
for _ in 0..4 {
|
||||
let mut held = Vec::with_capacity(POOL_CAP);
|
||||
for _ in 0..POOL_CAP {
|
||||
held.push(pool.try_acquire().expect("slot in capacity"));
|
||||
}
|
||||
assert!(pool.try_acquire().is_none());
|
||||
drop(held);
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
assert_eq!(stats.released, stats.acquired);
|
||||
}
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.acquired as usize, POOL_CAP * 4);
|
||||
assert_eq!(stats.released as usize, POOL_CAP * 4);
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_parts_release_does_not_abort_accounting() {
|
||||
let pool = PcmFramePool::new(2, 64).expect("pool");
|
||||
let mut slot = pool.try_acquire().expect("slot");
|
||||
slot.write(&[0.25_f32; 16]).expect("write");
|
||||
let (ptr, len, owned) = slot.into_external_parts();
|
||||
assert!(!ptr.is_null());
|
||||
assert_eq!(len, 16);
|
||||
drop(owned);
|
||||
let stats = pool.stats();
|
||||
assert_eq!(stats.in_flight, 0);
|
||||
assert_eq!(stats.released, 1);
|
||||
assert_eq!(stats.acquired, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user